testers] Double check that validation will not cause errors when called * from upgrade routine (some of the WP functions may not yet be available). */ abstract class WPSEO_Option { /** * Prefix for override option keys that allow or disallow the option key of the same name. * * @var string */ const ALLOW_KEY_PREFIX = 'allow_'; /** * Option name - MUST be set in concrete class and set to public. * * @var string */ protected $option_name; /** * Option group name for use in settings forms. * * Will be set automagically if not set in concrete class (i.e. * if it confirm to the normal pattern 'yoast' . $option_name . 'options', * only set in conrete class if it doesn't). * * @var string */ public $group_name; /** * Whether to include the option in the return for WPSEO_Options::get_all(). * * Also determines which options are copied over for ms_(re)set_blog(). * * @var bool */ public $include_in_all = true; /** * Whether this option is only for when the install is multisite. * * @var bool */ public $multisite_only = false; /** * Array of defaults for the option - MUST be set in concrete class. * * Shouldn't be requested directly, use $this->get_defaults(); * * @var array */ protected $defaults; /** * Array of variable option name patterns for the option - if any -. * * Set this when the option contains array keys which vary based on post_type * or taxonomy. * * @var array */ protected $variable_array_key_patterns; /** * Array of sub-options which should not be overloaded with multi-site defaults. * * @var array */ public $ms_exclude = []; /** * Name for an option higher in the hierarchy to override setting access. * * @var string */ protected $override_option_name; /** * Instance of this class. * * @var WPSEO_Option */ protected static $instance; /* *********** INSTANTIATION METHODS *********** */ /** * Add all the actions and filters for the option. */ protected function __construct() { /* Add filters which get applied to the get_options() results. */ $this->add_default_filters(); // Return defaults if option not set. $this->add_option_filters(); // Merge with defaults if option *is* set. if ( $this->multisite_only !== true ) { /** * The option validation routines remove the default filters to prevent failing * to insert an option if it's new. Let's add them back afterwards. */ add_action( 'add_option', [ $this, 'add_default_filters_if_same_option' ] ); // Adding back after INSERT. add_action( 'update_option', [ $this, 'add_default_filters_if_same_option' ] ); add_filter( 'pre_update_option', [ $this, 'add_default_filters_if_not_changed' ], PHP_INT_MAX, 3 ); // Refills the cache when the option has been updated. add_action( 'update_option_' . $this->option_name, [ 'WPSEO_Options', 'clear_cache' ], 10 ); } elseif ( is_multisite() ) { /* * The option validation routines remove the default filters to prevent failing * to insert an option if it's new. Let's add them back afterwards. * * For site_options, this method is not foolproof as these actions are not fired * on an insert/update failure. Please use the WPSEO_Options::update_site_option() method * for updating site options to make sure the filters are in place. */ add_action( 'add_site_option_' . $this->option_name, [ $this, 'add_default_filters' ] ); add_action( 'update_site_option_' . $this->option_name, [ $this, 'add_default_filters' ] ); add_filter( 'pre_update_site_option_' . $this->option_name, [ $this, 'add_default_filters_if_not_changed' ], PHP_INT_MAX, 3 ); // Refills the cache when the option has been updated. add_action( 'update_site_option_' . $this->option_name, [ 'WPSEO_Options', 'clear_cache' ], 1, 0 ); } /* * Make sure the option will always get validated, independently of register_setting() * (only available on back-end). */ add_filter( 'sanitize_option_' . $this->option_name, [ $this, 'validate' ] ); // Flushes the rewrite rules when option is updated. add_action( 'update_option_' . $this->option_name, [ 'WPSEO_Utils', 'clear_rewrites' ] ); /* Register our option for the admin pages */ add_action( 'admin_init', [ $this, 'register_setting' ] ); /* Set option group name if not given */ if ( ! isset( $this->group_name ) || $this->group_name === '' ) { $this->group_name = 'yoast_' . $this->option_name . '_options'; } /* Translate some defaults as early as possible - textdomain is loaded in init on priority 1. */ if ( method_exists( $this, 'translate_defaults' ) ) { add_action( 'init', [ $this, 'translate_defaults' ], 2 ); } /** * Enrich defaults once custom post types and taxonomies have been registered * which is normally done on the init action. * * @todo [JRF/testers] Verify that none of the options which are only available after * enrichment are used before the enriching. */ if ( method_exists( $this, 'enrich_defaults' ) ) { add_action( 'init', [ $this, 'enrich_defaults' ], 99 ); } } /* * All concrete classes *must* contain the get_instance method. * * {@internal Unfortunately I can't define it as an abstract as it also *has* to be static...}} * * ``` * abstract protected static function get_instance(); * ``` * --------------- * * Concrete classes *may* contain a translate_defaults method. * ``` * abstract public function translate_defaults(); * ``` * --------------- * * Concrete classes *may* contain a enrich_defaults method to add additional defaults once * all post_types and taxonomies have been registered. * * ``` * abstract public function enrich_defaults(); * ``` */ /* *********** METHODS INFLUENCING get_option() *********** */ /** * Add filters to make sure that the option default is returned if the option is not set. * * @return void */ public function add_default_filters() { // Don't change, needs to check for false as could return prio 0 which would evaluate to false. if ( has_filter( 'default_option_' . $this->option_name, [ $this, 'get_defaults' ] ) === false ) { add_filter( 'default_option_' . $this->option_name, [ $this, 'get_defaults' ] ); } } /** * Adds back the default filters that were removed during validation if the option was changed. * Checks if this option was changed to prevent constantly checking if filters are present. * * @param string $option_name The option name. * * @return void */ public function add_default_filters_if_same_option( $option_name ) { if ( $option_name === $this->option_name ) { $this->add_default_filters(); } } /** * Adds back the default filters that were removed during validation if the option was not changed. * This is because in that case the latter actions are not called and thus the filters are never * added back. * * @param mixed $value The current value. * @param string $option_name The option name. * @param mixed $old_value The old value. * * @return string The current value. */ public function add_default_filters_if_not_changed( $value, $option_name, $old_value ) { if ( $option_name !== $this->option_name ) { return $value; } if ( $value === $old_value || maybe_serialize( $value ) === maybe_serialize( $old_value ) ) { $this->add_default_filters(); } return $value; } /** * Validate webmaster tools & Pinterest verification strings. * * @param string $key Key to check, by type of service. * @param array $dirty Dirty data with the new values. * @param array $old Old data. * @param array $clean Clean data by reference, normally the default values. */ public function validate_verification_string( $key, $dirty, $old, &$clean ) { if ( isset( $dirty[ $key ] ) && $dirty[ $key ] !== '' ) { $meta = $dirty[ $key ]; if ( strpos( $meta, 'content=' ) ) { // Make sure we only have the real key, not a complete meta tag. preg_match( '`content=([\'"])?([^\'"> ]+)(?:\1|[ />])`', $meta, $match ); if ( isset( $match[2] ) ) { $meta = $match[2]; } unset( $match ); } $meta = sanitize_text_field( $meta ); if ( $meta !== '' ) { $regex = '`^[A-Fa-f0-9_-]+$`'; $service = ''; switch ( $key ) { case 'baiduverify': $regex = '`^[A-Za-z0-9_-]+$`'; $service = 'Baidu Webmaster tools'; break; case 'googleverify': $regex = '`^[A-Za-z0-9_-]+$`'; $service = 'Google Webmaster tools'; break; case 'msverify': $service = 'Bing Webmaster tools'; break; case 'pinterestverify': $service = 'Pinterest'; break; case 'yandexverify': $service = 'Yandex Webmaster tools'; break; } if ( preg_match( $regex, $meta ) ) { $clean[ $key ] = $meta; } else { // Restore the previous value, if any. if ( isset( $old[ $key ] ) && preg_match( $regex, $old[ $key ] ) ) { $clean[ $key ] = $old[ $key ]; } if ( function_exists( 'add_settings_error' ) ) { add_settings_error( $this->group_name, // Slug title of the setting. $key, // Suffix-ID for the error message box. WordPress prepends `setting-error-`. /* translators: 1: Verification string from user input; 2: Service name. */ sprintf( __( '%1$s does not seem to be a valid %2$s verification string. Please correct.', 'wordpress-seo' ), '' . esc_html( $meta ) . '', $service ), // The error message. 'error' // CSS class for the WP notice, either the legacy 'error' / 'updated' or the new `notice-*` ones. ); } Yoast_Input_Validation::add_dirty_value_to_settings_errors( $key, $meta ); } } } } /** * Validates an option as a valid URL. Prints out a WordPress settings error * notice if the URL is invalid. * * @param string $key Key to check, by type of URL setting. * @param array $dirty Dirty data with the new values. * @param array $old Old data. * @param array $clean Clean data by reference, normally the default values. */ public function validate_url( $key, $dirty, $old, &$clean ) { if ( isset( $dirty[ $key ] ) && $dirty[ $key ] !== '' ) { $submitted_url = trim( $dirty[ $key ] ); $validated_url = filter_var( WPSEO_Utils::sanitize_url( $submitted_url ), FILTER_VALIDATE_URL ); if ( $validated_url === false ) { if ( function_exists( 'add_settings_error' ) ) { add_settings_error( // Slug title of the setting. $this->group_name, // Suffix-ID for the error message box. WordPress prepends `setting-error-`. $key, // The error message. sprintf( /* translators: %s expands to an invalid URL. */ __( '%s does not seem to be a valid url. Please correct.', 'wordpress-seo' ), '' . esc_url( $submitted_url ) . '' ), // Message type. 'error' ); } // Restore the previous URL value, if any. if ( isset( $old[ $key ] ) && $old[ $key ] !== '' ) { $url = WPSEO_Utils::sanitize_url( $old[ $key ] ); if ( $url !== '' ) { $clean[ $key ] = $url; } } Yoast_Input_Validation::add_dirty_value_to_settings_errors( $key, $submitted_url ); return; } // The URL format is valid, let's sanitize it. $url = WPSEO_Utils::sanitize_url( $validated_url ); if ( $url !== '' ) { $clean[ $key ] = $url; } } } /** * Remove the default filters. * Called from the validate() method to prevent failure to add new options. * * @return void */ public function remove_default_filters() { remove_filter( 'default_option_' . $this->option_name, [ $this, 'get_defaults' ] ); } /** * Get the enriched default value for an option. * * Checks if the concrete class contains an enrich_defaults() method and if so, runs it. * * {@internal The enrich_defaults method is used to set defaults for variable array keys * in an option, such as array keys depending on post_types and/or taxonomies.}} * * @return array */ public function get_defaults() { if ( method_exists( $this, 'translate_defaults' ) ) { $this->translate_defaults(); } if ( method_exists( $this, 'enrich_defaults' ) ) { $this->enrich_defaults(); } return apply_filters( 'wpseo_defaults', $this->defaults, $this->option_name ); } /** * Add filters to make sure that the option is merged with its defaults before being returned. * * @return void */ public function add_option_filters() { // Don't change, needs to check for false as could return prio 0 which would evaluate to false. if ( has_filter( 'option_' . $this->option_name, [ $this, 'get_option' ] ) === false ) { add_filter( 'option_' . $this->option_name, [ $this, 'get_option' ] ); } } /** * Remove the option filters. * Called from the clean_up methods to make sure we retrieve the original old option. * * @return void */ public function remove_option_filters() { remove_filter( 'option_' . $this->option_name, [ $this, 'get_option' ] ); } /** * Merge an option with its default values. * * This method should *not* be called directly!!! It is only meant to filter the get_option() results. * * @param mixed $options Option value. * * @return mixed Option merged with the defaults for that option. */ public function get_option( $options = null ) { $filtered = $this->array_filter_merge( $options ); /* * If the option contains variable option keys, make sure we don't remove those settings * - even if the defaults are not complete yet. * Unfortunately this means we also won't be removing the settings for post types or taxonomies * which are no longer in the WP install, but rather that than the other way around. */ if ( isset( $this->variable_array_key_patterns ) ) { $filtered = $this->retain_variable_keys( $options, $filtered ); } return $filtered; } /* *********** METHODS influencing add_uption(), update_option() and saving from admin pages. *********** */ /** * Register (whitelist) the option for the configuration pages. * The validation callback is already registered separately on the sanitize_option hook, * so no need to double register. * * @return void */ public function register_setting() { if ( ! WPSEO_Capability_Utils::current_user_can( 'wpseo_manage_options' ) ) { return; } if ( $this->multisite_only === true ) { $network_settings_api = Yoast_Network_Settings_API::get(); if ( $network_settings_api->meets_requirements() ) { $network_settings_api->register_setting( $this->group_name, $this->option_name ); } return; } register_setting( $this->group_name, $this->option_name ); } /** * Validate the option * * @param mixed $option_value The unvalidated new value for the option. * * @return array Validated new value for the option. */ public function validate( $option_value ) { $clean = $this->get_defaults(); /* Return the defaults if the new value is empty. */ if ( ! is_array( $option_value ) || $option_value === [] ) { return $clean; } $option_value = array_map( [ 'WPSEO_Utils', 'trim_recursive' ], $option_value ); $old = $this->get_original_option(); if ( ! is_array( $old ) ) { $old = []; } $old = array_merge( $clean, $old ); $clean = $this->validate_option( $option_value, $clean, $old ); // Prevent updates to variables that are disabled via the override option. $clean = $this->prevent_disabled_options_update( $clean, $old ); /* Retain the values for variable array keys even when the post type/taxonomy is not yet registered. */ if ( isset( $this->variable_array_key_patterns ) ) { $clean = $this->retain_variable_keys( $option_value, $clean ); } $this->remove_default_filters(); return $clean; } /** * Checks whether a specific option key is disabled. * * This is determined by whether an override option is available with a key that equals the given key prefixed * with 'allow_'. * * @param string $key Option key. * * @return bool True if option key is disabled, false otherwise. */ public function is_disabled( $key ) { $override_option = $this->get_override_option(); if ( empty( $override_option ) ) { return false; } return isset( $override_option[ self::ALLOW_KEY_PREFIX . $key ] ) && ! $override_option[ self::ALLOW_KEY_PREFIX . $key ]; } /** * All concrete classes must contain a validate_option() method which validates all * values within the option. * * @param array $dirty New value for the option. * @param array $clean Clean value for the option, normally the defaults. * @param array $old Old value of the option. */ abstract protected function validate_option( $dirty, $clean, $old ); /* *********** METHODS for ADDING/UPDATING/UPGRADING the option. *********** */ /** * Retrieve the real old value (unmerged with defaults). * * @return array|bool The original option value (which can be false if the option doesn't exist). */ protected function get_original_option() { $this->remove_default_filters(); $this->remove_option_filters(); // Get (unvalidated) array, NOT merged with defaults. if ( $this->multisite_only !== true ) { $option_value = get_option( $this->option_name ); } else { $option_value = get_site_option( $this->option_name ); } $this->add_option_filters(); $this->add_default_filters(); return $option_value; } /** * Add the option if it doesn't exist for some strange reason. * * @uses WPSEO_Option::get_original_option() * * @return void */ public function maybe_add_option() { if ( $this->get_original_option() === false ) { if ( $this->multisite_only !== true ) { update_option( $this->option_name, $this->get_defaults() ); } else { $this->update_site_option( $this->get_defaults() ); } } } /** * Update a site_option. * * {@internal This special method is only needed for multisite options, but very needed indeed there. * The order in which certain functions and hooks are run is different between * get_option() and get_site_option() which means in practice that the removing * of the default filters would be done too late and the re-adding of the default * filters might not be done at all. * Aka: use the WPSEO_Options::update_site_option() method (which calls this method) * for safely adding/updating multisite options.}} * * @param mixed $value The new value for the option. * * @return bool Whether the update was succesfull. */ public function update_site_option( $value ) { if ( $this->multisite_only === true && is_multisite() ) { $this->remove_default_filters(); $result = update_site_option( $this->option_name, $value ); $this->add_default_filters(); return $result; } else { return false; } } /** * Retrieve the real old value (unmerged with defaults), clean and re-save the option. * * @uses WPSEO_Option::get_original_option() * @uses WPSEO_Option::import() * * @param string|null $current_version Optional. Version from which to upgrade, if not set, * version specific upgrades will be disregarded. * * @return void */ public function clean( $current_version = null ) { $option_value = $this->get_original_option(); $this->import( $option_value, $current_version ); } /** * Clean and re-save the option. * * @uses clean_option() method from concrete class if it exists. * * @todo [JRF/whomever] Figure out a way to show settings error during/after the upgrade - maybe * something along the lines of: * -> add them to a property in this class * -> if that property isset at the end of the routine and add_settings_error function does not exist, * save as transient (or update the transient if one already exists) * -> next time an admin is in the WP back-end, show the errors and delete the transient or only delete it * once the admin has dismissed the message (add ajax function) * Important: all validation routines which add_settings_errors would need to be changed for this to work * * @param array $option_value Option value to be imported. * @param string|null $current_version Optional. Version from which to upgrade, if not set, * version specific upgrades will be disregarded. * @param array|null $all_old_option_values Optional. Only used when importing old options to * have access to the real old values, in contrast to * the saved ones. * * @return void */ public function import( $option_value, $current_version = null, $all_old_option_values = null ) { if ( $option_value === false ) { $option_value = $this->get_defaults(); } elseif ( is_array( $option_value ) && method_exists( $this, 'clean_option' ) ) { $option_value = $this->clean_option( $option_value, $current_version, $all_old_option_values ); } /* * Save the cleaned value - validation will take care of cleaning out array keys which * should no longer be there. */ if ( $this->multisite_only !== true ) { update_option( $this->option_name, $option_value ); } else { $this->update_site_option( $this->option_name, $option_value ); } } /** * Returns the variable array key patterns for an options class. * * @return array */ public function get_patterns() { return (array) $this->variable_array_key_patterns; } /** * Retrieves the option name. * * @return string The set option name. */ public function get_option_name() { return $this->option_name; } /* * Concrete classes *may* contain a clean_option method which will clean out old/renamed * values within the option. * * ``` * abstract public function clean_option( $option_value, $current_version = null, $all_old_option_values = null ); * ``` */ /* *********** HELPER METHODS for internal use. *********** */ /** * Helper method - Combines a fixed array of default values with an options array * while filtering out any keys which are not in the defaults array. * * @todo [JRF] - shouldn't this be a straight array merge ? at the end of the day, the validation * removes any invalid keys on save. * * @param array|null $options Optional. Current options. If not set, the option defaults * for the $option_key will be returned. * * @return array Combined and filtered options array. */ protected function array_filter_merge( $options = null ) { $defaults = $this->get_defaults(); if ( ! isset( $options ) || $options === false || $options === [] ) { return $defaults; } $options = (array) $options; /* $filtered = array(); if ( $defaults !== array() ) { foreach ( $defaults as $key => $default_value ) { // @todo should this walk through array subkeys ? $filtered[ $key ] = ( isset( $options[ $key ] ) ? $options[ $key ] : $default_value ); } } */ $filtered = array_merge( $defaults, $options ); return $filtered; } /** * Sets updated values for variables that are disabled via the override option back to their previous values. * * @param array $updated Updated option value. * @param array $old Old option value. * * @return array Updated option value, with all disabled variables set to their old values. */ protected function prevent_disabled_options_update( $updated, $old ) { $override_option = $this->get_override_option(); if ( empty( $override_option ) ) { return $updated; } /* * This loop could as well call `is_disabled( $key )` for each iteration, * however this would be worse performance-wise. */ foreach ( $old as $key => $value ) { if ( isset( $override_option[ self::ALLOW_KEY_PREFIX . $key ] ) && ! $override_option[ self::ALLOW_KEY_PREFIX . $key ] ) { $updated[ $key ] = $old[ $key ]; } } return $updated; } /** * Retrieves the value of the override option, if available. * * An override option contains values that may determine access to certain sub-variables * of this option. * * Only regular options in multisite can have override options, which in that case * would be network options. * * @return array Override option value, or empty array if unavailable. */ protected function get_override_option() { if ( empty( $this->override_option_name ) || $this->multisite_only === true || ! is_multisite() ) { return []; } return get_site_option( $this->override_option_name, [] ); } /** * Make sure that any set option values relating to post_types and/or taxonomies are retained, * even when that post_type or taxonomy may not yet have been registered. * * {@internal The wpseo_titles concrete class overrules this method. Make sure that any * changes applied here, also get ported to that version.}} * * @param array $dirty Original option as retrieved from the database. * @param array $clean Filtered option where any options which shouldn't be in our option * have already been removed and any options which weren't set * have been set to their defaults. * * @return array */ protected function retain_variable_keys( $dirty, $clean ) { if ( ( is_array( $this->variable_array_key_patterns ) && $this->variable_array_key_patterns !== [] ) && ( is_array( $dirty ) && $dirty !== [] ) ) { foreach ( $dirty as $key => $value ) { // Do nothing if already in filtered options. if ( isset( $clean[ $key ] ) ) { continue; } foreach ( $this->variable_array_key_patterns as $pattern ) { if ( strpos( $key, $pattern ) === 0 ) { $clean[ $key ] = $value; break; } } } } return $clean; } /** * Check whether a given array key conforms to one of the variable array key patterns for this option. * * @usedby validate_option() methods for options with variable array keys. * * @param string $key Array key to check. * * @return string Pattern if it conforms, original array key if it doesn't or if the option * does not have variable array keys. */ protected function get_switch_key( $key ) { if ( ! isset( $this->variable_array_key_patterns ) || ( ! is_array( $this->variable_array_key_patterns ) || $this->variable_array_key_patterns === [] ) ) { return $key; } foreach ( $this->variable_array_key_patterns as $pattern ) { if ( strpos( $key, $pattern ) === 0 ) { return $pattern; } } return $key; } }