`. 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'] );
return $clean;
}
/**
* 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;
}
}