Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
19.75% covered (danger)
19.75%
64 / 324
9.30% covered (danger)
9.30%
4 / 43
CRAP
n/a
0 / 0
Private_Site\is_module_active
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Private_Site\admin_init
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
90
Private_Site\init
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
Private_Site\muplugins_loaded
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
Private_Site\fetch_option_from_wpcom
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
Private_Site\site_is_coming_soon
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
Private_Site\site_is_public_coming_soon
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
Private_Site\site_launch_status
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
Private_Site\is_launched
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
Private_Site\site_is_private
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
Private_Site\should_prevent_site_access
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
10.37
Private_Site\register_additional_jetpack_xmlrpc_methods
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
Private_Site\get_closest_thumbnail_size_url
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
12.17
Private_Site\get_read_access_cookies
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
4
Private_Site\is_jetpack_admin_ajax_request
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
Private_Site\send_access_denied_error_response
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
Private_Site\parse_request
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
Private_Site\original_request_url
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
Private_Site\cache_option_on_update_site_settings
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
Private_Site\maybe_print_robots_txt
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
Private_Site\rest_dispatch_request
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
Private_Site\xmlrpc_methods_limit_to_allowed_list
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
Private_Site\is_private_blog_user
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Private_Site\blog_user_can
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
5.67
Private_Site\mask_site_name
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
Private_Site\remove_mask_site_name_filter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
Private_Site\preprocess_comment
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
Private_Site\is_jetpack_connected
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
Private_Site\is_site_preview
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
Private_Site\site_preview_source
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
156
Private_Site\access_denied_template_path
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
Private_Site\remove_privacy_option_from_whitelist
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
Private_Site\should_update_privacy_selector
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
Private_Site\private_robots_txt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
Private_Site\rename_subscriber_role_to_viewer
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
Private_Site\translate_viewer_role
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
4.59
Private_Site\private_no_pinning
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
Private_Site\hide_opml
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
Private_Site\filter_jetpack_active_modules
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
Private_Site\filter_jetpack_get_available_modules
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
Private_Site\use_classic_editor_if_requested
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
30
Private_Site\disable_classic_editor_plugin_when_needed
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
7.91
Private_Site\should_override_editor_with_classic_editor
36.36% covered (danger)
36.36%
4 / 11
0.00% covered (danger)
0.00%
0 / 1
19.63
1<?php
2/**
3 * Private Site
4 * Functionality to make sites private and only accessible to members with appropriate capabilities.
5 *
6 * @package private-site
7 */
8
9namespace Private_Site;
10
11use Automattic\Jetpack\Connection\Rest_Authentication;
12use Jetpack;
13use WP_Error;
14use WP_REST_Request;
15use function esc_html_e;
16use function get_current_blog_id;
17use function get_option;
18use function remove_filter;
19use function status_header;
20use function wp_get_current_user;
21use function wp_send_json_error;
22
23/**
24 * We disable some Jetpack modules if the site is private and atomic
25 *
26 * !!! KEEP THIS LIST IN SYNC WITH THE LIST ON WPCOM !!!
27 *
28 * @see private_blog_filter_jetpack_active_modules in wp-content/mu-plugins/private-blog.php (update this to an actual link when D41356-code lands)
29 */
30const DISABLED_JETPACK_MODULES_WHEN_PRIVATE = array(
31    'publicize',
32    'sharedaddy',
33    'json-api',
34    'enhanced-distribution',
35    'google-analytics',
36    'photon',
37    'photon-cdn',
38    'sitemaps',
39    'verification-tools',
40    'wordads',
41);
42
43/**
44 * This function was used when the feature was in testing. Currently we're trying it for a some WP.com users.
45 * Wpcomsh is not aware of any test groups so this function just says return true for now. Once the entire feature
46 * is ready to be rolled out to 100% of users, it's okay to completely remove this function and any checks.
47 */
48function is_module_active() {
49    return true;
50}
51
52/**
53 * Setup when wp-admin gets initialized.
54 */
55function admin_init() {
56    if ( ! is_module_active() ) {
57        return;
58    }
59
60    /*
61     * Don't add the action when we don't intend to alter core behavior.
62     * The mere presence of a `blog_privacy_selector` hook changes things!
63     *
64     * @see https://github.com/WordPress/wordpress-develop/blob/fd479f953731bbf522b32b9d95eeb68bc455c418/src/wp-admin/options-reading.php#L178-L202
65     */
66    if ( ( is_jetpack_connected() || site_is_private() ) && should_update_privacy_selector() ) {
67        // Prevent wp-admin from touching blog_public option.
68        add_filter( 'allowed_options', '\Private_Site\remove_privacy_option_from_whitelist' );
69    }
70
71    if ( should_override_editor_with_classic_editor() ) {
72        // Classic editor for private+atomic users is now handled in wp-admin instead of Calypso. @see use_classic_editor_if_requested
73        add_action( 'load-post.php', '\Private_Site\use_classic_editor_if_requested', - 1000 );
74        add_action( 'load-post-new.php', '\Private_Site\use_classic_editor_if_requested', - 1000 );
75    }
76
77    // Many AJAX actions do not execute the `parse_request` action. Catch them here.
78    if ( site_is_private() && should_prevent_site_access() && ! is_jetpack_admin_ajax_request() ) {
79        send_access_denied_error_response();
80    }
81}
82add_action( 'admin_init', '\Private_Site\admin_init' );
83
84/**
85 * Setup when WordPress gets initialized.
86 */
87function init() {
88    if ( ! is_module_active() ) {
89        return;
90    }
91
92    // Update `wpcom_coming_soon` cached value when it's updated on WP.com.
93    add_filter( 'rest_api_update_site_settings', '\Private_Site\cache_option_on_update_site_settings', 10, 2 );
94
95    if ( ! site_is_private() ) {
96        return;
97    }
98
99    // Scrutinize most requests.
100    add_action( 'parse_request', '\Private_Site\parse_request', 100 );
101
102    // Scrutinize REST API requests.
103    add_filter( 'rest_dispatch_request', '\Private_Site\rest_dispatch_request', 10, 3 );
104
105    // Prevent Pinterest pinning.
106    add_action( 'wp_head', '\Private_Site\private_no_pinning' );
107
108    // Prevent leaking site information via OPML.
109    add_action( 'opml_head', '\Private_Site\hide_opml' );
110
111    // Mask the blog name on the login screen etc.
112    add_filter( 'bloginfo', '\Private_Site\mask_site_name', 3, 2 );
113
114    // Block incoming comments for non-users.
115    add_filter( 'preprocess_comment', '\Private_Site\preprocess_comment', 0 );
116
117    // Robots requests are allowed via parse_request / maybe_print_robots_txt
118    add_filter( 'robots_txt', '\Private_Site\private_robots_txt' );
119
120    // @TODO pre_trackback_post maybe..?
121
122    // @TODO add "lock" toolbar item when private
123}
124add_action( 'init', '\Private_Site\init' );
125
126/**
127 * Jetpack-specific hooks.
128 */
129function muplugins_loaded() {
130    if ( ! is_module_active() ) {
131        return;
132    }
133
134    if ( ! site_is_private() ) {
135        return;
136    }
137
138    // Only allow Jetpack XMLRPC methods -- Jetpack handles verifying the token, request signature, etc.
139    add_filter( 'xmlrpc_methods', '\Private_Site\xmlrpc_methods_limit_to_allowed_list' );
140
141    // Register additional Jetpack XMLRPC methods.
142    add_filter( 'jetpack_xmlrpc_methods', '\Private_Site\register_additional_jetpack_xmlrpc_methods' );
143
144    // Lift the blog name mask prior to Jetpack sync activity.
145    add_action( 'jetpack_sync_before_send_queue_full_sync', '\Private_Site\remove_mask_site_name_filter' );
146    add_action( 'jetpack_sync_before_send_queue_sync', '\Private_Site\remove_mask_site_name_filter' );
147
148    // Prevent Jetpack certain modules from running while the site is private.
149    add_filter( 'jetpack_active_modules', '\Private_Site\filter_jetpack_active_modules' );
150    add_filter( 'jetpack_get_available_modules', '\Private_Site\filter_jetpack_get_available_modules' );
151    add_filter( 'jetpack_force_disable_site_accelerator', '__return_true' );
152}
153add_action( 'muplugins_loaded', '\Private_Site\muplugins_loaded' );
154
155/**
156 * Fetches an option from the Jetpack cloud site.
157 *
158 * @param string $option Name of option to be retrieved.
159 *
160 * @return mixed  Option value.
161 */
162function fetch_option_from_wpcom( $option ) {
163    if ( ! is_jetpack_connected() ) {
164        return false;
165    }
166
167    $jetpack = Jetpack::init();
168    if ( ! method_exists( $jetpack, 'get_cloud_site_options' ) ) {
169        return false;
170    }
171    $options = $jetpack->get_cloud_site_options( array( $option ) );
172
173    return $options[ $option ] ?? false;
174}
175
176/**
177 * The site is determined to be "coming soon" when both:
178 * - The site is private (@see site_is_private)
179 * - The `wpcom_coming_soon` option on the "cloud site" is truthy
180 *
181 * As such, "coming soon" is just a flavor of private sites and is always false on sites that are public.
182 *
183 * @return bool
184 */
185function site_is_coming_soon(): bool {
186    if ( ! site_is_private() ) {
187        return false;
188    }
189
190    $wpcom_coming_soon = wp_cache_get( 'wpcom_coming_soon', 'wpcomsh' );
191
192    if ( false === $wpcom_coming_soon ) {
193        $wpcom_coming_soon = (int) fetch_option_from_wpcom( 'wpcom_coming_soon' );
194        wp_cache_set( 'wpcom_coming_soon', $wpcom_coming_soon, 'wpcomsh' );
195    }
196
197    return (bool) $wpcom_coming_soon;
198}
199
200/**
201 * Checks whether a site is in public coming soon mode.
202 * The site is determined to be "public coming soon" when both:
203 * - The site is not private (@see site_is_private)
204 * - The `wpcom_public_coming_soon` option is truthy
205 *
206 * In feature-plugins/full-site-editing.php we have an option hook `wpcomsh_coming_soon_get_atomic_persistent_data()` that returns the
207 * value of Atomic persistence data.
208 *
209 * @return bool
210 */
211function site_is_public_coming_soon(): bool {
212    if ( site_is_private() ) {
213        return false;
214    }
215
216    return 1 === (int) get_option( 'wpcom_public_coming_soon' );
217}
218
219/**
220 * Sites are created as "unlaunched" and can only be launched once.
221 *
222 * @return string
223 */
224function site_launch_status(): string {
225    // We need to check for launch status for private by default sites and coming soon + public by default sites.
226    if ( ! site_is_private() && ! site_is_public_coming_soon() ) {
227        return '';
228    }
229
230    $launch_status = wp_cache_get( 'wpcom_launch_status', 'wpcomsh' );
231
232    if ( ! $launch_status ) {
233        $launch_status = (string) fetch_option_from_wpcom( 'launch-status' );
234        wp_cache_set( 'wpcom_launch_status', $launch_status, 'wpcomsh' );
235    }
236
237    return (string) $launch_status;
238}
239
240/**
241 * Whether the site is launched.
242 *
243 * @return bool
244 */
245function is_launched() {
246    return 'launched' === site_launch_status();
247}
248
249/**
250 * Hooked into filter: `pre_update_option_blog_public`.
251 *
252 * Sets a secondary option (`wpcom_blog_public_updated`) to `1` when the `blog_public` option is updated
253 * This will be used to determine that the option has been set after the launch of the Private Site module.
254 *
255 * This is in contrast to WordPress.com Simple sites which relies on the `blog_public` option.
256 *
257 * @return bool
258 */
259function site_is_private() {
260    return defined( 'AT_PRIVACY_MODEL' ) && AT_PRIVACY_MODEL === 'wp_uploads';
261}
262
263/**
264 * Determine if site access should be blocked for various types of requests.
265 * This function is cached for subsequent calls so we can use it gratuitously.
266 *
267 * IMPORTANT: This function assumes a site is set to private.
268 * This module is structured such that `site_is_private` is consulted prior to calling `should_prevent_site_access`
269 * You should likely do the same if you are building on to this.
270 *
271 * @return bool
272 */
273function should_prevent_site_access() {
274    static $cached;
275
276    if ( isset( $cached ) ) {
277        return $cached;
278    }
279
280    /*
281     * If Jetpack is enabled, check to see if blog token requests are authenticated before disallowing access.
282     * This allows Jetpack Sync to run for private sites.
283     */
284    if ( class_exists( 'Automattic\Jetpack\Connection\Rest_Authentication' ) && Rest_Authentication::is_signed_with_blog_token() ) {
285        return $cached = false; // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments
286    }
287
288    if (
289        ( defined( 'WP_CLI' ) && WP_CLI ) ||
290        ( defined( 'WP_IMPORTING' ) && WP_IMPORTING )
291    ) {
292        // WP-CLI & Importers are always allowed.
293        return $cached = false; // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments
294    }
295
296    return $cached = ! is_private_blog_user(); // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments
297}
298
299/**
300 * Adds custom XML-RPC endpoints for private-site.
301 *
302 * @param array $methods A list of registered XML-RPC methods.
303 *
304 * @return array
305 */
306function register_additional_jetpack_xmlrpc_methods( $methods ) {
307    return array_merge(
308        $methods,
309        array(
310            'jetpack.getClosestThumbnailSizeUrl' => '\Private_Site\get_closest_thumbnail_size_url',
311            'jetpack.getReadAccessCookies'       => '\Private_Site\get_read_access_cookies',
312        )
313    );
314}
315
316/**
317 * Returns the closest thumbnail size URL.
318 *
319 * @param array $args Image arguments.
320 *
321 * @return array|false
322 */
323function get_closest_thumbnail_size_url( $args ) {
324    if ( ! isset( $args['url'] ) || ! isset( $args['width'] ) || ! isset( $args['height'] ) ) {
325        return false;
326    }
327
328    $id = attachment_url_to_postid( $args['url'] );
329    if ( ! $id ) {
330        return false;
331    }
332
333    $result = wp_get_attachment_image_src( $id, array( $args['width'], $args['height'] ) );
334    if ( ! $result ) {
335        return false;
336    }
337
338    return $result;
339}
340
341/**
342 * We use this XMLPC method to ensure wp.com is able to fetch read access cookies even
343 * when Jetpack SSO module is disabled.
344 *
345 * @param array $args Cookie args.
346 *
347 * @return array|WP_Error
348 */
349function get_read_access_cookies( $args ) {
350    $user = get_user_by( 'id', intval( $args[0] ) );
351    if ( ! $user ) {
352        return new WP_Error(
353            'account_not_found',
354            'Account not found. If you already have an account, make sure you have connected to WordPress.com.'
355        );
356    }
357    if ( ! $user->has_cap( 'read' ) ) {
358        return new WP_Error( 'access error', 'User does not have "read" capabilities' );
359    }
360
361    add_filter( 'send_auth_cookies', '__return_false' );
362    add_filter(
363        'auth_cookie_expiration',
364        function () use ( $args ) {
365            return $args[1];
366        },
367        1000
368    );
369
370    $logged_in_cookie            = null;
371    $logged_in_cookie_expiration = null;
372
373    add_action(
374        'set_logged_in_cookie',
375        function ( $_cookie, $_expires, $_expiration ) use ( &$logged_in_cookie, &$logged_in_cookie_expiration ) {
376            $logged_in_cookie            = $_cookie;
377            $logged_in_cookie_expiration = $_expiration;
378        },
379        10,
380        3
381    );
382    wp_set_auth_cookie( $user->ID, true );
383    if ( ! $logged_in_cookie ) {
384        return new WP_Error( 'authorization_failed', 'Authorization cookie was not found' );
385    }
386
387    return array(
388        array( LOGGED_IN_COOKIE, $logged_in_cookie, $logged_in_cookie_expiration ),
389    );
390}
391
392/**
393 * Checks if current request is a request sent to admin-ajax.php and initiated by remote
394 * Jetpack API.
395 *
396 * @return bool
397 */
398function is_jetpack_admin_ajax_request() {
399    // phpcs:disable WordPress.Security
400    return (
401        isset( $_SERVER['REQUEST_URI'] ) &&
402        substr( $_SERVER['REQUEST_URI'], 0, 24 ) === '/wp-admin/admin-ajax.php' &&
403        isset( $_SERVER['HTTP_AUTHORIZATION'] ) &&
404        substr( $_SERVER['HTTP_AUTHORIZATION'], 0, 9 ) === 'X_JETPACK' &&
405        array_key_exists( 'action', $_POST ) &&
406        substr( $_POST['action'], 0, 8 ) === 'jetpack_'
407    );
408    // phpcs:enable
409}
410
411/**
412 * Tell the client that the site is private and they do not have access.
413 * This function always exits PHP (`wp_send_json_error` calls `wp_die` / `die`)
414 *
415 * @return never
416 */
417function send_access_denied_error_response() {
418    global $wp;
419
420    if ( ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || 'admin-ajax.php' === ( $wp->query_vars['pagename'] ?? '' ) ) {
421        wp_send_json_error(
422            array(
423                'code'    => 'private_site',
424                'message' => __(
425                    'This site is private.',
426                    'wpcomsh'
427                ),
428            ),
429            null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
430            JSON_UNESCAPED_SLASHES
431        );
432    }
433
434    require access_denied_template_path();
435    exit( 0 );
436}
437
438/**
439 * Prints robots.txt and prevents access if necessary.
440 */
441function parse_request() {
442    if ( maybe_print_robots_txt() ) {
443        // If robots.txt was requested, go ahead & serve our hard-coded version & bail
444        exit( 0 );
445    }
446
447    if ( should_prevent_site_access() ) {
448        send_access_denied_error_response();
449    }
450}
451
452/**
453 * Returns the original request URL.
454 *
455 * @return string
456 */
457function original_request_url() {
458    // phpcs:disable WordPress.Security
459    $origin = ( is_ssl() ? 'https://' : 'http://' ) . $_SERVER['SERVER_NAME'];
460
461    if ( ! empty( $_SERVER['SERVER_PORT'] ) && ! in_array( $_SERVER['SERVER_PORT'], array( 80, 443 ) ) ) { //phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
462        $origin .= ':' . $_SERVER['SERVER_PORT'];
463    }
464
465    return $origin . strtok( $_SERVER['REQUEST_URI'], '?' );
466    // phpcs:enable
467}
468
469/**
470 * Hooked into `rest_api_update_site_settings` filter.
471 *
472 * This filter updates the cached value of `wpcom_coming_soon` or `launch-status`
473 * whenever `wpcom_coming_soon` option is changed on WP.com and this plugin
474 * is notified via Jetpack-WPCOM REST API bridge.
475 *
476 * @param array $input            Filtered POST input.
477 * @param array $unfiltered_input Raw and unfiltered POST input.
478 *
479 * @return array
480 */
481function cache_option_on_update_site_settings( $input, $unfiltered_input ) {
482    if ( array_key_exists( 'wpcom_coming_soon', $unfiltered_input ) ) {
483        wp_cache_set( 'wpcom_coming_soon', $unfiltered_input['wpcom_coming_soon'], 'wpcomsh' );
484    }
485
486    if ( array_key_exists( 'launch-status', $unfiltered_input ) ) {
487        wp_cache_set( 'wpcom_launch_status', $unfiltered_input['launch-status'], 'wpcomsh' );
488    }
489
490    return $input;
491}
492
493/**
494 * Requests for the "Robots" file are not blocked by the site being marked as private.
495 * If the client has requested the `/robots.txt` file, execute the `do_robots` action and return true.
496 * This function compares the request to the site_url() so it also supports subdomain installs.
497 *
498 * @see `private_robots_txt`
499 * @return bool
500 */
501function maybe_print_robots_txt() {
502    if ( untrailingslashit( original_request_url() ) === site_url( '/robots.txt' ) ) {
503        do_action( 'do_robots' );
504        return true;
505    }
506
507    return false;
508}
509
510/**
511 * Scrutinize REST API Requests _after_ the permissions checks have been applied
512 * This enforces nonce & token checking on content endpoints to prevent CSRF-style attacks
513 * If using cookie auth, clients must send a valid nonce in order to access content endpoints
514 *
515 * @see rest_dispatch_request https://core.trac.wordpress.org/browser/tags/5.2.3/src/wp-includes/rest-api/class-wp-rest-server.php#L940
516 *
517 * @param mixed           $dispatch_result Dispatch result, will be used if not empty.
518 * @param WP_REST_Request $request         Request used to generate the response.
519 * @param string          $route           Route matched for the request.
520 *
521 * @return WP_Error|null  WP_Error on disallowed, null on ok
522 */
523function rest_dispatch_request( $dispatch_result, $request, $route ) {
524    // Don't clobber other plugins.
525    if ( $dispatch_result !== null ) {
526        return $dispatch_result;
527    }
528
529    /*
530     * Allow certain endpoints for plugin-based authentication methods.
531     * These are "anchored" on the left side with `^/`, but not the right, so include the trailing `/`
532     */
533    $allowed_routes = array(
534        '2fa/', // https://wordpress.org/plugins/application-passwords/
535        'jwt-auth/', // https://wordpress.org/plugins/jwt-authentication-for-wp-rest-api/
536        'oauth1/', // https://wordpress.org/plugins/rest-api-oauth1/
537    );
538
539    if ( preg_match( '#^/(' . implode( '|', $allowed_routes ) . ')#', $route ) ) {
540        return null;
541    }
542
543    if ( should_prevent_site_access() ) {
544        return new WP_Error( 'private_site', __( 'This site is private.', 'wpcomsh' ), array( 'status' => 403 ) );
545    }
546
547    return null;
548}
549
550/**
551 * Limits XML-RPC endpoints to the ones that are allowed.
552 *
553 * @param array $methods List of XML-RPC methods.
554 *
555 * @return array
556 */
557function xmlrpc_methods_limit_to_allowed_list( $methods ) {
558    if ( should_prevent_site_access() ) {
559        return array_filter(
560            $methods,
561            function ( $key ) {
562                // Permits the Jetpack debug tool. @see p58i-8OX-p2#comment-46085.
563                return 'demo.sayHello' === $key || preg_match( '/^jetpack\..+/', $key );
564            },
565            ARRAY_FILTER_USE_KEY
566        );
567    }
568
569    return $methods;
570}
571
572/**
573 * Checks if the current user is a member of the current site.
574 *
575 * @return bool
576 */
577function is_private_blog_user() {
578    return blog_user_can();
579}
580
581/**
582 * Checks the current user's capabilities for the current site.
583 *
584 * Does not check whether the blog is private. Works on current blog & user.
585 * Returns true for super admins.
586 *
587 * @param string $capability Capability name.
588 * @return bool
589 */
590function blog_user_can( $capability = 'read' ) {
591    $user = wp_get_current_user();
592    if ( ! $user->ID ) {
593        return false;
594    }
595
596    $blog_id = get_current_blog_id();
597    if ( ! $blog_id ) {
598        return false;
599    }
600
601    // Check if the user has read permissions.
602    $the_user = clone $user;
603    $the_user->for_site( $blog_id );
604    return $the_user->has_cap( $capability );
605}
606
607/**
608 * Replaces the the site's "name" & "title" values with "Private Site"
609 * Added to the `bloginfo` filter in our `init` function
610 *
611 * @param mixed $value The requested non-URL site information.
612 * @param mixed $what  Type of information requested.
613 * @return string The potentially modified bloginfo value
614 */
615function mask_site_name( $value, $what ) {
616    if ( ! site_is_coming_soon() && should_prevent_site_access() && in_array( $what, array( 'name', 'title' ), true ) ) {
617        return __( 'Private Site', 'wpcomsh' );
618    }
619
620    return $value;
621}
622
623/**
624 * Remove the mask_site_name filter.
625 */
626function remove_mask_site_name_filter() {
627    remove_filter( 'bloginfo', '\Private_Site\mask_site_name' );
628}
629
630/**
631 * Filters new comments so that users can't comment on private blogs.
632 *
633 * @param array $comment Documented in wp-includes/comment.php.
634 *
635 * @return array
636 */
637function preprocess_comment( $comment ) {
638    if ( should_prevent_site_access() ) {
639        require access_denied_template_path();
640        exit( 0 );
641    }
642    return $comment;
643}
644
645/**
646 * Whether the current site is connected to Jetpack.
647 *
648 * @return bool
649 */
650function is_jetpack_connected() {
651    return class_exists( 'Jetpack' ) && Jetpack::is_connection_ready();
652}
653
654/**
655 * Whether we're in preview mode.
656 *
657 * @return bool
658 */
659function is_site_preview() {
660    return site_preview_source() !== false;
661}
662
663/**
664 * Returns the site preview source.
665 *
666 * @return false|string
667 */
668function site_preview_source() {
669    // phpcs:disable WordPress.Security.NonceVerification.Recommended -- This request doesn't change any data.
670    $ua                = isset( $_SERVER['HTTP_USER_AGENT'] )
671            ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
672            : '';
673    $apps_ua_fragments = array(
674        'iphone-app'  => ' wp-iphone/',
675        'android-app' => ' wp-android/',
676        'desktop-app' => ' WordPressDesktop/',
677    );
678    foreach ( $apps_ua_fragments as $source => $fragment ) {
679        if ( strpos( $ua, $fragment ) !== false ) {
680            return $source;
681        }
682    }
683
684    if (
685        (
686            isset( $_GET['iframe'] )
687            && 'true' === $_GET['iframe']
688            && (
689                ( isset( $_GET['theme_preview'] ) && 'true' === $_GET['theme_preview'] )
690                || (
691                    isset( $_GET['preview'] )
692                    && 'true' === $_GET['preview']
693                )
694            )
695        )
696        || isset( $_GET['widgetPreview'] ) // Gutenberg < 9.2
697        || isset( $_GET['widget-preview'] ) // Gutenberg >= 9.2
698    ) {
699        return 'browser-iframe';
700    }
701
702    return false;
703    // phpcs:enable WordPress.Security.NonceVerification.Recommended
704}
705
706/**
707 * Grabs a proper access-denied template and returns its path.
708 */
709function access_denied_template_path() {
710    if ( is_site_preview() ) {
711        return __DIR__ . '/access-denied-preview-login-template.php';
712    }
713
714    /*
715     * Logged-out users coming from Calypso are likely authenticated in wordpress.com, so if we redirect them
716     * straight away to the login page, the SSO module will try to automatically log them in into the site.
717     */
718    $calypso_domains          = array(
719        'https://wordpress.com/',
720        'https://horizon.wordpress.com/',
721        'https://wpcalypso.wordpress.com/',
722        'http://calypso.localhost:3000/',
723        'http://127.0.0.1:41050/', // Desktop App.
724    );
725    $should_redirect_to_login = ( ! is_user_logged_in() ) && class_exists( 'Jetpack' ) && Jetpack::is_module_active( 'sso' ) && in_array( wp_get_referer(), $calypso_domains, true );
726    if ( $should_redirect_to_login ) {
727        wp_safe_redirect( wp_login_url( set_url_scheme( original_request_url() ) ) );
728        exit( 0 );
729    } elseif ( site_is_coming_soon() ) {
730        return __DIR__ . '/access-denied-coming-soon-template.php';
731    } else {
732        return __DIR__ . '/access-denied-private-site-template.php';
733    }
734}
735
736/**
737 * Hooked into filter: `allowed_options`.
738 *
739 * Prevents WordPress from saving blog_public option when site options are saved.
740 *
741 * This plugin disables the 'Site Visibility' selector in wp-admin and shows a link to Calypso instead. This function
742 * effectively prevents wp-admin from accidentally updating 'blog_public' option when other site options are updated.
743 *
744 * @param array $allow_list Options allow list.
745 * @return array
746 */
747function remove_privacy_option_from_whitelist( $allow_list ) {
748    $blog_public_index = array_search( 'blog_public', $allow_list['reading'], true );
749    unset( $allow_list['reading'][ $blog_public_index ] );
750
751    return $allow_list;
752}
753
754/**
755 * Makes it possible to disable WP.com customizations to 'Site Visibility' selector by attaching
756 * a 'wpcom_should_update_privacy_selector' filter and making sure it returns false.
757 *
758 * @return bool
759 */
760function should_update_privacy_selector() {
761    return apply_filters( 'wpcom_should_update_privacy_selector', true );
762}
763
764/**
765 * Don't let search engines index private sites.
766 * If the site is not private, do nothing.
767 *
768 * @return string The Robots.txt information.
769 */
770function private_robots_txt() {
771    // Purposefully overriding current output; we only want these rules.
772    return "User-agent: *\nDisallow: /\n";
773}
774
775// Dummy gettext calls to get strings in the catalog.
776/* translators: User role. */
777_x( 'Viewer', 'User role', 'wpcomsh' );
778
779/**
780 * Renames the "Subscriber" role to "Viewer".
781 *
782 * @param \WP_Roles $roles WP_Roles object.
783 */
784function rename_subscriber_role_to_viewer( $roles ) {
785    if ( site_is_private() && isset( $roles->roles['subscriber'] ) ) {
786        $roles->roles['subscriber']['name'] = 'Viewer';
787        $roles->role_names['subscriber']    = 'Viewer';
788    }
789}
790add_action( 'wp_roles_init', '\Private_Site\rename_subscriber_role_to_viewer' );
791
792/**
793 * Translate Viewer role using the wpcomsh textdomain.
794 *
795 * @param string $translation  Translated text.
796 * @param string $text         Text to translate.
797 * @param string $context      Context information for the translators.
798 * @param string $domain       Text domain. Unique identifier for retrieving translated strings.
799 * @return string
800 */
801function translate_viewer_role( $translation, $text, $context, $domain ) {
802    if ( 'User role' === $context && 'default' === $domain && 'Viewer' === $text ) {
803        return translate_user_role( $text, 'wpcomsh' );
804    }
805
806    return $translation;
807}
808add_filter( 'gettext_with_context', '\Private_Site\translate_viewer_role', 10, 4 );
809
810/**
811 * Output the meta tag that tells Pinterest not to allow users to pin
812 * content from this page.
813 * https://support.pinterest.com/entries/21063792-what-if-i-don-t-want-images-from-my-site-to-be-pinned
814 */
815function private_no_pinning() {
816    echo '<meta name="pinterest" content="nopin" />';
817}
818
819/**
820 * Returns the private page template for OPML.
821 */
822function hide_opml() {
823    if ( should_prevent_site_access() ) {
824        status_header( 403 );
825        ?>
826        <error><?php esc_html_e( 'This site is private.', 'wpcomsh' ); ?></error>
827    </head>
828</opml>
829        <?php
830        exit( 0 );
831    }
832}
833
834/**
835 * Removes disabled modules from the active list for private sites.
836 *
837 * @param array $modules Active modules.
838 *
839 * @return array Array of modules after filtering.
840 */
841function filter_jetpack_active_modules( $modules ) {
842    return array_filter(
843        $modules,
844        function ( $module_name ) {
845            return ! in_array( $module_name, DISABLED_JETPACK_MODULES_WHEN_PRIVATE, true );
846        }
847    );
848}
849
850/**
851 * Disables modules for private sites.
852 *
853 * @param array $modules Available modules.
854 *
855 * @return array Array of modules after filtering.
856 */
857function filter_jetpack_get_available_modules( $modules ) {
858    return array_filter(
859        $modules,
860        function ( $module_name ) {
861            return ! in_array( $module_name, DISABLED_JETPACK_MODULES_WHEN_PRIVATE, true );
862        },
863        ARRAY_FILTER_USE_KEY
864    );
865}
866
867/**
868 * Classic editor in calypso don't support displaying private media files because of CORS issues. Adding
869 * a support would be super complex and probably not worth it, considering that we're phasing out the classical
870 * editor altogether. Classic editor in wp-admin handles private files out of the box so we're redirecting all
871 * private+atomic+classic editor users from calypso to wp-admin with ?classic_editor.
872 *
873 * This hook ensures that all requests to post.php and post-new.php will show classic editor when ?classic_editor query
874 * parameter is present.
875 */
876function use_classic_editor_if_requested() {
877    if ( ! is_module_active() ) {
878        return;
879    }
880
881    if ( ! should_override_editor_with_classic_editor() ) {
882        return;
883    }
884
885    if ( class_exists( '\Classic_Editor' ) ) {
886        // This should never happen since we disabled the plugin in another filter
887        return;
888    }
889
890    add_action(
891        'classic_editor_plugin_settings',
892        function () {
893            return array(
894                'editor'      => 'classic',
895                'allow-users' => false,
896            );
897        }
898    );
899
900    require dirname( __DIR__ ) . '/vendor/wordpress/classic-editor-plugin/classic-editor.php';
901    \Classic_Editor::init_actions();
902
903    /*
904     * Classic editor registers itself to plugins_loaded action.
905     * By now it was already executed, but let's remove it just to be safe.
906     */
907    remove_action( 'plugins_loaded', array( 'Classic_Editor', 'init_actions' ) );
908
909    // In allow-users => false mode, these redirection helpers aren't used. Let's apply them manually.
910    add_filter( 'get_edit_post_link', array( 'Classic_Editor', 'get_edit_post_link' ) );
911    add_filter( 'redirect_post_location', array( 'Classic_Editor', 'redirect_location' ) );
912    add_action( 'edit_form_top', array( 'Classic_Editor', 'add_redirect_helper' ) );
913    add_action( 'admin_head-edit.php', array( 'Classic_Editor', 'add_edit_php_inline_style' ) );
914
915    /*
916     * Let's disable Calypsoify - it gets triggered when the user:
917     * 1. Opens Gutenberg.
918     * 2. Clicks "Switch to classic editor".
919     * 3. Clicks "Use Classic editor" in the prompt.
920     */
921    add_filter(
922        'get_user_metadata',
923        function ( $value, $object_id, $meta_key ) {
924            if ( $meta_key === 'calypsoify' ) {
925                return 0;
926            }
927
928            return $value;
929        },
930        10,
931        3
932    );
933}
934
935/**
936 * Disables the classic editor plugin when active.
937 *
938 * @param array $plugins List of active plugins.
939 *
940 * @return array
941 */
942function disable_classic_editor_plugin_when_needed( $plugins ) {
943    if ( ! is_module_active() ) {
944        return $plugins;
945    }
946
947    if ( ! should_override_editor_with_classic_editor() ) {
948        return $plugins;
949    }
950
951    $key = array_search( 'classic-editor/classic-editor.php', $plugins, true );
952    if ( false !== $key ) {
953        unset( $plugins[ $key ] );
954    }
955
956    return $plugins;
957}
958add_filter( 'option_active_plugins', '\Private_Site\disable_classic_editor_plugin_when_needed', 1000 );
959
960/**
961 * Determines whether to override the editor with the Classic Editor.
962 *
963 * @return bool
964 */
965function should_override_editor_with_classic_editor() {
966    if ( ! site_is_private() ) {
967        return false;
968    }
969
970    global $pagenow;
971    if ( empty( $pagenow ) ) {
972        return false;
973    }
974
975    if ( $pagenow !== 'post.php' && $pagenow !== 'post-new.php' ) {
976        return false;
977    }
978
979    if ( ! array_key_exists( 'classic-editor', $_REQUEST ) ) { // phpcs:ignore WordPress.Security.NonceVerification
980        return false;
981    }
982
983    if ( array_key_exists( 'classic-editor__forget', $_REQUEST ) ) {  // phpcs:ignore WordPress.Security.NonceVerification
984        return false;
985    }
986
987    return true;
988}