> */ private function definitions(): array { /** * Filters the settings schema. Add-ons can add groups; keys are group names. * * @since 1.0.0 * * @param array> $definitions Group schemas. */ return apply_filters( 'elevenrank_settings_schema', Settings_Schema::definitions() ); } /** * Names of all setting groups. * * @since 1.0.0 * * @return string[] */ public function groups(): array { return array_map( 'strval', array_keys( $this->definitions() ) ); } /** * Schema of one group, or an empty array for an unknown group. * * @since 1.0.0 * * @param string $group Group name. * @return array */ public function schema( string $group ): array { $definitions = $this->definitions(); return isset( $definitions[ $group ] ) ? $definitions[ $group ] : array(); } /** * Default values of one group. * * @since 1.0.0 * * @param string $group Group name. * @return array */ public function defaults( string $group ): array { $schema = $this->schema( $group ); $defaults = array(); if ( empty( $schema['properties'] ) || ! is_array( $schema['properties'] ) ) { return $defaults; } foreach ( $schema['properties'] as $key => $property ) { if ( is_array( $property ) && array_key_exists( 'default', $property ) ) { $defaults[ $key ] = $property['default']; } } return $defaults; } /** * Option name for a group. * * @since 1.0.0 * * @param string $group Group name. * @return string */ public function option_name( string $group ): string { return self::OPTION_PREFIX . $group; } /** * Registers each group with the Settings API. * * The groups are intentionally not exposed through the core /wp/v2/settings * endpoint; the plugin's own endpoint uses the same schema. * * @since 1.0.0 * * @return void */ public function register(): void { foreach ( $this->groups() as $group ) { register_setting( 'elevenrank_settings', $this->option_name( $group ), array( 'type' => 'object', 'default' => $this->defaults( $group ), 'show_in_rest' => false, 'sanitize_callback' => function ( $value ) use ( $group ) { return $this->sanitize( $group, $value ); }, ) ); } } /** * Creates the options with their defaults. Existing options are left alone. * * @since 1.0.0 * * @return void */ public function ensure_defaults(): void { foreach ( $this->groups() as $group ) { add_option( $this->option_name( $group ), $this->defaults( $group ), '', in_array( $group, self::AUTOLOAD_GROUPS, true ) ); } } /** * Values of one group: stored values over defaults, always sanitized. * * @since 1.0.0 * * @param string $group Group name. * @return array */ public function get( string $group ): array { return $this->sanitize( $group, array_merge( $this->defaults( $group ), $this->stored( $group ) ) ); } /** * A single value. * * @since 1.0.0 * * @param string $group Group name. * @param string $key Setting key. * @return mixed|null Null when the key does not exist. */ public function value( string $group, string $key ) { $values = $this->get( $group ); return array_key_exists( $key, $values ) ? $values[ $key ] : null; } /** * Merges values into a group and saves it. * * Top-level keys are replaced as a whole, so a nested map (such as the per-post-type * templates) must be sent complete. * * @since 1.0.0 * * @param string $group Group name. * @param array $values Values to change. * @return array The group after saving. */ public function update( string $group, array $values ): array { if ( ! in_array( $group, $this->groups(), true ) ) { return array(); } update_option( $this->option_name( $group ), $this->sanitize( $group, array_merge( $this->stored( $group ), $values ) ) ); return $this->get( $group ); } /** * Reduces any input to the keys and types the group's schema allows. * * @since 1.0.0 * * @param string $group Group name. * @param mixed $input Raw value. * @return array */ public function sanitize( string $group, $input ): array { return $this->sanitize_object( $this->schema( $group ), $input ); } /** * Raw stored values of a group. * * @since 1.0.0 * * @param string $group Group name. * @return array */ private function stored( string $group ): array { $stored = get_option( $this->option_name( $group ), array() ); return is_array( $stored ) ? $stored : array(); } /** * Sanitizes an object against its schema, recursively. * * Keys named in `properties` are kept; other keys are kept only when * `additionalProperties` is a schema and the key is a plain slug. * * @since 1.0.0 * * @param array $schema Object schema. * @param mixed $input Raw value. * @return array */ private function sanitize_object( array $schema, $input ): array { $properties = isset( $schema['properties'] ) && is_array( $schema['properties'] ) ? $schema['properties'] : array(); $input = is_object( $input ) ? get_object_vars( $input ) : $input; $input = is_array( $input ) ? $input : array(); $clean = array(); foreach ( $properties as $key => $property ) { if ( array_key_exists( $key, $input ) && is_array( $property ) ) { $clean[ $key ] = $this->sanitize_value( $property, $input[ $key ] ); } } if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) { foreach ( $input as $key => $value ) { if ( is_string( $key ) && ! isset( $properties[ $key ] ) && strlen( $key ) <= 64 && 1 === preg_match( '/^[a-z0-9_-]+$/', $key ) ) { $clean[ $key ] = $this->sanitize_value( $schema['additionalProperties'], $value ); } } } return $clean; } /** * Coerces one value to its schema type. * * @since 1.0.0 * * @param array $schema Property schema. * @param mixed $value Raw value. * @return mixed Sanitized value, or the property default when the value is not valid. */ private function sanitize_value( array $schema, $value ) { $type = isset( $schema['type'] ) ? $schema['type'] : 'string'; $default = array_key_exists( 'default', $schema ) ? $schema['default'] : null; switch ( $type ) { case 'boolean': return rest_sanitize_boolean( $value ); case 'integer': if ( ! is_numeric( $value ) ) { return $default; } $number = (int) $value; if ( isset( $schema['minimum'] ) ) { $number = max( (int) $schema['minimum'], $number ); } if ( isset( $schema['maximum'] ) ) { $number = min( (int) $schema['maximum'], $number ); } return $number; case 'object': return is_array( $value ) || is_object( $value ) ? $this->sanitize_object( $schema, $value ) : $default; case 'array': return $this->sanitize_array( $schema, $value, $default ); case 'string': return $this->sanitize_string( $schema, $value, $default ); } return $default; } /** * Sanitizes a list of integers or strings. * * The `items` schema decides the element type (`integer`, the default for ids, or * `string`); `maxItems` caps the length. Duplicates are removed and the list is re-indexed. * * @since 1.0.0 * * @param array $schema Property schema. * @param mixed $value Raw value. * @param mixed $fallback Value to use when the input is not a list. * @return mixed */ private function sanitize_array( array $schema, $value, $fallback ) { if ( ! is_array( $value ) ) { return $fallback; } $max = isset( $schema['maxItems'] ) ? max( 0, (int) $schema['maxItems'] ) : 1000; $item_type = isset( $schema['items']['type'] ) ? $schema['items']['type'] : 'integer'; if ( 'integer' === $item_type ) { return Sanitize::ids( $value, $max ); } $items = array(); foreach ( $value as $item ) { $clean = is_scalar( $item ) ? $this->sanitize_string( is_array( $schema['items'] ) ? $schema['items'] : array(), $item, '' ) : ''; if ( '' !== $clean ) { $items[ $clean ] = $clean; } if ( count( $items ) >= $max ) { break; } } return array_values( $items ); } /** * Sanitizes a string value according to its format, length and enum. * * @since 1.0.0 * * @param array $schema Property schema. * @param mixed $value Raw value. * @param mixed $fallback Value to use when the input is not valid. * @return mixed */ private function sanitize_string( array $schema, $value, $fallback ) { if ( ! is_scalar( $value ) ) { return $fallback; } $format = isset( $schema['format'] ) ? $schema['format'] : ''; if ( 'elevenrank-template' === $format ) { $string = Sanitize::template( $value ); } elseif ( 'elevenrank-url' === $format ) { $string = Sanitize::url( $value ); } elseif ( 'elevenrank-robots-txt' === $format ) { $string = Sanitize::robots_txt( $value, isset( $schema['maxLength'] ) ? (int) $schema['maxLength'] : 10000 ); } else { $string = sanitize_text_field( (string) $value ); } if ( isset( $schema['enum'] ) && is_array( $schema['enum'] ) && ! in_array( $string, $schema['enum'], true ) ) { return $fallback; } if ( isset( $schema['maxLength'] ) && mb_strlen( $string ) > (int) $schema['maxLength'] ) { $string = mb_substr( $string, 0, (int) $schema['maxLength'] ); } return $string; } }