Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.33% covered (danger)
15.33%
21 / 137
25.93% covered (danger)
25.93%
7 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Options
14.81% covered (danger)
14.81%
20 / 135
25.93% covered (danger)
25.93%
7 / 27
3276.48
0.00% covered (danger)
0.00%
0 / 1
 name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init_listeners
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 init_full_sync_listeners
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init_before_send
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_defaults
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 set_late_default
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 add_deprecated_options
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 enqueue_full_sync_actions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 send_full_sync_actions
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 estimate_full_sync_actions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_full_sync_actions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_all_options
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 update_options_whitelist
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_options_whitelist
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_options_whitelist
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 update_options_contentless
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_options_contentless
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 whitelist_options
13.33% covered (danger)
13.33%
2 / 15
0.00% covered (danger)
0.00%
0 / 1
49.66
 is_whitelisted_option
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_contentless_option
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 filter_theme_mods
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 jetpack_sync_core_icon
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 expand_options
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 total
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_objects_by_id
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
56
 get_object_by_id
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 should_enqueue_jetpack_options_update
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
110
1<?php
2/**
3 * Options sync module.
4 *
5 * @package automattic/jetpack-sync
6 */
7
8namespace Automattic\Jetpack\Sync\Modules;
9
10use Automattic\Jetpack\Sync\Defaults;
11use Automattic\Jetpack\Sync\Settings;
12
13if ( ! defined( 'ABSPATH' ) ) {
14    exit( 0 );
15}
16
17/**
18 * Class to handle sync for options.
19 */
20class Options extends Module {
21    /**
22     * Whitelist for options we want to sync.
23     *
24     * @access private
25     *
26     * @var array
27     */
28    private $options_whitelist;
29
30    /**
31     * Contentless options we want to sync.
32     *
33     * @access private
34     *
35     * @var array
36     */
37    private $options_contentless;
38
39    /**
40     * Sync module name.
41     *
42     * @access public
43     *
44     * @return string
45     */
46    public function name() {
47        return 'options';
48    }
49
50    /**
51     * Initialize options action listeners.
52     *
53     * @access public
54     *
55     * @param callable $callable Action handler callable.
56     */
57    public function init_listeners( $callable ) {
58        // Options.
59        add_action( 'added_option', $callable, 10, 2 );
60        add_action( 'updated_option', $callable, 10, 3 );
61        add_action( 'deleted_option', $callable, 10, 1 );
62
63        // Sync Core Icon: Detect changes in Core's Site Icon and make it syncable.
64        add_action( 'add_option_site_icon', array( $this, 'jetpack_sync_core_icon' ) );
65        add_action( 'update_option_site_icon', array( $this, 'jetpack_sync_core_icon' ) );
66        add_action( 'delete_option_site_icon', array( $this, 'jetpack_sync_core_icon' ) );
67
68        // Handle deprecated options.
69        add_filter( 'jetpack_options_whitelist', array( $this, 'add_deprecated_options' ) );
70
71        $whitelist_option_handler = array( $this, 'whitelist_options' );
72        add_filter( 'jetpack_sync_before_enqueue_deleted_option', $whitelist_option_handler );
73        add_filter( 'jetpack_sync_before_enqueue_added_option', $whitelist_option_handler );
74        add_filter( 'jetpack_sync_before_enqueue_updated_option', $whitelist_option_handler );
75    }
76
77    /**
78     * Initialize options action listeners for full sync.
79     *
80     * @access public
81     *
82     * @param callable $callable Action handler callable.
83     */
84    public function init_full_sync_listeners( $callable ) {
85        add_action( 'jetpack_full_sync_options', $callable );
86    }
87
88    /**
89     * Initialize the module in the sender.
90     *
91     * @access public
92     */
93    public function init_before_send() {
94        // Full sync.
95        add_filter( 'jetpack_sync_before_send_jetpack_full_sync_options', array( $this, 'expand_options' ) );
96    }
97
98    /**
99     * Set module defaults.
100     * Define the options whitelist and contentless options.
101     *
102     * @access public
103     */
104    public function set_defaults() {
105        $this->update_options_whitelist();
106        $this->update_options_contentless();
107    }
108
109    /**
110     * Set module defaults at a later time.
111     *
112     * @access public
113     */
114    public function set_late_default() {
115        /** This filter is already documented in json-endpoints/jetpack/class.wpcom-json-api-get-option-endpoint.php */
116        $late_options = apply_filters( 'jetpack_options_whitelist', array() );
117        if ( ! empty( $late_options ) && is_array( $late_options ) ) {
118            $this->options_whitelist = array_merge( $this->options_whitelist, $late_options );
119        }
120    }
121
122    /**
123     * Add old deprecated options to the list of options to keep in sync.
124     *
125     * @since 1.14.0
126     *
127     * @access public
128     *
129     * @param array $options The default list of site options.
130     */
131    public function add_deprecated_options( $options ) {
132        global $wp_version;
133
134        $deprecated_options = array(
135            'blacklist_keys'    => '5.5-alpha', // Replaced by disallowed_keys.
136            'comment_whitelist' => '5.5-alpha', // Replaced by comment_previously_approved.
137        );
138
139        foreach ( $deprecated_options as $option => $version ) {
140            if ( version_compare( $wp_version, $version, '<=' ) ) {
141                $options[] = $option;
142            }
143        }
144
145        return $options;
146    }
147
148    /**
149     * Enqueue the options actions for full sync.
150     *
151     * @access public
152     *
153     * @param array   $config               Full sync configuration for this sync module.
154     * @param int     $max_items_to_enqueue Maximum number of items to enqueue.
155     * @param boolean $state                True if full sync has finished enqueueing this module, false otherwise.
156     * @return array Number of actions enqueued, and next module state.
157     */
158    public function enqueue_full_sync_actions( $config, $max_items_to_enqueue, $state ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
159        /**
160         * Tells the client to sync all options to the server
161         *
162         * @since 1.6.3
163         * @since-jetpack 4.2.0
164         *
165         * @param boolean Whether to expand options (should always be true)
166         */
167        do_action( 'jetpack_full_sync_options', true );
168
169        // The number of actions enqueued, and next module state (true == done).
170        return array( 1, true );
171    }
172
173    /**
174     * Send the options actions for full sync.
175     *
176     * @access public
177     *
178     * @param array $config Full sync configuration for this sync module.
179     * @param array $status This module Full Sync status.
180     * @param int   $send_until The timestamp until the current request can send.
181     * @param int   $started The timestamp when the full sync started.
182     *
183     * @return array This module Full Sync status.
184     */
185    public function send_full_sync_actions( $config, $status, $send_until, $started ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
186        // we call this instead of do_action when sending immediately.
187        $result = $this->send_action( 'jetpack_full_sync_options', array( true ) );
188
189        if ( is_wp_error( $result ) ) {
190            $status['error'] = true;
191            return $status;
192        }
193        $status['finished'] = true;
194        $status['sent']     = $status['total'];
195        return $status;
196    }
197
198    /**
199     * Retrieve an estimated number of actions that will be enqueued.
200     *
201     * @access public
202     *
203     * @param array $config Full sync configuration for this sync module.
204     * @return int Number of items yet to be enqueued.
205     */
206    public function estimate_full_sync_actions( $config ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
207        return 1;
208    }
209
210    /**
211     * Retrieve the actions that will be sent for this module during a full sync.
212     *
213     * @access public
214     *
215     * @return array Full sync actions of this module.
216     */
217    public function get_full_sync_actions() {
218        return array( 'jetpack_full_sync_options' );
219    }
220
221    /**
222     * Retrieve all options as per the current options whitelist.
223     * Public so that we don't have to store so much data all the options twice.
224     *
225     * @access public
226     *
227     * @return array All options.
228     */
229    public function get_all_options() {
230        $options       = array();
231        $random_string = wp_generate_password();
232        foreach ( $this->options_whitelist as $option ) {
233            if ( str_starts_with( $option, Settings::SETTINGS_OPTION_PREFIX ) ) {
234                $option_value       = Settings::get_setting( str_replace( Settings::SETTINGS_OPTION_PREFIX, '', $option ) );
235                $options[ $option ] = $option_value;
236            } else {
237                $option_value = get_option( $option, $random_string );
238                if ( $option_value !== $random_string ) {
239                    $options[ $option ] = $option_value;
240                }
241            }
242        }
243
244        // Add theme mods.
245        $theme_mods_option = 'theme_mods_' . get_option( 'stylesheet' );
246        $theme_mods_value  = get_option( $theme_mods_option, $random_string );
247        if ( $theme_mods_value === $random_string ) {
248            return $options;
249        }
250        $this->filter_theme_mods( $theme_mods_value );
251        $options[ $theme_mods_option ] = $theme_mods_value;
252        return $options;
253    }
254
255    /**
256     * Update the options whitelist to the default one.
257     *
258     * @access public
259     */
260    public function update_options_whitelist() {
261        $this->options_whitelist = Defaults::get_options_whitelist();
262    }
263
264    /**
265     * Set the options whitelist.
266     *
267     * @access public
268     *
269     * @param array $options The new options whitelist.
270     */
271    public function set_options_whitelist( $options ) {
272        $this->options_whitelist = $options;
273    }
274
275    /**
276     * Get the options whitelist.
277     *
278     * @access public
279     *
280     * @return array The options whitelist.
281     */
282    public function get_options_whitelist() {
283        return $this->options_whitelist;
284    }
285
286    /**
287     * Update the contentless options to the defaults.
288     *
289     * @access public
290     */
291    public function update_options_contentless() {
292        $this->options_contentless = Defaults::get_options_contentless();
293    }
294
295    /**
296     * Get the contentless options.
297     *
298     * @access public
299     *
300     * @return array Array of the contentless options.
301     */
302    public function get_options_contentless() {
303        return $this->options_contentless;
304    }
305
306    /**
307     * Reject any options that aren't whitelisted or contentless.
308     *
309     * @access public
310     *
311     * @param array $args The hook parameters.
312     * @return array $args The hook parameters.
313     */
314    public function whitelist_options( $args ) {
315        // Reject non-whitelisted options.
316        if ( ! $this->is_whitelisted_option( $args[0] ) ) {
317            return false;
318        }
319
320        // Check if 'jetpack_options' were updated and reject if the change affected only blacklisted keys.
321        if ( 'jetpack_options' === $args[0] && 3 === count( $args ) ) {
322            if ( ! $this->should_enqueue_jetpack_options_update( $args[1], $args[2] ) ) {
323                return false;
324            }
325
326            return $args;
327        }
328
329        // Filter our weird array( false ) value for theme_mods_*.
330        if ( str_starts_with( $args[0], 'theme_mods_' ) ) {
331            $this->filter_theme_mods( $args[1] );
332            if ( isset( $args[2] ) ) {
333                $this->filter_theme_mods( $args[2] );
334            }
335        }
336
337        // Set value(s) of contentless option to empty string(s).
338        if ( $this->is_contentless_option( $args[0] ) ) {
339            // Create a new array matching length of $args, containing empty strings.
340            $empty    = array_fill( 0, count( $args ), '' );
341            $empty[0] = $args[0];
342            return $empty;
343        }
344
345        return $args;
346    }
347
348    /**
349     * Whether a certain option is whitelisted for sync.
350     *
351     * @access public
352     *
353     * @param string $option Option name.
354     * @return boolean Whether the option is whitelisted.
355     */
356    public function is_whitelisted_option( $option ) {
357        return in_array( $option, $this->options_whitelist, true ) || str_starts_with( $option, 'theme_mods_' );
358    }
359
360    /**
361     * Whether a certain option is a contentless one.
362     *
363     * @access private
364     *
365     * @param string $option Option name.
366     * @return boolean Whether the option is contentless.
367     */
368    private function is_contentless_option( $option ) {
369        return in_array( $option, $this->options_contentless, true );
370    }
371
372    /**
373     * Filters out falsy values from theme mod options.
374     *
375     * @access private
376     *
377     * @param array $value Option value.
378     */
379    private function filter_theme_mods( &$value ) {
380        if ( is_array( $value ) && isset( $value[0] ) ) {
381            unset( $value[0] );
382        }
383    }
384
385    /**
386     * Handle changes in the core site icon and sync them.
387     *
388     * @access public
389     */
390    public function jetpack_sync_core_icon() {
391        $url = get_site_icon_url();
392
393        $jetpack_url = \Jetpack_Options::get_option( 'site_icon_url' );
394        if ( defined( 'JETPACK__PLUGIN_DIR' ) ) {
395            if ( ! function_exists( 'jetpack_site_icon_url' ) ) {
396                require_once JETPACK__PLUGIN_DIR . 'modules/site-icon/site-icon-functions.php';
397            }
398            $jetpack_url = jetpack_site_icon_url();
399        }
400
401        // If there's a core icon, maybe update the option.  If not, fall back to Jetpack's.
402        if ( ! empty( $url ) && $jetpack_url !== $url ) {
403            // This is the option that is synced with dotcom.
404            \Jetpack_Options::update_option( 'site_icon_url', $url );
405        } elseif ( empty( $url ) ) {
406            \Jetpack_Options::delete_option( 'site_icon_url' );
407        }
408    }
409
410    /**
411     * Expand all options within a hook before they are serialized and sent to the server.
412     *
413     * @access public
414     *
415     * @param array $args The hook parameters.
416     * @return array $args The hook parameters.
417     */
418    public function expand_options( $args ) {
419        if ( $args[0] ) {
420            return $this->get_all_options();
421        }
422
423        return $args;
424    }
425
426    /**
427     * Return Total number of objects.
428     *
429     * @param array $config Full Sync config.
430     *
431     * @return int total
432     */
433    public function total( $config ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
434        return count( Defaults::get_options_whitelist() );
435    }
436
437    /**
438     * Retrieve a set of options by their IDs.
439     *
440     * @access public
441     *
442     * @param string $object_type Object type.
443     * @param array  $ids         Object IDs.
444     * @return array Array of objects.
445     */
446    public function get_objects_by_id( $object_type, $ids ) {
447        if ( empty( $ids ) || empty( $object_type ) || 'option' !== $object_type ) {
448            return array();
449        }
450
451        $objects = array();
452        foreach ( (array) $ids as $id ) {
453            $object = $this->get_object_by_id( $object_type, $id );
454
455            // Only add object if we have the object.
456            if ( 'OPTION-DOES-NOT-EXIST' !== $object ) {
457                if ( 'all' === $id ) {
458                    // If all was requested it contains all options and can simply be returned.
459                    return $object;
460                }
461                $objects[ $id ] = $object;
462            }
463        }
464
465        return $objects;
466    }
467
468    /**
469     * Retrieve an option by its name.
470     *
471     * @access public
472     *
473     * @param string $object_type Type of the sync object.
474     * @param string $id          ID of the sync object.
475     * @return mixed              Value of Option or 'OPTION-DOES-NOT-EXIST' if not found.
476     */
477    public function get_object_by_id( $object_type, $id ) {
478        if ( 'option' === $object_type ) {
479            // Utilize Random string as default value to distinguish between false and not exist.
480            $random_string = wp_generate_password();
481            // Only whitelisted options can be returned.
482            if ( in_array( $id, $this->options_whitelist, true ) ) {
483                if ( str_starts_with( $id, Settings::SETTINGS_OPTION_PREFIX ) ) {
484                    $option_value = Settings::get_setting( str_replace( Settings::SETTINGS_OPTION_PREFIX, '', $id ) );
485                    return $option_value;
486                } else {
487                    $option_value = get_option( $id, $random_string );
488                    if ( $option_value !== $random_string ) {
489                        return $option_value;
490                    }
491                }
492            } elseif ( 'all' === $id ) {
493                return $this->get_all_options();
494            }
495        }
496
497        return 'OPTION-DOES-NOT-EXIST';
498    }
499
500    /**
501     * Check if 'jetpack_options' option update should be processed based on excluded keys.
502     *
503     * @param mixed $old_value    The old option value.
504     * @param mixed $value        The new option value.
505     * @return bool False if only excluded keys changed (or no change), true otherwise.
506     */
507    private function should_enqueue_jetpack_options_update( $old_value, $value ) {
508        // No changes at all.
509        if ( $old_value === $value ) {
510            return false;
511        }
512        // Values are different but not both arrays - meaningful change.
513        if ( ! is_array( $old_value ) || ! is_array( $value ) ) {
514            return true;
515        }
516        // Determine all top-level keys present in either the old or new value.
517        $all_keys = array_unique(
518            array_merge(
519                array_keys( $old_value ),
520                array_keys( $value )
521            )
522        );
523        // Short-circuit as soon as we find a changed key that is not blacklisted.
524        foreach ( $all_keys as $key ) {
525            $old_has_key = array_key_exists( $key, $old_value );
526            $new_has_key = array_key_exists( $key, $value );
527            // Key was added or removed.
528            if ( ! $old_has_key || ! $new_has_key ) {
529                if ( ! in_array( $key, Defaults::$jetpack_options_blacklist, true ) ) {
530                    return true;
531                }
532                continue;
533            }
534            // Key exists in both arrays but the value changed.
535            if ( $old_value[ $key ] !== $value[ $key ] ) {
536                if ( ! in_array( $key, Defaults::$jetpack_options_blacklist, true ) ) {
537                    return true;
538                }
539            }
540        }
541        // Either there were no effective changes, or all changed keys are excluded.
542        return false;
543    }
544}