Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.00% covered (success)
96.00%
72 / 75
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Feature_Policy
96.00% covered (success)
96.00%
72 / 75
66.67% covered (warning)
66.67%
6 / 9
44
0.00% covered (danger)
0.00%
0 / 1
 ensure_hooks
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 reset
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_policy
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 filter_active_modules
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 warn_about_slugs_with_no_module
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 filter_default_modules
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 filter_visibility
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 filter_menu_visibility
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
13
 get_slugs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * A single place for hosts to force, default, or hide Jetpack features.
4 *
5 * @package automattic/jetpack-status
6 */
7
8namespace Automattic\Jetpack;
9
10/**
11 * Reads `jetpack_feature_policy` and feeds it into the filters that already own each decision.
12 *
13 * Activation goes through `jetpack_active_modules`, defaults through `jetpack_get_default_modules`,
14 * and visibility through both `jetpack_my_jetpack_feature_visibility` and
15 * `jetpack_admin_menu_visibility`, so every existing reader, including forced-module detection,
16 * sees the policy without knowing it exists.
17 */
18class Feature_Policy {
19
20    /**
21     * The policy filter's name.
22     *
23     * @var string
24     */
25    const FILTER = 'jetpack_feature_policy';
26
27    const ACTIVATION_DEFAULT     = 'default';
28    const ACTIVATION_FORCED_ON   = 'forced-on';
29    const ACTIVATION_FORCED_OFF  = 'forced-off';
30    const ACTIVATION_DEFAULT_ON  = 'default-on';
31    const ACTIVATION_DEFAULT_OFF = 'default-off';
32
33    const VISIBILITY_VISIBLE = 'visible';
34    const VISIBILITY_HIDDEN  = 'hidden';
35
36    /**
37     * Runs after other callbacks so the policy has the last word.
38     *
39     * @var int
40     */
41    const PRIORITY = PHP_INT_MAX;
42
43    /**
44     * Bridged filters, mapped to the callback and argument count each takes.
45     *
46     * @var array
47     */
48    const BRIDGES = array(
49        'jetpack_active_modules'                => array( 'filter_active_modules', 1 ),
50        'jetpack_get_default_modules'           => array( 'filter_default_modules', 5 ),
51        'jetpack_my_jetpack_feature_visibility' => array( 'filter_visibility', 1 ),
52        'jetpack_admin_menu_visibility'         => array( 'filter_menu_visibility', 2 ),
53    );
54
55    /**
56     * Forced-on slugs already reported through `_doing_it_wrong()` this request.
57     *
58     * @var string[]
59     */
60    private static $warned = array();
61
62    /**
63     * Registers the bridge callbacks once something uses the policy filter.
64     *
65     * Called by each reader rather than at load, because the status package has no bootstrap.
66     * Waiting for a policy keeps `has_filter( 'jetpack_active_modules' )` false on sites without one.
67     *
68     * @return void
69     */
70    public static function ensure_hooks() {
71        if ( ! has_filter( self::FILTER ) ) {
72            return;
73        }
74
75        foreach ( self::BRIDGES as $hook => list( $method, $accepted_args ) ) {
76            if ( false === has_filter( $hook, array( __CLASS__, $method ) ) ) {
77                add_filter( $hook, array( __CLASS__, $method ), self::PRIORITY, $accepted_args );
78            }
79        }
80    }
81
82    /**
83     * Removes the bridge callbacks. For tests.
84     *
85     * @return void
86     */
87    public static function reset() {
88        foreach ( self::BRIDGES as $hook => list( $method ) ) {
89            remove_filter( $hook, array( __CLASS__, $method ), self::PRIORITY );
90        }
91
92        self::$warned = array();
93    }
94
95    /**
96     * The validated policy.
97     *
98     * @return array Map of slug to an array with `activation` and `visibility`, either of which may be null.
99     */
100    public static function get_policy() {
101        /**
102         * Filters how Jetpack treats each feature on this site.
103         *
104         * Keys are module slugs for `activation`. For `visibility`, keys are anything the My Jetpack
105         * Features page answers to: product, module, or feature slugs. Each value is an array with
106         * either or both of:
107         *
108         * - `activation`: 'forced-on' or 'forced-off' pin the module on every request, the same as
109         *   `jetpack_active_modules`. 'default-on' or 'default-off' change what Jetpack turns on when
110         *   it activates its default modules, which happens at connection and upgrade, not on an
111         *   existing site. 'default' leaves it alone. A 'forced-on' slug this site has no module for
112         *   still reads as active, but calls `_doing_it_wrong()` since nothing will load it.
113         * - `visibility`: 'hidden' keeps the item off the My Jetpack Features page and out of the
114         *   wp-admin sidebar, 'visible' shows it in both. A sidebar entry matches on its item key or
115         *   on the product or module gate it declares. The policy runs last, so 'visible' overrides a
116         *   'hidden' another callback set.
117         *
118         * Standalone plugin products (Akismet, Boost, CRM, Protect) take `visibility` only: WordPress
119         * decides which plugins load before Jetpack runs, so forcing one needs `option_active_plugins`
120         * in an mu-plugin.
121         *
122         * Register the policy before `after_setup_theme`, from an mu-plugin, plugin, or theme:
123         * `Jetpack::load_modules()` runs there at priority -2, so a later policy (on `init`, say) misses
124         * module loading while My Jetpack still honors it — the module runs on a page saying it is off.
125         *
126         * @since 7.1.0
127         *
128         * @param array $policy Map of slug to policy, empty until a host adds to it.
129         */
130        $policy = apply_filters( 'jetpack_feature_policy', array() );
131
132        if ( ! is_array( $policy ) ) {
133            return array();
134        }
135
136        $activations  = array( self::ACTIVATION_DEFAULT, self::ACTIVATION_FORCED_ON, self::ACTIVATION_FORCED_OFF, self::ACTIVATION_DEFAULT_ON, self::ACTIVATION_DEFAULT_OFF );
137        $visibilities = array( self::VISIBILITY_VISIBLE, self::VISIBILITY_HIDDEN );
138        $valid        = array();
139
140        foreach ( $policy as $slug => $entry ) {
141            if ( ! is_string( $slug ) || '' === $slug || ! is_array( $entry ) ) {
142                continue;
143            }
144
145            $activation = $entry['activation'] ?? null;
146            $visibility = $entry['visibility'] ?? null;
147
148            $valid[ $slug ] = array(
149                'activation' => in_array( $activation, $activations, true ) ? $activation : null,
150                'visibility' => in_array( $visibility, $visibilities, true ) ? $visibility : null,
151            );
152        }
153
154        return $valid;
155    }
156
157    /**
158     * Adds forced-on modules to the active list and drops forced-off ones.
159     *
160     * @param array $active Active module slugs.
161     * @return array
162     */
163    public static function filter_active_modules( $active ) {
164        if ( ! is_array( $active ) ) {
165            return $active;
166        }
167
168        $policy    = self::get_policy();
169        $forced_on = self::get_slugs( $policy, 'activation', self::ACTIVATION_FORCED_ON );
170
171        self::warn_about_slugs_with_no_module( $forced_on );
172
173        $active = array_merge( $active, $forced_on );
174
175        return array_values( array_unique( array_diff( $active, self::get_slugs( $policy, 'activation', self::ACTIVATION_FORCED_OFF ) ) ) );
176    }
177
178    /**
179     * Reports a forced-on slug this site has no module for.
180     *
181     * The slug is kept, because discarding a host's instruction silently is worse than honoring a
182     * typo, so it reads as active everywhere while `load_modules()` never loads it.
183     *
184     * @param string[] $forced_on Slugs the policy forces on.
185     * @return void
186     */
187    private static function warn_about_slugs_with_no_module( $forced_on ) {
188        $unwarned = array_diff( $forced_on, self::$warned );
189
190        // This runs on every get_active() call, so skip the module scan unless there is news.
191        if ( ! $unwarned || ! function_exists( '_doing_it_wrong' ) ) {
192            return;
193        }
194
195        // No arguments: every slug this site has, so only a typo is flagged.
196        $available    = ( new Modules() )->get_available();
197        self::$warned = array_merge( self::$warned, $unwarned );
198
199        foreach ( $unwarned as $slug ) {
200            if ( in_array( $slug, $available, true ) ) {
201                continue;
202            }
203
204            $message = sprintf( 'Forced on "%s", which is not a Jetpack module on this site. It will report as active, but nothing will load it.', $slug );
205
206            // The version token is only replaced at release time when this call is on one line.
207            _doing_it_wrong( 'jetpack_feature_policy', esc_html( $message ), '7.1.0' );
208        }
209    }
210
211    /**
212     * Adds default-on modules to the defaults and drops default-off and forced-off ones.
213     *
214     * Forced-off has to go too: the activation path writes whatever it activates to
215     * `jetpack_active_modules`, so a forced-off default would be saved as active.
216     *
217     * @param array       $modules                  Default module slugs.
218     * @param bool|string $min_version              Minimum module version, passed through from the caller.
219     * @param bool|string $max_version              Maximum module version, passed through from the caller.
220     * @param bool|null   $requires_connection      Connection requirement, passed through from the caller.
221     * @param bool|null   $requires_user_connection User connection requirement, passed through from the caller.
222     * @return array
223     */
224    public static function filter_default_modules( $modules, $min_version = false, $max_version = false, $requires_connection = null, $requires_user_connection = null ) {
225        if ( ! is_array( $modules ) ) {
226            return $modules;
227        }
228
229        $policy     = self::get_policy();
230        $default_on = self::get_slugs( $policy, 'activation', self::ACTIVATION_DEFAULT_ON );
231
232        if ( $default_on ) {
233            // Honor the caller's constraints: an offline activation must not pick up a module that needs a connection.
234            $available = ( new Modules() )->get_available( $min_version, $max_version, $requires_connection, $requires_user_connection );
235            $modules   = array_merge( $modules, array_intersect( $default_on, $available ) );
236        }
237
238        return array_values( array_unique( array_diff( $modules, self::get_slugs( $policy, 'activation', self::ACTIVATION_DEFAULT_OFF ), self::get_slugs( $policy, 'activation', self::ACTIVATION_FORCED_OFF ) ) ) );
239    }
240
241    /**
242     * Sets each slug's My Jetpack visibility from the policy.
243     *
244     * @param array $states Map of slug to visibility state.
245     * @return array
246     */
247    public static function filter_visibility( $states ) {
248        if ( ! is_array( $states ) ) {
249            $states = array();
250        }
251
252        foreach ( self::get_policy() as $slug => $entry ) {
253            if ( null !== $entry['visibility'] ) {
254                $states[ $slug ] = $entry['visibility'];
255            }
256        }
257
258        return $states;
259    }
260
261    /**
262     * Sets each wp-admin sidebar item's state from the policy entry that names it.
263     *
264     * Hosts name features, not menu slugs, so an item is matched by its key and then by the
265     * product or module gate it declared. A policy slug matching no item changes nothing.
266     *
267     * @param array $states Map of menu item key to visibility state.
268     * @param array $items  The registered menu items.
269     * @return array
270     */
271    public static function filter_menu_visibility( $states, $items = array() ) {
272        if ( ! is_array( $states ) || ! is_array( $items ) ) {
273            return $states;
274        }
275
276        $policy = self::get_policy();
277
278        foreach ( $items as $item ) {
279            if ( ! is_array( $item ) ) {
280                continue;
281            }
282
283            $args = isset( $item['args'] ) && is_array( $item['args'] ) ? $item['args'] : array();
284            $key  = empty( $args['key'] ) ? ( $item['menu_slug'] ?? null ) : $args['key'];
285
286            if ( ! is_string( $key ) || '' === $key ) {
287                continue;
288            }
289
290            foreach ( array( $key, $args['product'] ?? null, $args['module'] ?? null ) as $slug ) {
291                if ( is_string( $slug ) && ! empty( $policy[ $slug ]['visibility'] ) ) {
292                    $states[ $key ] = $policy[ $slug ]['visibility'];
293                    break;
294                }
295            }
296        }
297
298        return $states;
299    }
300
301    /**
302     * Slugs whose policy sets `$key` to `$value`.
303     *
304     * @param array  $policy The validated policy.
305     * @param string $key    'activation' or 'visibility'.
306     * @param string $value  The value to match.
307     * @return string[]
308     */
309    private static function get_slugs( $policy, $key, $value ) {
310        $slugs = array();
311
312        foreach ( $policy as $slug => $entry ) {
313            if ( $value === $entry[ $key ] ) {
314                $slugs[] = $slug;
315            }
316        }
317
318        return $slugs;
319    }
320}