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