Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
26.57% covered (danger)
26.57%
38 / 143
15.38% covered (danger)
15.38%
2 / 13
CRAP
n/a
0 / 0
wpcomsh_map_feature_cap
51.11% covered (warning)
51.11%
23 / 45
0.00% covered (danger)
0.00%
0 / 1
112.18
wpcomsh_is_plugin_list_request
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
wpcomsh_is_theme_install_request
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
wpcomsh_is_woocommerce_onboarding_plugin_request
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
wpcomsh_is_woocommerce_connect_request
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
wpcomsh_get_rest_methods_as_array
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
wpcomsh_plupload_file_restrictions
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
wpcomsh_maybe_restrict_mimetypes
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
wpcomsh_maybe_redirect_to_calypso_plugin_pages
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
wpcomsh_gate_footer_credit_feature
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
wpcomsh_gate_jetpack_forms_integrations
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
wpcomsh_maybe_remove_jetpack_dashboard_menu_item
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
wpcomsh_remove_jetpack_manage_menu_item
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Collection of hooks that apply feature checks on Atomic sites.
4 *
5 * @package wpcomsh
6 */
7
8/**
9 * Disables theme and plugin related capabilities if the site doesn't have the required features.
10 *
11 * @param string[] $caps Primitive capabilities required of the user.
12 * @param string   $cap  Capability being checked.
13 * @return string[] Filtered primitive caps.
14 */
15function wpcomsh_map_feature_cap( $caps, $cap ) {
16
17    switch ( $cap ) {
18        case 'update_core':
19        case 'update_languages':
20            // Restrict access to Home > Updates on sites that can't manage plugins.
21            if ( ! wpcom_site_has_feature( WPCOM_Features::MANAGE_PLUGINS ) ) {
22                $caps[] = 'do_not_allow';
23            }
24            break;
25
26        case 'update_themes':
27        case 'delete_themes':
28            if ( ! wpcom_site_has_feature( WPCOM_Features::INSTALL_THEMES ) ) {
29                $caps[] = 'do_not_allow';
30            }
31            break;
32
33        case 'install_themes':
34            // Don't restrict `install_themes` when installing from WP.com.
35            if ( wpcomsh_is_theme_install_request() ) {
36                break;
37            }
38
39            if ( ! wpcom_site_has_feature( WPCOM_Features::INSTALL_THEMES ) ) {
40                $caps[] = 'do_not_allow';
41            }
42            break;
43
44        case 'edit_themes':
45            if ( ! wpcom_site_has_feature( WPCOM_Features::EDIT_THEMES ) ) {
46                $caps[] = 'do_not_allow';
47            }
48            break;
49
50        case 'upload_themes':
51            if ( ! wpcom_site_has_feature( WPCOM_Features::UPLOAD_THEMES ) ) {
52                $caps[] = 'do_not_allow';
53            }
54            break;
55
56        case 'activate_plugins':
57        case 'install_plugins':
58        case 'update_plugins':
59            /*
60             * Requests like /sites/207323956/plugins rely on the activate_plugins capability.
61             * Allow sites with the LIST_INSTALLED_PLUGINS feature to list the installed plugins.
62             */
63            if ( wpcomsh_is_plugin_list_request() && wpcom_site_has_feature( WPCOM_Features::LIST_INSTALLED_PLUGINS ) ) {
64                break;
65            }
66
67            /*
68             * Specifically allow install and activate permissions for WooCommerce onboarding plugins.
69             */
70            if (
71                wpcom_site_has_feature( WPCOM_Features::INSTALL_WOO_ONBOARDING_PLUGINS )
72                && (
73                    wpcomsh_is_woocommerce_onboarding_plugin_request()
74                    || wpcomsh_is_woocommerce_connect_request()
75                )
76            ) {
77                break;
78            }
79
80            if ( ! wpcom_site_has_feature( WPCOM_Features::INSTALL_PLUGINS ) ) {
81                $caps[] = 'do_not_allow';
82            }
83            break;
84
85        case 'upload_plugins':
86            if ( ! wpcom_site_has_feature( WPCOM_Features::UPLOAD_PLUGINS ) ) {
87                $caps[] = 'do_not_allow';
88            }
89            break;
90
91        case 'edit_plugins':
92            if ( ! wpcom_site_has_feature( WPCOM_Features::EDIT_PLUGINS ) ) {
93                $caps[] = 'do_not_allow';
94            }
95            break;
96    }
97
98    return $caps;
99}
100add_filter( 'map_meta_cap', 'wpcomsh_map_feature_cap', 10, 2 );
101
102/**
103 * Whether the current request is an XML-RPC request from Calypso to list plugins.
104 *
105 * @return bool
106 */
107function wpcomsh_is_plugin_list_request() {
108    return wpcomsh_is_xmlrpc_request_matching( '@^/sites/([^/]+)/plugins$@' );
109}
110
111/**
112 * Whether the current request is an XML-RPC request from Calypso to install a WP.com theme.
113 *
114 * @return bool
115 */
116function wpcomsh_is_theme_install_request() {
117    return wpcomsh_is_xmlrpc_request_matching( '@/sites/(.+)/themes/(.+)/install@' );
118}
119
120/**
121 * Whether the current request is a REST API request from the WooCommerce onboarding tasks
122 * trying to fetch a recommended payment gateway, or perform installation/activation of a plugin.
123 *
124 * @return bool
125 */
126function wpcomsh_is_woocommerce_onboarding_plugin_request() {
127    $wp_json_prefix = preg_quote( rest_get_url_prefix(), '@' );
128
129    // Check if we're looking up payment gateway suggestions.
130    if ( wpcomsh_is_wp_rest_request_matching( '@^/' . $wp_json_prefix . '/wc-admin/payment-gateway-suggestions@' ) ) {
131        return true;
132    }
133
134    $editable_methods = wpcomsh_get_rest_methods_as_array( \WP_REST_Server::EDITABLE );
135
136    if ( ! wpcomsh_is_wp_rest_request_matching( '@^/' . $wp_json_prefix . '/wc-admin/plugins/install@', $editable_methods ) && ! wpcomsh_is_wp_rest_request_matching( '@^/' . $wp_json_prefix . '/wc-admin/plugins/activate@', $editable_methods ) ) {
137        return false;
138    }
139
140    $wp_referer = wp_get_referer();
141
142    if ( empty( $wp_referer ) ) {
143        return false;
144    }
145
146    // Check if we're requesting a plugin installation or activation from WooCommerce onboarding tasks.
147
148    // User is retrying install from the payment gateway install/setup page.
149    if ( str_starts_with( $wp_referer, admin_url( 'admin.php?page=wc-admin&task=payments&id=' ) ) ) {
150        return true;
151    }
152
153    $permitted_admin_paths = array(
154        // Payments onboarding task
155        'admin.php?page=wc-admin&task=payments',
156        // WooCommerce Payments onboarding task
157        'admin.php?page=wc-admin&task=woocommerce-payments',
158        // Tax onboarding task
159        'admin.php?page=wc-admin&task=tax',
160        // WooCommerce Settings -> Payments tab
161        'admin.php?page=wc-settings&tab=checkout',
162    );
163
164    foreach ( $permitted_admin_paths as $permitted_admin_path ) {
165        if ( $wp_referer === admin_url( $permitted_admin_path ) ) {
166            return true;
167        }
168    }
169
170    return false;
171}
172
173/**
174 * Whether the current request is a REST API request to perform a
175 * WooCommerce connection activity.
176 *
177 * @return bool
178 */
179function wpcomsh_is_woocommerce_connect_request() {
180    $wp_json_prefix = preg_quote( rest_get_url_prefix(), '@' );
181
182    $editable_methods = wpcomsh_get_rest_methods_as_array( \WP_REST_Server::EDITABLE );
183
184    $permitted_connect_api_paths = array(
185        '/wc-admin/plugins/connect-jetpack' => \WP_REST_Server::READABLE,
186        '/wc-admin/plugins/connect-square'  => $editable_methods,
187        '/wc-admin/plugins/connect-wcpay'   => $editable_methods,
188    );
189
190    foreach ( $permitted_connect_api_paths as $permitted_connect_api_path => $supported_methods ) {
191        if ( wpcomsh_is_wp_rest_request_matching( '@^/' . $wp_json_prefix . $permitted_connect_api_path . '@', $supported_methods ) ) {
192            return true;
193        }
194    }
195
196    return false;
197}
198
199/**
200 * Helper method to split an HTTP method string from one of the REST constants in {@see \WP_REST_Server}
201 *
202 * @param string $method The HTTP method(s), generally drawn from constants in \WP_REST_Server.
203 * @return string[]
204 */
205function wpcomsh_get_rest_methods_as_array( $method ) {
206    return array_map(
207        'trim',
208        explode( ',', $method )
209    );
210}
211
212/**
213 * Restrict selectable files in the Media uploader by setting Plupload filters only.
214 * Minimal change: selection-only; no server-side mime policy changes.
215 *
216 * @param array $options Plupload options.
217 * @return array Plupload options with restricted mime types.
218 */
219function wpcomsh_plupload_file_restrictions( $options ) {
220    $mimes_map = get_allowed_mime_types();
221
222    $allowed_extensions = array();
223    $allowed_mime_types = array();
224
225    foreach ( $mimes_map as $ext_pattern => $mime ) {
226        // Extract real file extensions (filter out 'x-' prefixed ones)
227        // This prevents issues with non-standard extensions like 'x-wav' that aren't real file extensions
228        $extensions = explode( '|', $ext_pattern );
229        foreach ( $extensions as $ext ) {
230            // Only include extensions that don't start with 'x-'
231            if ( ! str_starts_with( $ext, 'x-' ) ) {
232                $allowed_extensions[] = $ext;
233            }
234        }
235
236        $allowed_mime_types[] = $mime;
237    }
238
239    if ( empty( $allowed_extensions ) ) {
240        return $options;
241    }
242
243    if ( ! isset( $options['filters'] ) || ! is_array( $options['filters'] ) ) {
244        $options['filters'] = array();
245    }
246    $options['filters']['mime_types'] = array(
247        array( 'extensions' => implode( ',', $allowed_extensions ) ),
248    );
249
250    // Store MIME types for potential use in HTML file inputs
251    $options['allowed_mime_types'] = $allowed_mime_types;
252
253    return $options;
254}
255
256add_action( 'plupload_init', 'wpcomsh_plupload_file_restrictions' );
257
258/**
259 * Restricts the allowed mime types if the site have does NOT have access to the required feature.
260 *
261 * @param array $mimes Mime types keyed by the file extension regex corresponding to those types.
262 * @return array Allowed mime types.
263 */
264function wpcomsh_maybe_restrict_mimetypes( $mimes ) {
265    $disallowed_mimes = array();
266    if ( ! wpcom_site_has_feature( WPCOM_Features::UPGRADED_UPLOAD_FILETYPES ) ) {
267        // Copied from WPCOM (see `WPCOM_UPLOAD_FILETYPES_FOR_UPGRADES` in `.config/wpcom-options.php`).
268        $upgraded_upload_filetypes = 'mp3 m4a wav ogg zip txt tiff bmp';
269        $disallowed_mimes          = array_merge( $disallowed_mimes, explode( ' ', $upgraded_upload_filetypes ) );
270    }
271
272    // Allow video uploads if site has either VIDEOPRESS or UPLOAD_VIDEO_FILES feature.
273    // Sites with UPLOAD_VIDEO_FILES can upload videos without VideoPress (e.g. Premium plans with gating-business-q1 sticker).
274    if ( ! wpcom_site_can_upload_videos() ) {
275        // Copied from WPCOM (see `WPCOM_UPLOAD_FILETYPES_FOR_VIDEOS` in `.config/wpcom-options.php`).
276        $video_upload_filetypes = 'ogv mp4 m4v mov wmv avi mpg 3gp 3g2';
277        $disallowed_mimes       = array_merge( $disallowed_mimes, explode( ' ', $video_upload_filetypes ) );
278    }
279
280    // TTML subtitles require VideoPress specifically (not just video upload capability).
281    if ( ! wpcom_site_has_feature( WPCOM_Features::VIDEOPRESS ) ) {
282        $disallowed_mimes[] = 'ttml';
283    }
284
285    foreach ( $disallowed_mimes as $disallowed_mime ) {
286        foreach ( $mimes as $ext_pattern => $mime ) {
287            if ( strpos( $ext_pattern, $disallowed_mime ) !== false ) {
288                unset( $mimes[ $ext_pattern ] );
289            }
290        }
291    }
292
293    return $mimes;
294}
295add_filter( 'upload_mimes', 'wpcomsh_maybe_restrict_mimetypes', PHP_INT_MAX );
296
297/**
298 * Redirect plugins.php and plugin-install.php to their Calypso counterparts if this site doesn't have the
299 * MANAGE_PLUGINS feature.
300 */
301function wpcomsh_maybe_redirect_to_calypso_plugin_pages() {
302    $request_uri = wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore
303    // Quick exit if on non-plugin page.
304    if ( false === strpos( $request_uri, '/wp-admin/plugin' ) ) {
305        return;
306    }
307
308    if ( wpcom_site_has_feature( WPCOM_Features::MANAGE_PLUGINS ) ) {
309        return;
310    }
311
312    if ( ! class_exists( 'Automattic\Jetpack\Status' ) ) {
313        return;
314    }
315
316    $site = ( new Automattic\Jetpack\Status() )->get_site_suffix();
317
318    // Redirect to calypso when user is trying to install plugin.
319    if ( 0 === strpos( $request_uri, '/wp-admin/plugin-install.php' ) ) {
320        wp_safe_redirect( 'https://wordpress.com/plugins/' . $site );
321        exit( 0 );
322    }
323}
324add_action( 'plugins_loaded', 'wpcomsh_maybe_redirect_to_calypso_plugin_pages' );
325
326/**
327 * This function manages the feature that allows the user to hide the "WP.com Footer Credit".
328 * The footer credit feature lives in a separate platform-agnostic repository, so we rely on filters to manage it.
329 * Pressable Footer Credit repository: https://github.com/Automattic/at-pressable-footer-credit
330 *
331 * @return bool
332 */
333function wpcomsh_gate_footer_credit_feature() {
334    return wpcom_site_has_feature( WPCOM_Features::NO_WPCOM_BRANDING );
335}
336add_filter( 'wpcom_better_footer_credit_can_customize', 'wpcomsh_gate_footer_credit_feature' );
337
338/**
339 * Controls whether Jetpack Forms integrations feature is enabled based on site plan.
340 *
341 * @return bool
342 */
343function wpcomsh_gate_jetpack_forms_integrations() {
344    return wpcom_site_has_feature( WPCOM_Features::FORM_INTEGRATIONS );
345}
346add_filter( 'jetpack_forms_is_integrations_enabled', 'wpcomsh_gate_jetpack_forms_integrations' );
347
348/**
349 * Remove the Jetpack > Dashboard menu if the site doesn't have the required feature.
350 */
351function wpcomsh_maybe_remove_jetpack_dashboard_menu_item() {
352    if ( wpcom_site_has_feature( WPCOM_Features::JETPACK_DASHBOARD ) ) {
353        return;
354    }
355
356    remove_submenu_page( 'jetpack', 'jetpack#/dashboard' );
357}
358add_action( 'admin_menu', 'wpcomsh_maybe_remove_jetpack_dashboard_menu_item', 1000 ); // Jetpack uses 998.
359
360/**
361 * Remove Jetpack > Manage menu item as part of the wpcom navigation redesign.
362 * For more context, see https://github.com/Automattic/dotcom-forge/issues/5824.
363 */
364function wpcomsh_remove_jetpack_manage_menu_item() {
365    if ( ! class_exists( 'Jetpack' ) || ! class_exists( 'Jetpack_Options' ) || get_option( 'wpcom_admin_interface' ) !== 'wp-admin' ) {
366        return;
367    }
368    $blog_id = Jetpack_Options::get_option( 'id' );
369    remove_submenu_page( 'jetpack', 'https://jetpack.com/redirect/?source=cloud-manage-dashboard-wp-menu&#038;site=' . $blog_id );
370}
371
372add_action( 'admin_menu', 'wpcomsh_remove_jetpack_manage_menu_item', 1001 ); // Automattic\Jetpack\Admin_UI\Admin_Menu uses 1000.