`. The option value is an associative array that * also carries a `_version` field (the module VERSION at the time of last * write) so migrations know what schema produced the stored data. * * The pre-Module v1 settings (the global cache_enabled / minify_* / * gzip_enabled / cache_expiry / excluded_urls) keep living in * `xspeed_options` under the existing Settings class — Settings_Manager * does not touch them. When v1 features are refactored into Modules, * they'll migrate from `xspeed_options` to their per-module options as * part of that PR. * * @package XSpeed */ namespace XSpeed; defined( 'ABSPATH' ) || exit; final class Settings_Manager { public const OPTION_PREFIX = 'xspeed_module_'; /** * Read settings for a module slug. Returns defaults merged with stored * values + the schema applied (unknown keys stripped). Always safe to * call before activation — returns pure defaults if nothing is stored. */ public static function get( string $slug ): array { $module = Module_Registry::get( $slug ); if ( ! $module ) { return array(); } $schema = $module->settings_schema(); $defaults = self::defaults_from_schema( $schema ); $stored = get_option( self::option_key( $slug ), array() ); if ( ! is_array( $stored ) ) { $stored = array(); } $merged = array_merge( $defaults, $stored ); // Strip keys not in schema; coerce types to what the schema declares. $clean = array(); foreach ( $schema as $key => $spec ) { $clean[ $key ] = array_key_exists( $key, $merged ) ? self::coerce( $merged[ $key ], $spec ) : ( $spec['default'] ?? null ); } // Carry through any out-of-schema keys the module explicitly preserves // (e.g. the REST-cache route `rules` array) so a schema-driven save // doesn't silently drop them. (FBS-82408) foreach ( $module->preserved_keys() as $key ) { if ( array_key_exists( $key, $stored ) ) { $clean[ $key ] = $stored[ $key ]; } } return $clean; } /** * Validate input against the module's schema, merge over stored values, * and persist. Returns the final clean array. Unknown keys are stripped * silently. Out-of-range / wrong-type values fall back to the previous * stored value (or default). */ public static function update( string $slug, array $input ): array { $module = Module_Registry::get( $slug ); if ( ! $module ) { return array(); } $schema = $module->settings_schema(); $current = self::get( $slug ); $clean = $current; foreach ( $schema as $key => $spec ) { if ( ! array_key_exists( $key, $input ) ) { continue; } [ $value, $valid ] = self::validate_field( $input[ $key ], $spec ); if ( $valid ) { $clean[ $key ] = $value; } // Invalid → keep $current[$key]. We do not throw; REST layer can // add its own strict-mode validation that 400s on invalid input. } // Carry through out-of-schema keys the module explicitly preserves when // they arrive in the INPUT — not only when already stored. Otherwise a // caller that routes through update() to SET a preserved key (e.g. a // migration/profile writing `mobile_separate_review`) has it silently // stripped, because it isn't in $current yet. (FBS-83144) foreach ( $module->preserved_keys() as $key ) { if ( array_key_exists( $key, $input ) ) { $clean[ $key ] = $input[ $key ]; } } $clean['_version'] = $module->version(); update_option( self::option_key( $slug ), $clean ); // Strip the internal _version key from the returned array. unset( $clean['_version'] ); // Change annotation (issue #45): every real mutation — from the UI, // REST, CLI, or an MCP agent — lands in the activity log with the // old→new diff and its source channel, so the dashboard can tell the // causal story ("expiry raised → hit ratio climbed"). self::log_changes( $slug, $current, $clean, array_keys( $schema ) ); return $clean; } /** * Record changed schema fields as one activity event. No-op when * nothing actually changed (idempotent re-saves stay silent). * * @param string $slug Module slug. * @param array $before Settings before the write. * @param array $after Settings after the write. * @param string[] $schema_keys Keys eligible to diff. */ private static function log_changes( string $slug, array $before, array $after, array $schema_keys ): void { if ( ! class_exists( '\\XSpeed\\Activity_Log' ) ) { return; } $diffs = array(); foreach ( $schema_keys as $key ) { $old = $before[ $key ] ?? null; $new = $after[ $key ] ?? null; if ( $old === $new ) { continue; } if ( self::is_secret_key( $key ) ) { // Never record the value itself — the annotation is served to // the dashboard by the trend endpoints, so a token written // here would be readable from the REST payload. $diffs[] = sprintf( '%s changed', $key ); continue; } $diffs[] = sprintf( '%s %s→%s', $key, self::describe_value( $old ), self::describe_value( $new ) ); } if ( empty( $diffs ) ) { return; } Activity_Log::record( 'settings_changed', sprintf( '%s: %s (via %s)', $slug, implode( ', ', array_slice( $diffs, 0, 5 ) ), self::source_channel() ) ); } /** * Setting keys whose VALUE must never reach the activity log. The log is * surfaced by the dashboard trend endpoints, so anything recorded here is * readable by any user who can load the dashboard. * * Matched on the key name rather than the value, because a credential is * indistinguishable from an ordinary string once it's been stringified. * Pure — unit-tested. * * @param string $key Schema key, e.g. 'api_token'. */ public static function is_secret_key( string $key ): bool { return 1 === preg_match( '/(token|password|secret|api_key|passwd|private_key|credential)/i', $key ); } /** Compact human form of a setting value for the change log. */ private static function describe_value( $value ): string { if ( is_bool( $value ) ) { return $value ? 'on' : 'off'; } if ( is_array( $value ) ) { return count( $value ) . ' item' . ( 1 === count( $value ) ? '' : 's' ); } if ( null === $value ) { return '—'; } $str = (string) $value; return strlen( $str ) > 40 ? substr( $str, 0, 39 ) . '…' : $str; } /** * Which surface performed this write. MCP is detected via the tool * dispatcher's in-flight flag; the dashboard UI writes through REST. */ private static function source_channel(): string { if ( class_exists( '\\XSpeed\\Modules\\Mcp\\Mcp_Tools' ) && \XSpeed\Modules\Mcp\Mcp_Tools::in_dispatch() ) { return 'mcp'; } if ( defined( 'WP_CLI' ) && WP_CLI ) { return 'cli'; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return 'dashboard'; } return 'admin'; } /** * Run any pending schema migrations for a module. Called by * Module_Registry before boot(). Idempotent — migrations only run once * per version bump because we persist `_version` after each successful * migration step. */ public static function run_migrations( Module $module ): void { $migrations = $module->migrations(); if ( empty( $migrations ) ) { return; } $option_key = self::option_key( $module->slug() ); $stored = get_option( $option_key, null ); if ( null === $stored ) { return; // fresh install — no data to migrate. } if ( ! is_array( $stored ) ) { $stored = array(); } $from = isset( $stored['_version'] ) ? (string) $stored['_version'] : '0.0.0'; // Sort migrations by version ascending. uksort( $migrations, static function ( $a, $b ) { return version_compare( (string) $a, (string) $b ); } ); $dirty = false; foreach ( $migrations as $target => $callable ) { $target = (string) $target; if ( version_compare( $from, $target, '>=' ) ) { continue; } $migrated = call_user_func( $callable, $stored ); if ( is_array( $migrated ) ) { $stored = $migrated; $stored['_version'] = $target; $from = $target; $dirty = true; } } if ( $dirty ) { update_option( $option_key, $stored ); } } /** * Coerce a stored value to the schema's declared type — used on read * to defend against options edited by hand or imported across versions. */ private static function coerce( $value, array $spec ) { $type = $spec['type'] ?? 'string'; switch ( $type ) { case 'bool': return (bool) $value; case 'int': $v = (int) $value; if ( isset( $spec['min'] ) ) { $v = max( (int) $spec['min'], $v ); } if ( isset( $spec['max'] ) ) { $v = min( (int) $spec['max'], $v ); } return $v; case 'enum': return in_array( $value, $spec['options'] ?? array(), true ) ? $value : ( $spec['default'] ?? null ); case 'list': if ( ! is_array( $value ) ) { return $spec['default'] ?? array(); } return array_values( array_filter( $value, 'is_scalar' ) ); case 'url': $url = esc_url_raw( (string) $value ); return $url ?: ( $spec['default'] ?? '' ); case 'media': // Media-library image URL. Empty is a valid "no image" state. // esc_url_raw alone lets through any safe URL (…/evil.txt, // non-images) which then renders as a broken ; require it // to look like an image and drop anything else to empty. $media = esc_url_raw( (string) $value ); return ( '' === $media || self::is_image_url( $media ) ) ? $media : ''; case 'string': default: return sanitize_text_field( (string) $value ); } } /** * Validate one field; returns [ coerced_value, was_valid ]. Distinct * from coerce() because validate is strict (out-of-range int is * INVALID) while coerce is forgiving (clamps to range). */ private static function validate_field( $value, array $spec ): array { $type = $spec['type'] ?? 'string'; switch ( $type ) { case 'bool': // Strictly validate (don't blindly (bool)-cast). A plain cast // treated every non-empty string as true, so a client sending // the string "false" (or any junk text) silently ENABLED the // toggle. filter_var with FILTER_NULL_ON_FAILURE accepts the // real bool-ish forms (true/false, 1/0, "1"/"0", "true"/ // "false", "yes"/"no", "on"/"off") and returns null for // anything else — which we report as invalid so the previous // stored value is kept, mirroring int/enum. (FBS-82158) $b = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); if ( null === $b ) { return array( null, false ); } return array( $b, true ); case 'int': if ( ! is_numeric( $value ) ) { return array( null, false ); } $v = (int) $value; if ( isset( $spec['min'] ) && $v < (int) $spec['min'] ) { return array( null, false ); } if ( isset( $spec['max'] ) && $v > (int) $spec['max'] ) { return array( null, false ); } return array( $v, true ); case 'enum': $ok = in_array( $value, $spec['options'] ?? array(), true ); return array( $ok ? $value : null, $ok ); case 'list': if ( ! is_array( $value ) ) { return array( null, false ); } $item_type = $spec['item_type'] ?? 'string'; $out = array(); foreach ( $value as $item ) { // Skip non-scalar items (e.g. a nested array). Casting one // with (string) emits an "Array to string conversion" // warning and stores the garbage literal "Array" — coerce() // already filters these via is_scalar; mirror it here. // (FBS-82172 Bug 4) if ( ! is_scalar( $item ) ) { continue; } if ( 'url' === $item_type ) { $u = esc_url_raw( (string) $item ); if ( $u ) { $out[] = $u; } } else { $out[] = sanitize_text_field( (string) $item ); } } return array( $out, true ); case 'url': $u = esc_url_raw( (string) $value ); return array( $u, (bool) $u ); case 'media': // Empty (cleared logo) is valid; any non-empty value must be a // safe URL after esc_url_raw AND look like an image, so a // non-image URL (…/evil.txt) is rejected rather than stored to // render as a broken . $m = esc_url_raw( (string) $value ); if ( '' === (string) $value ) { return array( '', true ); } $ok = '' !== $m && self::is_image_url( $m ); return array( $ok ? $m : '', $ok ); case 'string': default: return array( sanitize_text_field( (string) $value ), true ); } } /** * Whether a URL looks like an image — used to gate `media` fields so a * non-image URL can't be stored and later rendered as a broken * (e.g. the white-label brand logo, FBS-82222). Tests the path extension * against the known image types (query/fragment tolerated). Not a content * check — a cheap, deterministic guard that pairs with the front-end * onError fallback; the Media Library picker already yields conforming * http(s) upload URLs. (data: URIs are stripped by esc_url_raw upstream, * since `data` isn't an allowed protocol, so they never reach here.) */ private static function is_image_url( string $url ): bool { $url = trim( $url ); if ( '' === $url ) { return false; } // Drop the query string + fragment so ?ver=… / #frag don't defeat the // extension test (e.g. logo.webp?v=2). Plain string ops — no WP URL // parser dependency on this low-level coercion path. $path = (string) preg_replace( '/[?#].*$/', '', $url ); return (bool) preg_match( '/\.(jpe?g|png|gif|svg|webp|avif|ico|bmp)$/i', $path ); } private static function defaults_from_schema( array $schema ): array { $out = array(); foreach ( $schema as $key => $spec ) { $out[ $key ] = $spec['default'] ?? null; } return $out; } private static function option_key( string $slug ): string { return self::OPTION_PREFIX . $slug; } }