Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.22% covered (success)
95.22%
199 / 209
77.27% covered (warning)
77.27%
17 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Automattic\Jetpack\Admin_UI\admin_shell_collect_route
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
Admin_Shell
95.67% covered (success)
95.67%
199 / 208
80.95% covered (warning)
80.95%
17 / 21
94
0.00% covered (danger)
0.00%
0 / 1
 register_feature_flag
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 init
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 is_enabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_members
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 get_host
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolve_host
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
9
 screen_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setup
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
7
 enqueue_client
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 load_guest
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 collect_route
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 add_guest_dependencies
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 get_client_config
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
2
 admin_title
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 ajax_guest_assets
16.67% covered (danger)
16.67%
1 / 6
0.00% covered (danger)
0.00%
0 / 1
13.26
 guest_assets
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 hook_snapshot
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 added_callbacks
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 remove_added_callbacks
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 normalize_member
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
17
 is_string_list
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Admin shell: switch between Jetpack wp-build dashboards without a page load.
4 *
5 * @package automattic/jetpack-admin-ui
6 */
7
8namespace Automattic\Jetpack\Admin_UI;
9
10use Automattic\Jetpack\Feature_Flags\Feature_Flags;
11
12/**
13 * Decides whether the admin shell is on, and which dashboards have joined it.
14 */
15class Admin_Shell {
16
17    /**
18     * Feature flag that turns the shell on. Off by default.
19     */
20    const FLAG = 'jetpack-admin-shell';
21
22    /**
23     * Query argument that turns the shell off for one request when set to `0`.
24     */
25    const DISABLE_QUERY_ARG = 'jetpack_admin_shell';
26
27    /**
28     * Member keys that must be non-empty strings.
29     */
30    const REQUIRED_STRING_KEYS = array( 'build_prefix', 'build_page', 'title' );
31
32    /**
33     * The admin-ajax action that returns a guest's page data.
34     */
35    const GUEST_ACTION = 'jetpack_admin_shell_guest';
36
37    /**
38     * Script module ID of the client.
39     */
40    const CLIENT_MODULE = '@automattic/jetpack-admin-shell';
41
42    /**
43     * Hooks where guests add their page data. On a host page, callbacks a guest adds here are removed: the guest endpoint runs them later.
44     */
45    const DEFERRED_HOOKS = array( 'admin_enqueue_scripts', 'admin_print_scripts', 'admin_print_footer_scripts', 'jetpack_admin_js_script_data' );
46
47    /**
48     * Whether init() has run.
49     *
50     * @var bool
51     */
52    private static $initialized = false;
53
54    /**
55     * Routes of the guest being loaded, or null outside load_guest().
56     *
57     * @var array[]|null
58     */
59    private static $collected = null;
60
61    /**
62     * Register the feature flag.
63     *
64     * @return void
65     */
66    public static function register_feature_flag() {
67        Feature_Flags::register(
68            self::FLAG,
69            array(
70                'default'     => false,
71                'description' => 'Switch between Jetpack dashboards in wp-admin without a page load.',
72                'owner'       => 'jetpack-admin-ui',
73            )
74        );
75    }
76
77    /**
78     * Hook the shell up. Each hook checks is_enabled() itself, so this is safe to call on every request.
79     *
80     * @return void
81     */
82    public static function init() {
83        if ( self::$initialized ) {
84            return;
85        }
86        self::$initialized = true;
87
88        // Priority 0: guests must register their modules before the host page builds its import map.
89        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'setup' ), 0 );
90        add_action( 'wp_ajax_' . self::GUEST_ACTION, array( __CLASS__, 'ajax_guest_assets' ) );
91    }
92
93    /**
94     * Whether the shell is on for this request.
95     *
96     * @return bool
97     */
98    public static function is_enabled() {
99        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only opt-out; it can only turn the shell off.
100        if ( isset( $_GET[ self::DISABLE_QUERY_ARG ] ) && '0' === $_GET[ self::DISABLE_QUERY_ARG ] ) {
101            return false;
102        }
103
104        /**
105         * Filters whether Jetpack dashboards share one wp-admin page.
106         *
107         * @since $$next-version$$
108         *
109         * @param bool $enabled Whether the shell is on. Defaults to the `jetpack-admin-shell` feature flag.
110         */
111        return (bool) apply_filters( 'jetpack_admin_shell_enabled', Feature_Flags::is_enabled( self::FLAG ) );
112    }
113
114    /**
115     * Member dashboards, keyed by admin page slug. Entries that don't match the contract are dropped.
116     *
117     * @return array<string, array{build_prefix: string, build_page: string, init_modules: string[], title: string, default_path: string|null, load: callable, capability: string, handles: string[]}>
118     */
119    public static function get_members() {
120        /**
121         * Filters the dashboards that share the admin shell.
122         *
123         * Each entry is keyed by its admin page slug and holds:
124         * - build_prefix (string)   The wp-build prefix of the generated page functions.
125         * - build_page (string)     The wp-build page name.
126         * - init_modules (string[]) Script modules to import before the app boots.
127         * - title (string)          Document title while the page is shown.
128         * - default_path (string)   Optional. The `p` a bare menu link opens.
129         * - load (callable)         Registers the page's scripts and prints its initial data. Must be safe to run twice, and must not redirect, print or write options.
130         * - capability (string)     Optional, default `manage_options`. Required to load the page into the shell.
131         * - handles (string[])      Optional. Script and style handles the guest endpoint prints, with their dependencies; nothing else is enqueued.
132         *
133         * @since $$next-version$$
134         *
135         * @param array $members Member definitions keyed by admin page slug.
136         */
137        $members = apply_filters( 'jetpack_admin_shell_members', array() );
138        if ( ! is_array( $members ) ) {
139            return array();
140        }
141
142        $valid = array();
143        foreach ( $members as $slug => $member ) {
144            $normalized = self::normalize_member( $slug, $member );
145            if ( null === $normalized ) {
146                $message = sprintf( 'Admin shell member "%s" is missing a required key or has one of the wrong type, so it was skipped.', $slug );
147                // Keep on one line: the release tooling only replaces the version in a single-line call.
148                _doing_it_wrong( __METHOD__, esc_html( $message ), '$$next-version$$' );
149                continue;
150            }
151            $valid[ $slug ] = $normalized;
152        }
153
154        return $valid;
155    }
156
157    /**
158     * The member page this request is for, when the shell runs on it.
159     *
160     * @return string|null
161     */
162    public static function get_host() {
163        return self::resolve_host()[0];
164    }
165
166    /**
167     * The host page and the members, so callers that need both run the members filter once.
168     *
169     * @return array{0: string|null, 1: array} Host slug, or null when the shell doesn't run here; members.
170     */
171    private static function resolve_host() {
172        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only picks which page this is.
173        $page = isset( $_GET['page'] ) && is_string( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
174        // Members are Jetpack submenu pages; checking the screen first keeps the members filter off other admin pages.
175        $on_jetpack_screen = isset( $GLOBALS['hook_suffix'] ) && self::screen_id( $page ) === $GLOBALS['hook_suffix'];
176        if ( '' === $page || ! $on_jetpack_screen || ! self::is_enabled() ) {
177            return array( null, array() );
178        }
179
180        $members = self::get_members();
181        if ( count( $members ) < 2 || ! isset( $members[ $page ] ) ) {
182            return array( null, $members );
183        }
184        return array( $page, $members );
185    }
186
187    /**
188     * The screen ID, hook suffix and body class of a member page.
189     *
190     * @param string $slug Admin page slug.
191     * @return string
192     */
193    public static function screen_id( $slug ) {
194        return 'jetpack_page_' . $slug;
195    }
196
197    /**
198     * On a member page, ready the other members and print the client.
199     *
200     * @return void
201     */
202    public static function setup() {
203        list( $host, $members ) = self::resolve_host();
204        if ( null === $host ) {
205            return;
206        }
207
208        $guests = array();
209        foreach ( $members as $slug => $member ) {
210            if ( $slug === $host || ! current_user_can( $member['capability'] ) ) {
211                continue;
212            }
213            $routes = self::load_guest( $member );
214            if ( null !== $routes ) {
215                $guests[ $slug ] = $routes;
216            }
217        }
218        if ( ! $guests ) {
219            return;
220        }
221
222        add_filter(
223            $members[ $host ]['build_page'] . '-wp-admin_boot_dependencies',
224            static function ( $deps ) use ( $members, $guests ) {
225                return self::add_guest_dependencies( $deps, $members, $guests );
226            }
227        );
228
229        $config = self::get_client_config( $host, $members, $guests );
230        add_filter(
231            'script_module_data_' . self::CLIENT_MODULE,
232            static function () use ( $config ) {
233                return $config;
234            }
235        );
236        self::enqueue_client();
237    }
238
239    /**
240     * Enqueue the client: one script module, built from `src/admin-shell/` by webpack.
241     *
242     * @return void
243     */
244    private static function enqueue_client() {
245        $file = dirname( __DIR__ ) . '/build/admin-shell.js';
246        if ( ! is_readable( $file ) ) {
247            return;
248        }
249        // A hash of the file: CDNs cache script URLs by their version for a long time.
250        wp_enqueue_script_module( self::CLIENT_MODULE, plugins_url( 'build/admin-shell.js', __DIR__ ), array(), substr( (string) md5_file( $file ), 0, 12 ) );
251    }
252
253    /**
254     * Run a guest's loader on the host page and collect its routes.
255     *
256     * @param array $member Normalized member.
257     * @return array[]|null Routes, or null when the guest's build isn't there.
258     */
259    private static function load_guest( $member ) {
260        $snapshot = self::hook_snapshot();
261        call_user_func( $member['load'] );
262        self::remove_added_callbacks( $snapshot );
263
264        $register_page_routes = $member['build_prefix'] . '_register_page_routes';
265        $routes_global        = $member['build_prefix'] . '_' . str_replace( '-', '_', $member['build_page'] ) . '_routes_data';
266        if ( ! function_exists( $register_page_routes ) || ! isset( $GLOBALS[ $routes_global ] ) ) {
267            return null;
268        }
269
270        self::$collected = array();
271        // The generated helper only calls a registrar that function_exists() accepts.
272        call_user_func( $register_page_routes, $GLOBALS[ $routes_global ], __NAMESPACE__ . '\admin_shell_collect_route' );
273        $routes          = self::$collected;
274        self::$collected = null;
275
276        return $routes;
277    }
278
279    /**
280     * Receive one route from a guest's generated route registration.
281     *
282     * @param string      $path           Route path.
283     * @param string|null $content_module Content script module ID.
284     * @param string|null $route_module   Route script module ID.
285     * @return void
286     */
287    public static function collect_route( $path, $content_module = null, $route_module = null ) {
288        if ( null === self::$collected ) {
289            return;
290        }
291        $route = array( 'path' => $path );
292        if ( $content_module ) {
293            $route['content_module'] = $content_module;
294        }
295        if ( $route_module ) {
296            $route['route_module'] = $route_module;
297        }
298        self::$collected[] = $route;
299    }
300
301    /**
302     * Add the guests' modules to the host page's import map, without downloading them.
303     *
304     * @param array $deps    Host boot dependencies.
305     * @param array $members Members keyed by slug.
306     * @param array $guests  Guest routes keyed by slug.
307     * @return array
308     */
309    public static function add_guest_dependencies( $deps, $members, $guests ) {
310        foreach ( $guests as $slug => $routes ) {
311            $ids = $members[ $slug ]['init_modules'];
312            foreach ( $routes as $route ) {
313                $ids[] = $route['route_module'] ?? null;
314                $ids[] = $route['content_module'] ?? null;
315            }
316            foreach ( array_filter( $ids ) as $id ) {
317                $deps[] = array(
318                    'import' => 'dynamic',
319                    'id'     => $id,
320                );
321            }
322        }
323        return $deps;
324    }
325
326    /**
327     * What the client needs to know about the host and its guests.
328     *
329     * @param string $host    Host page slug.
330     * @param array  $members Members keyed by slug.
331     * @param array  $guests  Guest routes keyed by slug.
332     * @return array
333     */
334    private static function get_client_config( $host, $members, $guests ) {
335        $config = array(
336            'host'             => $host,
337            'members'          => array(),
338            // Saved headers are per site, user and release: subdirectory multisite sites share one origin.
339            'headerStorageKey' => sprintf( 'jetpack-admin-shell-header:%d:%d:%s:', get_current_blog_id(), get_current_user_id(), Admin_Menu::PACKAGE_VERSION ),
340            'guestAssetsUrl'   => add_query_arg(
341                array(
342                    'action' => self::GUEST_ACTION,
343                    'nonce'  => wp_create_nonce( self::GUEST_ACTION ),
344                ),
345                admin_url( 'admin-ajax.php' )
346            ),
347        );
348        foreach ( array( $host => array() ) + $guests as $slug => $routes ) {
349            $member                     = $members[ $slug ];
350            $config['members'][ $slug ] = array(
351                'mountId'     => $member['build_page'] . '-wp-admin-app',
352                'routes'      => $routes,
353                'initModules' => $member['init_modules'],
354                'defaultPath' => $member['default_path'],
355                'title'       => $member['title'],
356                'adminTitle'  => self::admin_title( $slug, $member['title'] ),
357                'bodyClass'   => self::screen_id( $slug ),
358            );
359        }
360        return $config;
361    }
362
363    /**
364     * The document title a member page gets on its own page load.
365     *
366     * @param string $slug     Admin page slug.
367     * @param string $fallback Title to use when the page isn't in the admin menu.
368     * @return string
369     */
370    private static function admin_title( $slug, $fallback ) {
371        global $submenu;
372        $title = $fallback;
373        foreach ( is_array( $submenu ) ? $submenu : array() as $items ) {
374            foreach ( $items as $item ) {
375                if ( isset( $item[2] ) && $slug === $item[2] ) {
376                    $title = wp_strip_all_tags( $item[3] ?? $item[0] );
377                    break 2;
378                }
379            }
380        }
381        /* translators: Admin screen title. 1: Admin screen name, 2: Network or site name. */
382        $admin_title = sprintf( __( '%1$s &lsaquo; %2$s &#8212; WordPress' ), $title, get_bloginfo( 'name' ) ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- Core's string, so the title matches the one core prints.
383        /** This filter is documented in wp-admin/admin-header.php */
384        $admin_title = apply_filters( 'admin_title', $admin_title, $title ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter.
385        return html_entity_decode( wp_strip_all_tags( $admin_title ), ENT_QUOTES, 'UTF-8' );
386    }
387
388    /**
389     * Send the styles and scripts a guest prints on its own page, for the client to run before booting it.
390     *
391     * @return void
392     */
393    public static function ajax_guest_assets() {
394        check_ajax_referer( self::GUEST_ACTION, 'nonce' );
395        // Not `page`: products treat a `page` naming them as a visit to their own screen.
396        $slug = isset( $_GET['member'] ) && is_string( $_GET['member'] ) ? sanitize_key( wp_unslash( $_GET['member'] ) ) : '';
397
398        $html = self::guest_assets( $slug );
399        if ( is_int( $html ) ) {
400            wp_send_json_error( null, $html, JSON_UNESCAPED_SLASHES );
401        }
402        wp_send_json_success( array( 'html' => $html ), 200, JSON_UNESCAPED_SLASHES );
403    }
404
405    /**
406     * The styles and scripts a member prints on its own page.
407     *
408     * @param string $slug Member page slug.
409     * @return string|int The HTML, or an HTTP status: 404 for no such member while the shell is on, 403 when the user can't open it.
410     */
411    public static function guest_assets( $slug ) {
412        $members = self::is_enabled() ? self::get_members() : array();
413        if ( ! isset( $members[ $slug ] ) ) {
414            return 404;
415        }
416        if ( ! current_user_can( $members[ $slug ]['capability'] ) ) {
417            return 403;
418        }
419
420        call_user_func( $members[ $slug ]['load'] );
421        $hook_suffix = self::screen_id( $slug );
422        set_current_screen( $hook_suffix );
423        $GLOBALS['concatenate_scripts'] = false; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- One script tag per handle, so the client can skip what the host already has.
424
425        ob_start();
426        do_action( 'admin_enqueue_scripts', $hook_suffix ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook.
427        // Only the member's own handles, and what they depend on, go back to the page. Printing them
428        // even when nothing enqueued them: a page can rely on another script pulling one in.
429        foreach ( array( wp_scripts(), wp_styles() ) as $dependencies ) {
430            $dependencies->queue = array_values( array_filter( $members[ $slug ]['handles'], array( $dependencies, 'query' ) ) );
431        }
432        wp_print_styles();
433        // The print hooks attach data to handles (Script_Data does), so they run in full.
434        do_action( 'admin_print_scripts' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook.
435        print_head_scripts();
436        print_footer_scripts();
437        do_action( 'admin_print_footer_scripts' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook.
438        return ob_get_clean();
439    }
440
441    /**
442     * Snapshot the callbacks on the deferred hooks.
443     *
444     * @return array<string, array<string, true>>
445     */
446    private static function hook_snapshot() {
447        global $wp_filter;
448        $snapshot = array();
449        foreach ( self::DEFERRED_HOOKS as $hook ) {
450            $snapshot[ $hook ] = array();
451            foreach ( isset( $wp_filter[ $hook ] ) ? $wp_filter[ $hook ]->callbacks : array() as $priority => $callbacks ) {
452                foreach ( array_keys( $callbacks ) as $id ) {
453                    $snapshot[ $hook ][ $priority . '|' . $id ] = true;
454                }
455            }
456        }
457        return $snapshot;
458    }
459
460    /**
461     * Callbacks added to the deferred hooks since the snapshot, in the order the hooks would run them.
462     *
463     * @param array<string, array<string, true>> $snapshot From hook_snapshot().
464     * @return array<string, array<array{function: callable, priority: int}>> Keyed by every deferred hook.
465     */
466    private static function added_callbacks( $snapshot ) {
467        global $wp_filter;
468        $added = array();
469        foreach ( self::DEFERRED_HOOKS as $hook ) {
470            $added[ $hook ] = array();
471            $callbacks      = isset( $wp_filter[ $hook ] ) ? $wp_filter[ $hook ]->callbacks : array();
472            ksort( $callbacks );
473            foreach ( $callbacks as $priority => $by_id ) {
474                foreach ( $by_id as $id => $callback ) {
475                    if ( ! isset( $snapshot[ $hook ][ $priority . '|' . $id ] ) ) {
476                        $added[ $hook ][] = array(
477                            'function' => $callback['function'],
478                            'priority' => $priority,
479                        );
480                    }
481                }
482            }
483        }
484        return $added;
485    }
486
487    /**
488     * Remove callbacks added to the deferred hooks since the snapshot.
489     *
490     * @param array<string, array<string, true>> $snapshot From hook_snapshot().
491     * @return void
492     */
493    private static function remove_added_callbacks( $snapshot ) {
494        foreach ( self::added_callbacks( $snapshot ) as $hook => $callbacks ) {
495            foreach ( $callbacks as $callback ) {
496                remove_filter( $hook, $callback['function'], $callback['priority'] );
497            }
498        }
499    }
500
501    /**
502     * Fill in a member's defaults, or return null when it can't be used.
503     *
504     * @param mixed $slug   Admin page slug.
505     * @param mixed $member Member definition.
506     * @return array|null
507     */
508    private static function normalize_member( $slug, $member ) {
509        if ( ! is_string( $slug ) || sanitize_key( $slug ) !== $slug || '' === $slug || ! is_array( $member ) ) {
510            return null;
511        }
512
513        foreach ( self::REQUIRED_STRING_KEYS as $key ) {
514            if ( ! isset( $member[ $key ] ) || ! is_string( $member[ $key ] ) || '' === $member[ $key ] ) {
515                return null;
516            }
517        }
518
519        if ( ! isset( $member['load'] ) || ! is_callable( $member['load'] ) ) {
520            return null;
521        }
522
523        $member = array_merge(
524            array(
525                'init_modules' => array(),
526                'default_path' => null,
527                'capability'   => 'manage_options',
528                'handles'      => array(),
529            ),
530            $member
531        );
532
533        if (
534            ! self::is_string_list( $member['init_modules'] )
535            || ! self::is_string_list( $member['handles'] )
536            || ! is_string( $member['capability'] ) || '' === $member['capability']
537            || ( null !== $member['default_path'] && ! is_string( $member['default_path'] ) )
538        ) {
539            return null;
540        }
541
542        return $member;
543    }
544
545    /**
546     * Whether a value is a list of strings.
547     *
548     * @param mixed $value Value to check.
549     * @return bool
550     */
551    private static function is_string_list( $value ) {
552        if ( ! is_array( $value ) || array_values( $value ) !== $value ) {
553            return false;
554        }
555        foreach ( $value as $item ) {
556            if ( ! is_string( $item ) ) {
557                return false;
558            }
559        }
560        return true;
561    }
562}
563
564/**
565 * Plain-function registrar for wp-build's generated route helpers, which only accept a function name.
566 *
567 * @param string      $path           Route path.
568 * @param string|null $content_module Content script module ID.
569 * @param string|null $route_module   Route script module ID.
570 * @return void
571 */
572function admin_shell_collect_route( $path, $content_module = null, $route_module = null ) { // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed -- Must load with the class; wp-build route helpers only take a function name.
573    Admin_Shell::collect_route( $path, $content_module, $route_module );
574}