Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.35% covered (warning)
82.35%
126 / 153
60.00% covered (warning)
60.00%
9 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
RTC
82.35% covered (warning)
82.35%
126 / 153
60.00% covered (warning)
60.00%
9 / 15
62.19
0.00% covered (danger)
0.00%
0 / 1
 init
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 is_allowed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_enabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
6
 get_providers
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 register_rest_routes
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 register_providers
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
6
 unregister_rtc_setting
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 filter_rtc_option
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 default_rtc_option
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 pre_rtc_option
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
4.59
 override_rtc_setting_default
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 get_max_peers_per_room
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_plan_owner
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 get_site_slug
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 load_notices
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Real-time Collaboration (RTC) websocket transport support.
4 *
5 * Extends Gutenberg's RTC feature with PingHub websocket transport
6 * using the WordPress.com infrastructure.
7 *
8 * @package automattic/jetpack-rtc
9 */
10
11namespace Automattic\Jetpack;
12
13use Automattic\Jetpack\RTC\REST_Connection_Log;
14use Automattic\Jetpack\RTC\REST_Pinghub_Token;
15use Automattic\Jetpack\RTC\REST_RTC_Notices;
16
17/**
18 * Main RTC class.
19 */
20class RTC {
21
22    const PACKAGE_VERSION = '0.1.0';
23
24    /**
25     * Option names for the RTC setting.
26     * The old name was used until Gutenberg PR #76643 renamed it.
27     * Both are supported for backwards compatibility.
28     */
29    const OPTION_OLD = 'wp_enable_real_time_collaboration';
30    const OPTION_NEW = 'wp_collaboration_enabled';
31
32    /**
33     * Whether the hooks have been initialized.
34     *
35     * @var bool
36     */
37    private static $initialized = false;
38
39    /**
40     * Initialize the RTC package by registering hooks.
41     *
42     * @return void
43     */
44    public static function init() {
45        if ( self::$initialized ) {
46            return;
47        }
48        self::$initialized = true;
49
50        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
51        add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'register_providers' ) );
52        add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'load_notices' ) );
53        add_action( 'load-options-writing.php', array( __CLASS__, 'unregister_rtc_setting' ) );
54        add_action( 'load-options-writing.php', array( __CLASS__, 'override_rtc_setting_default' ) );
55
56        // Hook into both old and new option names for backwards compatibility.
57        foreach ( array( self::OPTION_OLD, self::OPTION_NEW ) as $option ) {
58            add_filter( 'option_' . $option, array( __CLASS__, 'filter_rtc_option' ), 10 );
59            add_filter( 'default_option_' . $option, array( __CLASS__, 'default_rtc_option' ), 20, 2 );
60            add_filter( 'pre_option_' . $option, array( __CLASS__, 'pre_rtc_option' ) );
61        }
62    }
63
64    /**
65     * Determine whether RTC is allowed.
66     *
67     * @return bool
68     */
69    public static function is_allowed() {
70        /**
71         * Filter whether RTC can be enabled.
72         *
73         * @param bool $is_enabled Whether RTC can be enabled.
74         */
75        return apply_filters( 'jetpack_rtc_enabled', false );
76    }
77
78    /**
79     * Determine whether RTC is enabled.
80     *
81     * @return bool
82     */
83    public static function is_enabled() {
84        global $pagenow;
85
86        // Real-time collaboration is not enabled in the site editor.
87        if (
88            'site-editor.php' === $pagenow ||
89            ( 'admin.php' === $pagenow && isset( $_GET['page'] ) && 'site-editor-v2' === sanitize_text_field( wp_unslash( $_GET['page'] ) ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
90        ) {
91            return false;
92        }
93
94        return self::is_allowed() && (bool) get_option( 'wp_collaboration_enabled' );
95    }
96
97    /**
98     * Get the list of active RTC providers.
99     *
100     * @return string[]
101     */
102    public static function get_providers() {
103        if ( ! self::is_enabled() ) {
104            return array();
105        }
106
107        $allowed_providers = array( 'http-polling', 'pinghub' );
108
109        $default_providers = array( 'pinghub' );
110
111        /**
112         * Filter the list of RTC providers.
113         *
114         * @param string[] $providers List of provider identifiers.
115         */
116        $providers = apply_filters( 'jetpack_rtc_providers', $default_providers );
117        if ( ! is_array( $providers ) ) {
118            return array();
119        }
120
121        return array_values(
122            array_filter(
123                $providers,
124                function ( $provider ) use ( $allowed_providers ) {
125                    return in_array( $provider, $allowed_providers, true );
126                }
127            )
128        );
129    }
130
131    /**
132     * Register REST API routes for the PingHub token endpoint.
133     *
134     * @return void
135     */
136    public static function register_rest_routes() {
137        ( new REST_Pinghub_Token() )->register_routes();
138        ( new REST_RTC_Notices() )->register_routes();
139
140        if ( function_exists( 'log2logstash' ) ) {
141            ( new REST_Connection_Log() )->register_routes();
142        }
143    }
144
145    /**
146     * Enqueue the assets that extend the RTC providers.
147     *
148     * @return void
149     */
150    public static function register_providers() {
151        if ( ! self::is_enabled() ) {
152            return;
153        }
154
155        $providers = self::get_providers();
156
157        // If HTTP polling (Gutenberg's built-in default provider when this script isn't enqueued)
158        // is the only provider being used, then we don't need to inject any assets since that's
159        // already the default behavior.
160        if ( count( $providers ) === 1 && in_array( 'http-polling', $providers, true ) ) {
161            return;
162        }
163
164        $handle = 'jetpack-rtc-providers';
165
166        Assets::register_script(
167            $handle,
168            '../build/rtc-providers.js',
169            __FILE__,
170            array(
171                'in_footer'  => true,
172                'textdomain' => 'jetpack-rtc',
173                'enqueue'    => true,
174            )
175        );
176
177        $data = wp_json_encode(
178            array(
179                'providers'         => $providers,
180                'connectionLogging' => function_exists( 'log2logstash' ),
181                'currentPostType'   => get_post_type() ? get_post_type() : null,
182                'currentPostId'     => get_the_ID() ? get_the_ID() : null,
183            ),
184            JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
185        );
186
187        wp_add_inline_script(
188            $handle,
189            "var jetpackRTC = $data;",
190            'before'
191        );
192    }
193
194    /**
195     * Unregister the RTC setting field on the Writing page if RTC is not allowed.
196     *
197     * @return void
198     */
199    public static function unregister_rtc_setting() {
200        if ( self::is_allowed() ) {
201            return;
202        }
203
204        global $wp_settings_fields;
205
206        foreach ( array( self::OPTION_OLD, self::OPTION_NEW ) as $option ) {
207            if ( isset( $wp_settings_fields['writing']['default'][ $option ] ) ) {
208                unset( $wp_settings_fields['writing']['default'][ $option ] );
209            }
210        }
211    }
212
213    /**
214     * When RTC is not allowed, always force the option off.
215     * When RTC is allowed, respect the stored option value.
216     *
217     * @param mixed $value  The value of the option.
218     * @return mixed
219     */
220    public static function filter_rtc_option( $value ) {
221        // RTC not allowed: force the option off, regardless of what's in the DB.
222        if ( ! self::is_allowed() ) {
223            return '0';
224        }
225
226        // RTC allowed: respect whatever is stored.
227        return $value;
228    }
229
230    /**
231     * When RTC is allowed and the option is NOT stored yet,
232     * default the option to enabled (1), unless the old option
233     * has a stored value to migrate from.
234     *
235     * This handles the Gutenberg upgrade path: e.g. a site on 22.7 stored
236     * wp_enable_real_time_collaboration, then upgraded to 22.8 which reads
237     * wp_collaboration_enabled â€” the new option inherits the old value.
238     *
239     * @param mixed  $default The default value.
240     * @param string $option  The option name.
241     * @return mixed
242     */
243    public static function default_rtc_option( $default = '', $option = '' ) {
244        // RTC not allowed: keep default disabled.
245        if ( ! self::is_allowed() ) {
246            return '0';
247        }
248        // RTC allowed and option is not stored yet
249        if ( $option === self::OPTION_NEW ) {
250            // If the old option is set, use that.
251            return get_option( self::OPTION_OLD );
252        }
253        // Default to enabled.
254        return '1';
255    }
256
257    /**
258     * Disable RTC for super admins that are not members of the blog to avoid
259     * accidentally exposing their presence to site users (e.g. during support).
260     * Skip this on the Writing settings page so they can still toggle the option.
261     *
262     * @return mixed
263     */
264    public static function pre_rtc_option() {
265        global $pagenow;
266
267        if ( 'options-writing.php' !== $pagenow && is_super_admin() && ! is_user_member_of_blog() ) {
268            return '0';
269        }
270
271        // Returning false to let `get_option` proceed normally.
272        return false;
273    }
274
275    /**
276     * Override the default for the Gutenberg RTC setting so it defaults to enabled in the UI.
277     *
278     * @return void
279     */
280    public static function override_rtc_setting_default() {
281        global $wp_registered_settings;
282
283        // No need to override the setting when RTC is not allowed, since we unregister it
284        // in `unregister_rtc_setting`.
285        if ( ! self::is_allowed() ) {
286            return;
287        }
288
289        foreach ( array( self::OPTION_OLD, self::OPTION_NEW ) as $option ) {
290            // Only re-register the option if Gutenberg already registered it.
291            if ( ! isset( $wp_registered_settings[ $option ] ) ) {
292                continue;
293            }
294
295            unregister_setting( 'writing', $option );
296
297            register_setting(
298                'writing',
299                $option,
300                array(
301                    'type'              => 'boolean',
302                    'description'       => __( 'Enable Real-Time Collaboration', 'jetpack-rtc' ),
303                    'sanitize_callback' => 'rest_sanitize_boolean',
304                    'default'           => true,
305                    'show_in_rest'      => true,
306                )
307            );
308        }
309    }
310
311    /**
312     * Get the maximum number of peers allowed per room.
313     *
314     * @return int Max peers per room.
315     */
316    public static function get_max_peers_per_room() {
317        return (int) apply_filters( 'jetpack_rtc_max_peers_per_room', 3 );
318    }
319
320    /**
321     * Check if the current user is the plan owner for this site.
322     * Works on Simple sites (via wpcom_get_blog_owner) and Atomic sites
323     * (via Jetpack connection master_user). Returns false on self-hosted
324     * since there is no WP.com plan to upgrade.
325     *
326     * @return bool
327     */
328    public static function is_plan_owner() {
329        $current_user_id = get_current_user_id();
330
331        // Simple sites: wpcom_get_blog_owner is the canonical source.
332        if ( function_exists( 'wpcom_get_blog_owner' ) ) {
333            $owner_id = wpcom_get_blog_owner( get_wpcom_blog_id() );
334            return (int) $current_user_id === (int) $owner_id;
335        }
336
337        // Atomic sites: the Jetpack connection master_user is the plan owner.
338        if ( class_exists( 'Jetpack_Options' ) ) {
339            $master_user = \Jetpack_Options::get_option( 'master_user' );
340            if ( $master_user ) {
341                return (int) $current_user_id === (int) $master_user;
342            }
343        }
344
345        return false;
346    }
347
348    /**
349     * Get the site slug for use in WP.com URLs.
350     *
351     * @return string
352     */
353    private static function get_site_slug() {
354        if ( function_exists( 'wpcom_get_site_slug' ) ) {
355            return wpcom_get_site_slug();
356        }
357
358        if ( class_exists( '\Automattic\Jetpack\Status' ) ) {
359            $jetpack_status = new \Automattic\Jetpack\Status();
360            return $jetpack_status->get_site_suffix();
361        }
362
363        return '';
364    }
365
366    /**
367     * Enqueue the assets that handle the RTC notices.
368     *
369     * @return void
370     */
371    public static function load_notices() {
372        if ( ! self::is_enabled() ) {
373            return;
374        }
375
376        $handle = 'jetpack-rtc-notices';
377
378        Assets::register_script(
379            $handle,
380            '../build/rtc-notices.js',
381            __FILE__,
382            array(
383                'in_footer'  => true,
384                'textdomain' => 'jetpack-rtc',
385                'enqueue'    => true,
386            )
387        );
388
389        $is_admin_user = current_user_can( 'manage_options' );
390        $is_plan_owner = self::is_plan_owner();
391        $post_type     = get_post_type();
392
393        $data = wp_json_encode(
394            array(
395                'assetsUrl'          => plugins_url( '../build/', __FILE__ ),
396                'isAdmin'            => $is_admin_user,
397                'isPlanOwner'        => $is_plan_owner,
398                'postId'             => get_the_ID(),
399                'postType'           => $post_type ? $post_type : null,
400                'userId'             => get_current_user_id(),
401                'postTitle'          => get_the_title(),
402                'postEditUrl'        => get_edit_post_link( get_the_ID(), 'raw' ),
403                'postsListUrl'       => admin_url( 'edit.php' ),
404                'siteSlug'           => self::get_site_slug(),
405                'maxPeersPerRoom'    => self::get_max_peers_per_room(),
406                'enableLimitNotices' => apply_filters( 'jetpack_rtc_enable_limit_notices', false ),
407            ),
408            JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
409        );
410
411        wp_add_inline_script(
412            $handle,
413            "var jetpackRtcNotices = $data;",
414            'before'
415        );
416    }
417}