array( 'type' => 'bot_map', 'default' => $bots, ), 'llms' => array( 'type' => 'group', 'default' => array( 'post_types' => array( 'page', 'post' ), 'mode' => 'auto', 'override' => '', 'max_items' => 500, 'robots_link' => true, ), ), 'headers' => array( 'type' => 'group', 'default' => array( 'noai' => false, 'signals' => true, ), ), 'schema' => array( 'type' => 'group', 'default' => array( 'mode' => Oorank_Schema::MODE_AUTO, 'entity_type' => 'Organization', 'entity_name' => '', 'logo' => 0, 'same_as' => array(), 'street' => '', 'locality' => '', 'region' => '', 'postal_code' => '', 'country' => '', 'telephone' => '', 'custom' => array(), ), ), 'freshness' => array( 'type' => 'group', 'default' => array( 'months' => Oorank_Freshness::DEFAULT_MONTHS, ), ), 'mcp' => array( 'type' => 'group', 'default' => array( 'enabled' => false, ), ), 'digest' => array( 'type' => 'group', 'default' => array( 'enabled' => false, 'email' => '', 'threshold' => 5, ), ), 'roles' => array( 'type' => 'group', 'default' => array( 'manage' => array( 'administrator' ), ), ), ); /** * Filter the settings schema. * * Extension seam: Pro appends its own groups (analytics, citations…). * * @param array $schema Settings schema keyed by group. * @param Oorank_Settings $settings This store. */ return apply_filters( 'oorank_settings_schema', $schema, $this ); } /** * Default settings derived from the schema. * * @return array */ public function defaults() { $defaults = array(); foreach ( $this->schema() as $group => $spec ) { $defaults[ $group ] = $spec['default']; } return $defaults; } /** * Get the full settings array, merged over defaults. * * @return array */ public function get() { $stored = get_option( self::OPTION, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } return $this->merge_defaults( $stored ); } /** * Persist settings after sanitizing against the schema. * * @param array $input Raw incoming settings. * @return array The sanitized settings that were stored. */ public function update( $input ) { $clean = $this->sanitize( $input ); update_option( self::OPTION, $clean ); return $clean; } /** * Seed defaults on activation if the option is absent. */ public function maybe_seed_defaults() { if ( false === get_option( self::OPTION, false ) ) { add_option( self::OPTION, $this->defaults() ); } } /** * Sanitize raw input against the schema. Unknown keys are dropped. * * @param array $input Raw settings. * @return array Clean settings. */ public function sanitize( $input ) { $input = is_array( $input ) ? $input : array(); $defaults = $this->defaults(); $clean = array(); // Bots: allow/block per known key only. $clean['bots'] = array(); $in_bots = isset( $input['bots'] ) && is_array( $input['bots'] ) ? $input['bots'] : array(); foreach ( $defaults['bots'] as $bot => $default_state ) { $state = isset( $in_bots[ $bot ] ) ? sanitize_key( $in_bots[ $bot ] ) : $default_state; $clean['bots'][ $bot ] = ( Oorank_Bot_Manager::BLOCK === $state ) ? Oorank_Bot_Manager::BLOCK : Oorank_Bot_Manager::ALLOW; } // llms group. $in_llms = isset( $input['llms'] ) && is_array( $input['llms'] ) ? $input['llms'] : array(); $post_types = isset( $in_llms['post_types'] ) && is_array( $in_llms['post_types'] ) ? $in_llms['post_types'] : $defaults['llms']['post_types']; $clean['llms'] = array( // Order is meaningful: the index's sections follow it, so the // posted order is kept rather than sorted. 'post_types' => array_values( array_unique( array_filter( array_map( 'sanitize_key', $post_types ) ) ) ), 'mode' => ( isset( $in_llms['mode'] ) && 'manual' === $in_llms['mode'] ) ? 'manual' : 'auto', 'override' => isset( $in_llms['override'] ) ? sanitize_textarea_field( $in_llms['override'] ) : '', 'max_items' => min( 2000, max( 10, isset( $in_llms['max_items'] ) ? absint( $in_llms['max_items'] ) : 500 ) ), 'robots_link' => ! isset( $in_llms['robots_link'] ) || ! empty( $in_llms['robots_link'] ), ); // headers group. $in_headers = isset( $input['headers'] ) && is_array( $input['headers'] ) ? $input['headers'] : array(); $clean['headers'] = array( 'noai' => ! empty( $in_headers['noai'] ), // Absent key means "on": the signal ships enabled, and a client // that posts a headers group without it must not silently disable // a published policy line. 'signals' => ! isset( $in_headers['signals'] ) || ! empty( $in_headers['signals'] ), ); // schema group. $in_schema = isset( $input['schema'] ) && is_array( $input['schema'] ) ? $input['schema'] : array(); $modes = array( Oorank_Schema::MODE_AUTO, Oorank_Schema::MODE_ALWAYS, Oorank_Schema::MODE_OFF ); $mode = isset( $in_schema['mode'] ) ? sanitize_key( $in_schema['mode'] ) : ''; $entity_type = isset( $in_schema['entity_type'] ) ? sanitize_text_field( $in_schema['entity_type'] ) : ''; $clean['schema'] = array( 'mode' => in_array( $mode, $modes, true ) ? $mode : $defaults['schema']['mode'], 'entity_type' => in_array( $entity_type, array( 'Person', 'LocalBusiness' ), true ) ? $entity_type : 'Organization', 'entity_name' => isset( $in_schema['entity_name'] ) ? sanitize_text_field( $in_schema['entity_name'] ) : '', 'logo' => isset( $in_schema['logo'] ) ? absint( $in_schema['logo'] ) : 0, 'same_as' => $this->sanitize_urls( isset( $in_schema['same_as'] ) ? $in_schema['same_as'] : array() ), 'street' => isset( $in_schema['street'] ) ? sanitize_text_field( $in_schema['street'] ) : '', 'locality' => isset( $in_schema['locality'] ) ? sanitize_text_field( $in_schema['locality'] ) : '', 'region' => isset( $in_schema['region'] ) ? sanitize_text_field( $in_schema['region'] ) : '', 'postal_code' => isset( $in_schema['postal_code'] ) ? sanitize_text_field( $in_schema['postal_code'] ) : '', 'country' => isset( $in_schema['country'] ) ? sanitize_text_field( $in_schema['country'] ) : '', 'telephone' => isset( $in_schema['telephone'] ) ? sanitize_text_field( $in_schema['telephone'] ) : '', 'custom' => $this->sanitize_custom_schema( isset( $in_schema['custom'] ) ? $in_schema['custom'] : array() ), ); // freshness group. $in_freshness = isset( $input['freshness'] ) && is_array( $input['freshness'] ) ? $input['freshness'] : array(); $clean['freshness'] = array( 'months' => Oorank_Freshness::clamp_months( isset( $in_freshness['months'] ) ? $in_freshness['months'] : 0 ), ); // mcp group. Off unless explicitly enabled: this one opens an endpoint. $in_mcp = isset( $input['mcp'] ) && is_array( $input['mcp'] ) ? $input['mcp'] : array(); $clean['mcp'] = array( 'enabled' => ! empty( $in_mcp['enabled'] ), ); // digest group. Threshold is clamped: a 0-point threshold would mail on // every rounding wobble, and anything over 50 never fires. $in_digest = isset( $input['digest'] ) && is_array( $input['digest'] ) ? $input['digest'] : array(); $threshold = isset( $in_digest['threshold'] ) ? absint( $in_digest['threshold'] ) : 5; $clean['digest'] = array( 'enabled' => ! empty( $in_digest['enabled'] ), 'email' => ( isset( $in_digest['email'] ) && is_email( $in_digest['email'] ) ) ? sanitize_email( $in_digest['email'] ) : '', 'threshold' => min( 50, max( 1, $threshold ) ), ); // roles group. Administrator is always in the list, whatever was // posted: a role list that can lock the owner out of the screen that // edits the role list is not a setting, it is a trap. $in_roles = isset( $input['roles'] ) && is_array( $input['roles'] ) ? $input['roles'] : array(); $manage = isset( $in_roles['manage'] ) && is_array( $in_roles['manage'] ) ? $in_roles['manage'] : array(); $manage = array_values( array_intersect( array_map( 'sanitize_key', $manage ), array_keys( Oorank_Roles::assignable() ) ) ); $clean['roles'] = array( 'manage' => array_values( array_unique( array_merge( array( 'administrator' ), $manage ) ) ), ); /** * Filter sanitized settings before save. * * Extension seam: Pro sanitizes and merges its own groups here. * * @param array $clean Sanitized free-core settings. * @param array $input Raw input. */ return apply_filters( 'oorank_sanitize_settings', $clean, $input ); } /** * Sanitize hand-written JSON-LD blocks and their display conditions. * * The JSON itself is stored as text, not re-encoded: a block is the * owner's markup, and normalising it would silently change what they * wrote. Invalid JSON is kept too — it is a draft the editor is still * working on, and the screen shows the parser's complaint — but a block * that does not parse is never emitted on the front end. * * @param mixed $input Raw blocks. * @return array */ private function sanitize_custom_schema( $input ) { if ( ! is_array( $input ) ) { return array(); } $clean = array(); foreach ( array_slice( $input, 0, 20 ) as $block ) { if ( ! is_array( $block ) || empty( $block['json'] ) ) { continue; } $clean[] = array( 'label' => isset( $block['label'] ) ? sanitize_text_field( $block['label'] ) : '', // wp_kses would mangle the JSON; the value is echoed only // inside a JSON-encoded script block, never as HTML. 'json' => wp_check_invalid_utf8( (string) $block['json'] ), // Named show/hide rather than include/exclude: the static // analysers wp.org runs read an `exclude` key as a WP_Query // exclusion parameter and warn about a query that is not here. 'show' => $this->sanitize_conditions( isset( $block['show'] ) ? $block['show'] : array( 'all' ) ), 'hide' => $this->sanitize_conditions( isset( $block['hide'] ) ? $block['hide'] : array() ), ); } return $clean; } /** * Sanitize a display-condition list: `all`, `front_page`, a post type, or * `post:123`. * * @param mixed $input Raw conditions. * @return string[] */ private function sanitize_conditions( $input ) { $clean = array(); foreach ( (array) $input as $value ) { $value = trim( (string) $value ); if ( 0 === strpos( $value, 'post:' ) ) { $id = absint( substr( $value, 5 ) ); if ( $id ) { $clean[] = 'post:' . $id; } continue; } $value = sanitize_key( $value ); if ( '' !== $value ) { $clean[] = $value; } } return array_values( array_unique( $clean ) ); } /** * Sanitize a list of profile URLs, dropping blanks and duplicates. * * @param mixed $input Raw list. * @return string[] */ private function sanitize_urls( $input ) { if ( ! is_array( $input ) ) { return array(); } $clean = array(); foreach ( $input as $url ) { $url = esc_url_raw( trim( (string) $url ) ); if ( '' !== $url ) { $clean[] = $url; } } return array_values( array_unique( $clean ) ); } /** * Recursively merge stored values over defaults (one level of groups). * * @param array $stored Stored settings. * @return array */ private function merge_defaults( $stored ) { $defaults = $this->defaults(); $merged = $defaults; foreach ( $defaults as $group => $default_value ) { if ( isset( $stored[ $group ] ) && is_array( $stored[ $group ] ) && is_array( $default_value ) ) { $merged[ $group ] = array_merge( $default_value, $stored[ $group ] ); } } // Preserve any extra groups a filter added. foreach ( $stored as $group => $value ) { if ( ! isset( $merged[ $group ] ) ) { $merged[ $group ] = $value; } } return $merged; } }