> */ public static function schema() { return array( 'general.replace_wp_login' => array( 'type' => 'bool', 'default' => false, ), 'general.emergency_slug' => array( 'type' => 'slug', 'default' => '', ), 'general.default_country' => array( 'type' => 'country', 'default' => 'US', ), 'general.legacy_phone_meta_keys' => array( 'type' => 'textarea', 'default' => 'digits_phone', ), 'general.login_redirect' => array( 'type' => 'url', 'default' => '', ), 'general.delete_data_on_uninstall' => array( 'type' => 'bool', 'default' => false, ), 'forms.login_page_id' => array( 'type' => 'page', 'default' => 0, ), 'forms.allow_registration' => array( 'type' => 'bool', 'default' => true, ), 'forms.accept_phone' => array( 'type' => 'bool', 'default' => true, ), 'forms.accept_email' => array( 'type' => 'bool', 'default' => true, ), 'forms.accept_username' => array( 'type' => 'bool', 'default' => true, ), 'forms.allow_otp' => array( 'type' => 'bool', 'default' => true, ), 'forms.allow_password' => array( 'type' => 'bool', 'default' => true, ), 'forms.register_name' => array( 'type' => 'enum', 'default' => 'optional', 'choices' => array( 'hidden', 'optional', 'required' ), ), 'forms.register_password' => array( 'type' => 'enum', 'default' => 'hidden', 'choices' => array( 'hidden', 'optional', 'required' ), ), 'forms.field_first_name' => array( 'type' => 'enum', 'default' => 'hidden', 'choices' => array( 'hidden', 'optional', 'required' ), ), 'forms.field_last_name' => array( 'type' => 'enum', 'default' => 'hidden', 'choices' => array( 'hidden', 'optional', 'required' ), ), 'forms.field_email' => array( 'type' => 'enum', 'default' => 'hidden', 'choices' => array( 'hidden', 'optional', 'required' ), ), 'forms.field_username' => array( 'type' => 'enum', 'default' => 'hidden', 'choices' => array( 'hidden', 'optional', 'required' ), ), 'forms.field_password_confirm' => array( 'type' => 'enum', 'default' => 'hidden', 'choices' => array( 'hidden', 'optional', 'required' ), ), 'forms.field_national_id' => array( 'type' => 'enum', 'default' => 'hidden', 'choices' => array( 'hidden', 'optional', 'required' ), ), 'forms.field_postal_code' => array( 'type' => 'enum', 'default' => 'hidden', 'choices' => array( 'hidden', 'optional', 'required' ), ), 'forms.field_birth_date' => array( 'type' => 'enum', 'default' => 'hidden', 'choices' => array( 'hidden', 'optional', 'required' ), ), 'forms.national_id_unique' => array( 'type' => 'bool', 'default' => true, ), 'forms.custom_fields' => array( 'type' => 'custom_fields', 'default' => array(), ), 'forms.date_calendar' => array( 'type' => 'enum', 'default' => 'gregorian', 'choices' => array( 'gregorian', 'jalali', 'both' ), ), 'forms.password_min_length' => array( 'type' => 'int', 'default' => 8, 'min' => 6, 'max' => 64, ), 'forms.terms_required' => array( 'type' => 'bool', 'default' => false, ), 'forms.terms_url' => array( 'type' => 'url', 'default' => '', ), 'forms.accent_color' => array( 'type' => 'color', 'default' => '#2563eb', ), 'forms.google_link_by_email' => array( 'type' => 'bool', 'default' => true, ), 'woocommerce.replace_account_login' => array( 'type' => 'bool', 'default' => false, ), 'woocommerce.verify_checkout_phone' => array( 'type' => 'bool', 'default' => false, ), 'woocommerce.sync_billing_phone' => array( 'type' => 'bool', 'default' => true, ), 'woocommerce.sync_billing_details' => array( 'type' => 'bool', 'default' => true, ), 'forms.email_verify' => array( 'type' => 'enum', 'default' => 'off', 'choices' => array( 'off', 'optional', 'required' ), ), 'forms.channel_choice' => array( 'type' => 'bool', 'default' => true, ), 'forms.country_selector' => array( 'type' => 'bool', 'default' => false, ), 'twofa.mode' => array( 'type' => 'enum', 'default' => 'off', 'choices' => array( 'off', 'optional', 'required' ), ), 'twofa.scope' => array( 'type' => 'enum', 'default' => 'all', 'choices' => array( 'all', 'roles', 'users' ), ), 'twofa.roles' => array( 'type' => 'roles', 'default' => '', ), 'twofa.users' => array( 'type' => 'textarea', 'default' => '', ), 'twofa.issuer' => array( 'type' => 'text', 'default' => '', ), 'google.enabled' => array( 'type' => 'bool', 'default' => false, ), 'google.client_id' => array( 'type' => 'text', 'default' => '', ), 'security.blocked_identifiers' => array( 'type' => 'textarea', 'default' => '', ), 'security.blocked_ips' => array( 'type' => 'textarea', 'default' => '', ), 'security.allowed_countries' => array( 'type' => 'textarea', 'default' => '', ), 'security.notify_user_login' => array( 'type' => 'bool', 'default' => false, ), 'security.notify_admin_login' => array( 'type' => 'bool', 'default' => false, ), 'security.hide_account_existence' => array( 'type' => 'bool', 'default' => false, ), 'channels.message_template' => array( 'type' => 'text', 'default' => '{code} is your {site_title} verification code. It expires in {minutes} minutes.', ), 'channels.email_subject' => array( 'type' => 'text', 'default' => 'Your {site_title} verification code', ), 'channels.email_body' => array( 'type' => 'textarea', 'default' => "Your verification code is: {code}\n\nIt expires in {minutes} minutes. If you did not request it, you can ignore this message.", ), 'otp.length' => array( 'type' => 'int', 'default' => 6, 'min' => 4, 'max' => 8, ), 'otp.expiry' => array( 'type' => 'int', 'default' => 300, 'min' => 60, 'max' => 3600, ), 'otp.resend_delay' => array( 'type' => 'int', 'default' => 60, 'min' => 10, 'max' => 600, ), 'otp.max_attempts' => array( 'type' => 'int', 'default' => 5, 'min' => 1, 'max' => 10, ), 'otp.send_limit' => array( 'type' => 'int', 'default' => 5, 'min' => 1, 'max' => 50, ), 'otp.send_window' => array( 'type' => 'int', 'default' => 600, 'min' => 60, 'max' => 86400, ), 'security.ip_send_limit' => array( 'type' => 'int', 'default' => 20, 'min' => 1, 'max' => 500, ), 'security.verify_fail_limit' => array( 'type' => 'int', 'default' => 10, 'min' => 3, 'max' => 100, ), 'security.lockout_duration' => array( 'type' => 'int', 'default' => 900, 'min' => 60, 'max' => 86400, ), 'security.ip_header' => array( 'type' => 'enum', 'default' => 'REMOTE_ADDR', 'choices' => array( 'REMOTE_ADDR', 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR' ), ), 'security.audit_enabled' => array( 'type' => 'bool', 'default' => true, ), 'security.audit_retention_days' => array( 'type' => 'int', 'default' => 90, 'min' => 0, 'max' => 3650, ), ); } /** * Returns the default value of every key. * * @return array */ public static function defaults() { $defaults = array(); foreach ( self::schema() as $key => $definition ) { $defaults[ $key ] = $definition['default']; } $defaults['general.default_country'] = self::locale_country(); return $defaults; } /** * Guesses the default country from the site language, for example fa_IR gives IR. * * @return string ISO country code, or "US" when the language has no region. */ private static function locale_country() { $locale = function_exists( 'get_locale' ) ? (string) get_locale() : ''; if ( preg_match( '/^[a-z]{2,3}_([A-Z]{2})/', $locale, $match ) && Phone_Countries::exists( $match[1] ) ) { return $match[1]; } return 'US'; } /** * Returns one setting, falling back to its default. * * @param string $key Dot path, for example "otp.length". * @return mixed */ public static function get( $key ) { $all = self::all(); return array_key_exists( $key, $all ) ? $all[ $key ] : null; } /** * Returns every setting merged over the defaults. * * @return array */ public static function all() { if ( null === self::$cache ) { $stored = get_option( self::OPTION_NAME, array() ); $stored = is_array( $stored ) ? $stored : array(); self::$cache = array_merge( self::defaults(), array_intersect_key( $stored, self::schema() ) ); // Early versions stored this setting as on or off. On meant the code was required. if ( ! in_array( self::$cache['forms.email_verify'], array( 'off', 'optional', 'required' ), true ) ) { self::$cache['forms.email_verify'] = self::$cache['forms.email_verify'] ? 'required' : 'off'; } } return self::$cache; } /** * Sanitizes submitted values and merges them over the stored ones. * * Only keys present in the input change, so one admin tab never resets * the settings of another tab. Used as the register_setting callback. * * @param mixed $input Raw submitted array. * @return array */ public static function sanitize_input( $input ) { $current = self::all(); if ( ! is_array( $input ) ) { return $current; } foreach ( self::schema() as $key => $definition ) { $field = str_replace( '.', '__', $key ); // WordPress may run this callback twice for a new option (update_option, then add_option), // so values that already use dot keys must be accepted too. if ( array_key_exists( $field, $input ) ) { $current[ $key ] = self::sanitize_value( $definition, $input[ $field ] ); } elseif ( array_key_exists( $key, $input ) ) { $current[ $key ] = self::sanitize_value( $definition, $input[ $key ] ); } } // Never allow replacing wp-login.php without a way back in. if ( ! empty( $current['general.replace_wp_login'] ) && '' === (string) $current['general.emergency_slug'] ) { $current['general.replace_wp_login'] = false; } self::$cache = null; return $current; } /** * Sanitizes a single value according to its schema definition. * * @param array $definition Schema entry. * @param mixed $value Raw value. * @return mixed */ public static function sanitize_value( array $definition, $value ) { if ( 'roles' === $definition['type'] ) { return self::sanitize_roles( $value ); } if ( 'custom_fields' === $definition['type'] ) { return Custom_Fields::sanitize_definitions( $value ); } if ( is_array( $value ) ) { return $definition['default']; } switch ( $definition['type'] ) { case 'bool': return in_array( (string) $value, array( '1', 'true', 'on', 'yes' ), true ); case 'int': $number = (int) $value; $min = isset( $definition['min'] ) ? (int) $definition['min'] : $number; $max = isset( $definition['max'] ) ? (int) $definition['max'] : $number; return max( $min, min( $max, $number ) ); case 'enum': $value = (string) $value; return in_array( $value, $definition['choices'], true ) ? $value : $definition['default']; case 'country': $value = strtoupper( (string) $value ); return Phone_Countries::exists( $value ) ? $value : $definition['default']; case 'slug': return sanitize_title( (string) $value ); case 'url': return esc_url_raw( (string) $value ); case 'page': return absint( $value ); case 'color': $color = sanitize_hex_color( (string) $value ); return $color ? $color : $definition['default']; case 'textarea': return sanitize_textarea_field( (string) $value ); default: return sanitize_text_field( (string) $value ); } } /** * Keeps only role slugs that exist and returns them as a comma separated string. * * @param mixed $value Array of slugs or a comma separated string. * @return string */ private static function sanitize_roles( $value ) { $list = is_array( $value ) ? $value : explode( ',', (string) $value ); $roles = array(); foreach ( $list as $slug ) { $slug = sanitize_key( (string) $slug ); if ( '' !== $slug && wp_roles()->is_role( $slug ) ) { $roles[] = $slug; } } return implode( ',', array_unique( $roles ) ); } /** * Clears the request cache. Needed when the option changes elsewhere. * * @return void */ public static function flush_cache() { self::$cache = null; } }