Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
Admin_Shell
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
5 / 5
30
100.00% covered (success)
100.00%
1 / 1
 register_feature_flag
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 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
 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     * Register the feature flag.
34     *
35     * @return void
36     */
37    public static function register_feature_flag() {
38        Feature_Flags::register(
39            self::FLAG,
40            array(
41                'default'     => false,
42                'description' => 'Switch between Jetpack dashboards in wp-admin without a page load.',
43                'owner'       => 'jetpack-admin-ui',
44            )
45        );
46    }
47
48    /**
49     * Whether the shell is on for this request.
50     *
51     * @return bool
52     */
53    public static function is_enabled() {
54        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only opt-out; it can only turn the shell off.
55        if ( isset( $_GET[ self::DISABLE_QUERY_ARG ] ) && '0' === $_GET[ self::DISABLE_QUERY_ARG ] ) {
56            return false;
57        }
58
59        /**
60         * Filters whether Jetpack dashboards share one wp-admin page.
61         *
62         * @since $$next-version$$
63         *
64         * @param bool $enabled Whether the shell is on. Defaults to the `jetpack-admin-shell` feature flag.
65         */
66        return (bool) apply_filters( 'jetpack_admin_shell_enabled', Feature_Flags::is_enabled( self::FLAG ) );
67    }
68
69    /**
70     * Member dashboards, keyed by admin page slug. Entries that don't match the contract are dropped.
71     *
72     * @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[]}>
73     */
74    public static function get_members() {
75        /**
76         * Filters the dashboards that share the admin shell.
77         *
78         * Each entry is keyed by its admin page slug and holds:
79         * - build_prefix (string)   The wp-build prefix of the generated page functions.
80         * - build_page (string)     The wp-build page name.
81         * - init_modules (string[]) Script modules to import before the app boots.
82         * - title (string)          Document title while the page is shown.
83         * - default_path (string)   Optional. The `p` a bare menu link opens.
84         * - 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.
85         * - capability (string)     Optional, default `manage_options`. Required to load the page into the shell.
86         * - handles (string[])      Optional. Script and style handles the shell may return for the page.
87         *
88         * @since $$next-version$$
89         *
90         * @param array $members Member definitions keyed by admin page slug.
91         */
92        $members = apply_filters( 'jetpack_admin_shell_members', array() );
93        if ( ! is_array( $members ) ) {
94            return array();
95        }
96
97        $valid = array();
98        foreach ( $members as $slug => $member ) {
99            $normalized = self::normalize_member( $slug, $member );
100            if ( null === $normalized ) {
101                $message = sprintf( 'Admin shell member "%s" is missing a required key or has one of the wrong type, so it was skipped.', $slug );
102                // Keep on one line: the release tooling only replaces the version in a single-line call.
103                _doing_it_wrong( __METHOD__, esc_html( $message ), '$$next-version$$' );
104                continue;
105            }
106            $valid[ $slug ] = $normalized;
107        }
108
109        return $valid;
110    }
111
112    /**
113     * Fill in a member's defaults, or return null when it can't be used.
114     *
115     * @param mixed $slug   Admin page slug.
116     * @param mixed $member Member definition.
117     * @return array|null
118     */
119    private static function normalize_member( $slug, $member ) {
120        if ( ! is_string( $slug ) || sanitize_key( $slug ) !== $slug || '' === $slug || ! is_array( $member ) ) {
121            return null;
122        }
123
124        foreach ( self::REQUIRED_STRING_KEYS as $key ) {
125            if ( ! isset( $member[ $key ] ) || ! is_string( $member[ $key ] ) || '' === $member[ $key ] ) {
126                return null;
127            }
128        }
129
130        if ( ! isset( $member['load'] ) || ! is_callable( $member['load'] ) ) {
131            return null;
132        }
133
134        $member = array_merge(
135            array(
136                'init_modules' => array(),
137                'default_path' => null,
138                'capability'   => 'manage_options',
139                'handles'      => array(),
140            ),
141            $member
142        );
143
144        if (
145            ! self::is_string_list( $member['init_modules'] )
146            || ! self::is_string_list( $member['handles'] )
147            || ! is_string( $member['capability'] ) || '' === $member['capability']
148            || ( null !== $member['default_path'] && ! is_string( $member['default_path'] ) )
149        ) {
150            return null;
151        }
152
153        return $member;
154    }
155
156    /**
157     * Whether a value is a list of strings.
158     *
159     * @param mixed $value Value to check.
160     * @return bool
161     */
162    private static function is_string_list( $value ) {
163        if ( ! is_array( $value ) || array_values( $value ) !== $value ) {
164            return false;
165        }
166        foreach ( $value as $item ) {
167            if ( ! is_string( $item ) ) {
168                return false;
169            }
170        }
171        return true;
172    }
173}