(WordPress owns it). * * @var string[] */ const META_FIELDS = array( 'description', 'og_title', 'og_description', 'og_url', 'og_type', 'twitter_card', 'twitter_title', 'twitter_description', 'canonical_enabled', 'snippet_directives', ); /** * Maximum recursion depth accepted when sanitizing schema data. */ const MAX_SCHEMA_DEPTH = 20; /** * Return the cached bundle, or null when none is stored. * * @return array|null */ public static function get_bundle() { $bundle = get_option( 'aeo_connect_bundle', null ); return is_array( $bundle ) ? $bundle : null; } /** * Return the locally applied bundle version (0 = nothing applied). * * @return int */ public static function get_version() { return (int) get_option( 'aeo_connect_version', 0 ); } /** * Return the per-module enabled flags. * * Defaults come from the bundle's `enabled_modules` until the site admin * saves an explicit choice (which then takes precedence). * * @return array { jsonld: bool, robots: bool, llms: bool } */ public static function get_modules() { $saved = get_option( 'aeo_connect_modules', false ); if ( is_array( $saved ) ) { $modules = array(); foreach ( self::MODULE_SLUGS as $slug ) { $modules[ $slug ] = ! empty( $saved[ $slug ] ); } return $modules; } // No explicit admin choice yet: default from the bundle's enabled_modules. // `meta` defaults OFF (opt-in) — it only injects when no SEO plugin is // present, but staying conservative avoids any surprise on existing sites. $defaults = array( 'jsonld' => true, 'robots' => true, 'llms' => true, 'meta' => false, ); $bundle = self::get_bundle(); if ( $bundle && isset( $bundle['enabled_modules'] ) && is_array( $bundle['enabled_modules'] ) ) { foreach ( self::MODULE_SLUGS as $slug ) { if ( array_key_exists( $slug, $bundle['enabled_modules'] ) ) { $defaults[ $slug ] = ! empty( $bundle['enabled_modules'][ $slug ] ); } } } return $defaults; } /** * Slugs of the modules that are currently active (global kill-switch * respected). Used for the telemetry ping. * * @return string[] */ public static function active_module_slugs() { if ( ! get_option( 'aeo_connect_enabled', true ) ) { return array(); } $slugs = array(); foreach ( self::get_modules() as $slug => $on ) { if ( $on ) { $slugs[] = $slug; } } return $slugs; } /** * Pull the bundle from everlange.com and apply it if its version changed. * * On failure the previously cached bundle keeps being served (fail-open * on content). A 403 means the site key was revoked: all modules are * disabled and an admin notice is raised. * * @return true|WP_Error */ public static function sync() { $result = AEO_Connect_API_Client::pull(); if ( is_wp_error( $result ) ) { if ( 'aeo_connect_revoked' === $result->get_error_code() ) { self::handle_revoked(); } return $result; } // Successful contact with the SaaS. update_option( 'aeo_connect_last_sync', time(), false ); delete_option( 'aeo_connect_revoked' ); $remote_version = (int) $result['version']; $local_version = self::get_version(); if ( $remote_version === $local_version && null !== self::get_bundle() ) { // 304-like: same version already applied, skip re-apply. AEO_Connect_API_Client::ping( $local_version, self::active_module_slugs() ); return true; } // Snapshot the currently applied bundle before replacing it. AEO_Connect_Rollback::snapshot(); self::apply_bundle( $result ); // Best-effort telemetry; failures are non-fatal. AEO_Connect_API_Client::ping( self::get_version(), self::active_module_slugs() ); return true; } /** * Sanitize and persist a bundle as the current one. * * @param array $bundle Raw decoded bundle from the API. * @return void */ public static function apply_bundle( $bundle ) { $clean = self::sanitize_bundle( $bundle ); update_option( 'aeo_connect_bundle', $clean, false ); update_option( 'aeo_connect_version', (int) $clean['version'], false ); } /** * Revoked (HTTP 403): disable everything and flag for the admin notice. * * @return void */ public static function handle_revoked() { update_option( 'aeo_connect_revoked', time(), false ); update_option( 'aeo_connect_enabled', '0', false ); update_option( 'aeo_connect_modules', array( 'jsonld' => false, 'robots' => false, 'llms' => false, ), false ); } /** * Whether the last successful sync is older than 48 hours. * * @return bool */ public static function is_sync_stale() { $last_sync = (int) get_option( 'aeo_connect_last_sync', 0 ); if ( 0 === $last_sync ) { return false; // Never synced yet (e.g. just paired). } return ( time() - $last_sync ) > 2 * DAY_IN_SECONDS; } /** * Sanitize a raw bundle. The bundle is data, never code: anything that * is not plain data (executable payloads, non-array schemas, unexpected * value types) is refused and dropped. * * @param array $bundle Raw decoded bundle. * @return array Clean bundle. */ public static function sanitize_bundle( $bundle ) { $bundle = is_array( $bundle ) ? $bundle : array(); $clean = array( 'version' => isset( $bundle['version'] ) ? (int) $bundle['version'] : 0, 'generated_at' => isset( $bundle['generated_at'] ) ? sanitize_text_field( (string) $bundle['generated_at'] ) : '', 'audit' => array(), 'enabled_modules' => array(), 'modules' => array(), ); if ( isset( $bundle['audit'] ) && is_array( $bundle['audit'] ) ) { $clean['audit'] = array( 'id' => isset( $bundle['audit']['id'] ) ? sanitize_text_field( (string) $bundle['audit']['id'] ) : '', 'score' => isset( $bundle['audit']['score'] ) ? (int) $bundle['audit']['score'] : 0, ); } if ( isset( $bundle['enabled_modules'] ) && is_array( $bundle['enabled_modules'] ) ) { foreach ( $bundle['enabled_modules'] as $key => $value ) { $key = sanitize_key( (string) $key ); if ( '' !== $key ) { $clean['enabled_modules'][ $key ] = (bool) $value; } } } $modules = ( isset( $bundle['modules'] ) && is_array( $bundle['modules'] ) ) ? $bundle['modules'] : array(); // JSON-LD items: schema MUST be structured data (array), never a string. if ( isset( $modules['jsonld'] ) && is_array( $modules['jsonld'] ) ) { $items = array(); foreach ( $modules['jsonld'] as $item ) { if ( ! is_array( $item ) || ! isset( $item['page'], $item['schema'] ) ) { continue; } if ( ! is_array( $item['schema'] ) ) { continue; // Refuse non-structured payloads. } $items[] = array( 'page' => sanitize_text_field( (string) $item['page'] ), 'schema' => self::sanitize_schema( $item['schema'], 0 ), 'id' => isset( $item['id'] ) ? sanitize_text_field( (string) $item['id'] ) : '', ); } $clean['modules']['jsonld'] = $items; } // robots.txt: plain text directives, append-only at output time. if ( isset( $modules['robots_txt'] ) && is_array( $modules['robots_txt'] ) && isset( $modules['robots_txt']['content'] ) && is_string( $modules['robots_txt']['content'] ) ) { $clean['modules']['robots_txt'] = array( 'content' => self::sanitize_plain_text( $modules['robots_txt']['content'] ), 'merge_strategy' => isset( $modules['robots_txt']['merge_strategy'] ) ? sanitize_text_field( (string) $modules['robots_txt']['merge_strategy'] ) : 'append_ai_bots', ); } // llms.txt: plain text served with a text/plain content type. if ( isset( $modules['llms_txt'] ) && is_array( $modules['llms_txt'] ) && isset( $modules['llms_txt']['content'] ) && is_string( $modules['llms_txt']['content'] ) ) { $clean['modules']['llms_txt'] = array( 'content' => self::sanitize_plain_text( $modules['llms_txt']['content'] ), ); } // Plain-text virtual files (1.3.0): ai.txt / security.txt / humans.txt — // same contract as llms_txt (text/plain body, sanitized on intake). foreach ( array( 'ai_txt', 'security_txt', 'humans_txt' ) as $file_module ) { if ( isset( $modules[ $file_module ] ) && is_array( $modules[ $file_module ] ) && isset( $modules[ $file_module ]['content'] ) && is_string( $modules[ $file_module ]['content'] ) ) { $clean['modules'][ $file_module ] = array( 'content' => self::sanitize_plain_text( $modules[ $file_module ]['content'] ), ); } } // IndexNow (1.3.0): the key file served at /{key}.txt — strict hex only. if ( isset( $modules['indexnow'] ) && is_array( $modules['indexnow'] ) && isset( $modules['indexnow']['key'] ) && is_string( $modules['indexnow']['key'] ) ) { $key = strtolower( trim( $modules['indexnow']['key'] ) ); if ( preg_match( '/^[a-f0-9]{16,64}$/', $key ) ) { $clean['modules']['indexnow'] = array( 'key' => $key ); } } // meta: whitelisted Open Graph / description fields, each a plain string. // Rendered as tags, attributes escaped at output time. if ( isset( $modules['meta'] ) && is_array( $modules['meta'] ) ) { $meta = array(); foreach ( self::META_FIELDS as $field ) { if ( isset( $modules['meta'][ $field ] ) && is_string( $modules['meta'][ $field ] ) ) { $value = sanitize_text_field( $modules['meta'][ $field ] ); if ( '' !== $value ) { $meta[ $field ] = $value; } } } if ( ! empty( $meta ) ) { $clean['modules']['meta'] = $meta; } } return $clean; } /** * Recursively sanitize a schema structure: keep arrays/strings/numbers/ * booleans/null, drop everything else. Depth-limited. * * String values are kept verbatim (minus null bytes): they are emitted * JSON-encoded inside a ld+json script tag, where they are inert data. * * @param array $schema Schema node. * @param int $depth Current recursion depth. * @return array */ private static function sanitize_schema( $schema, $depth ) { if ( $depth > self::MAX_SCHEMA_DEPTH ) { return array(); } $clean = array(); foreach ( $schema as $key => $value ) { $key = is_int( $key ) ? $key : str_replace( "\0", '', (string) $key ); if ( is_array( $value ) ) { $clean[ $key ] = self::sanitize_schema( $value, $depth + 1 ); } elseif ( is_string( $value ) ) { $clean[ $key ] = str_replace( "\0", '', $value ); } elseif ( is_int( $value ) || is_float( $value ) || is_bool( $value ) || null === $value ) { $clean[ $key ] = $value; } // Objects/resources/anything else: dropped (data only, never code). } return $clean; } /** * Normalize plain-text content: strip null bytes, normalize line endings. * * @param string $text Raw text. * @return string */ private static function sanitize_plain_text( $text ) { $text = str_replace( "\0", '', (string) $text ); $text = str_replace( array( "\r\n", "\r" ), "\n", $text ); return $text; } }