PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.6
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-settings-manager.php

class-settings-manager.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.6, at includes/class-settings-manager.php

781 lines 29.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings_Manager — per-module typed settings storage, validation, and
4 * versioned migrations.
5 *
6 * Storage layout: one wp_option per module under the key
7 * `xspeed_module_<slug>`. The option value is an associative array that
8 * also carries a `_version` field (the module VERSION at the time of last
9 * write) so migrations know what schema produced the stored data.
10 *
11 * The pre-Module v1 settings (the global cache_enabled / minify_* /
12 * gzip_enabled / cache_expiry / excluded_urls) keep living in
13 * `xspeed_options` under the existing Settings class — Settings_Manager
14 * does not touch them. When v1 features are refactored into Modules,
15 * they'll migrate from `xspeed_options` to their per-module options as
16 * part of that PR.
17 *
18 * @package XSpeed
19 */
20
21 namespace XSpeed;
22
23 defined( 'ABSPATH' ) || exit;
24
25 final class Settings_Manager {
26
27 public const OPTION_PREFIX = 'xspeed_module_';
28
29 /**
30 * Marker prefixing a secret value that has been encrypted at rest. A stored
31 * value without this prefix is legacy plaintext (or empty) and is read back
32 * verbatim — so the encryption rollout is lazy and non-destructive.
33 */
34 private const SECRET_CIPHER_PREFIX = 'xsenc:v1:';
35
36 /**
37 * Bullet run embedded in a masked secret hint. Also the write-preserve
38 * sentinel: an incoming value containing it (or an empty string) is treated
39 * as "the client is echoing the mask, keep the stored secret" — so saving an
40 * unrelated field on the same panel never wipes the credential. (#115)
41 */
42 public const SECRET_MASK_BULLETS = '••••';
43
44 /**
45 * Read settings for a module slug. Returns defaults merged with stored
46 * values + the schema applied (unknown keys stripped). Always safe to
47 * call before activation — returns pure defaults if nothing is stored.
48 */
49 public static function get( string $slug ): array {
50 $module = Module_Registry::get( $slug );
51 if ( ! $module ) {
52 return array();
53 }
54 $schema = $module->settings_schema();
55 $defaults = self::defaults_from_schema( $schema );
56 $stored = get_option( self::option_key( $slug ), array() );
57 if ( ! is_array( $stored ) ) {
58 $stored = array();
59 }
60 $merged = array_merge( $defaults, $stored );
61
62 // Strip keys not in schema; coerce types to what the schema declares.
63 $clean = array();
64 foreach ( $schema as $key => $spec ) {
65 $clean[ $key ] = array_key_exists( $key, $merged )
66 ? self::coerce( $merged[ $key ], $spec )
67 : ( $spec['default'] ?? null );
68 }
69
70 // Carry through any out-of-schema keys the module explicitly preserves
71 // (e.g. the REST-cache route `rules` array) so a schema-driven save
72 // doesn't silently drop them. (FBS-82408)
73 foreach ( $module->preserved_keys() as $key ) {
74 if ( array_key_exists( $key, $stored ) ) {
75 $clean[ $key ] = $stored[ $key ];
76 }
77 }
78
79 return $clean;
80 }
81
82 /**
83 * Validate input against the module's schema, merge over stored values,
84 * and persist. Returns the final clean array. Unknown keys are stripped
85 * silently. Out-of-range / wrong-type values fall back to the previous
86 * stored value (or default).
87 */
88 public static function update( string $slug, array $input ): array {
89 $module = Module_Registry::get( $slug );
90 if ( ! $module ) {
91 return array();
92 }
93 $schema = $module->settings_schema();
94 $current = self::get( $slug );
95
96 // An MCP agent must not silently rewrite credentials — repointing the
97 // Cloudflare or object-cache backend at an attacker endpoint — unless the
98 // connection was explicitly granted the `configure` scope. Strip secret
99 // fields from an unprivileged MCP write here so every write path (the
100 // update_settings tool AND run_command → CLI) is covered at one choke
101 // point. The tool handler surfaces the refusal as a clear error. (#116)
102 if ( self::mcp_write_blocked() ) {
103 foreach ( $schema as $key => $spec ) {
104 if ( self::is_secret_field( $key, $spec ) ) {
105 unset( $input[ $key ] );
106 }
107 }
108 }
109
110 $clean = $current;
111 foreach ( $schema as $key => $spec ) {
112 if ( ! array_key_exists( $key, $input ) ) {
113 continue;
114 }
115 // A secret field whose incoming value is the masked placeholder means
116 // the client is echoing back what get_public() sent, not setting a new
117 // credential — keep the stored value so an unrelated save on the same
118 // panel never wipes the key. An empty value is NOT a mask echo: it's a
119 // deliberate clear and flows through to remove the credential. (#115)
120 if ( self::is_secret_field( $key, $spec ) && self::is_masked_secret( (string) $input[ $key ] ) ) {
121 continue;
122 }
123 [ $value, $valid ] = self::validate_field( $input[ $key ], $spec );
124 if ( $valid ) {
125 $clean[ $key ] = $value;
126 }
127 // Invalid → keep $current[$key]. We do not throw; REST layer can
128 // add its own strict-mode validation that 400s on invalid input.
129 }
130
131 // Carry through out-of-schema keys the module explicitly preserves when
132 // they arrive in the INPUT — not only when already stored. Otherwise a
133 // caller that routes through update() to SET a preserved key (e.g. a
134 // migration/profile writing `mobile_separate_review`) has it silently
135 // stripped, because it isn't in $current yet. (FBS-83144)
136 foreach ( $module->preserved_keys() as $key ) {
137 if ( array_key_exists( $key, $input ) ) {
138 $clean[ $key ] = $input[ $key ];
139 }
140 }
141
142 // Change annotation (issue #45): every real mutation — from the UI,
143 // REST, CLI, or an MCP agent — lands in the activity log with the
144 // old→new diff and its source channel, so the dashboard can tell the
145 // causal story ("expiry raised → hit ratio climbed").
146 //
147 // $clean holds plaintext secrets (carried from $current, which get()
148 // decrypts, or freshly validated). Log + diff BEFORE encrypting, so the
149 // change annotation compares like-for-like (log_changes redacts secret
150 // values by key anyway). The encrypted copy is persisted below. (#115)
151 //
152 // Pass the FULL schema, not just its keys — log_changes() needs each
153 // field's `label` to write "Disable Dashicons on Frontend" instead of
154 // `disable_dashicons_frontend`. The schema was already in scope here
155 // and was simply being discarded. (#88)
156 self::log_changes( $slug, $current, $clean, $schema );
157
158 // Encrypt at rest ONLY fields explicitly typed `secret`. This must match
159 // coerce(), which decrypts only for `type === 'secret'` — encrypting a
160 // merely name-matched `string` field (a credential a module author typed
161 // as string) would store ciphertext that the string coercer then hands
162 // back verbatim, breaking the engine. Such fields are still masked and
163 // write-preserved via the broader is_secret_field() (masking a plaintext
164 // is always safe); they just aren't encrypted until retyped to `secret`.
165 $stored = $clean;
166 foreach ( $schema as $key => $spec ) {
167 if ( 'secret' === ( $spec['type'] ?? '' ) ) {
168 $stored[ $key ] = self::encrypt_for_storage( (string) ( $stored[ $key ] ?? '' ) );
169 }
170 }
171 $stored['_version'] = $module->version();
172 update_option( self::option_key( $slug ), $stored );
173
174 // Return the PUBLIC view: real non-secret values, masked secrets. This
175 // is the REST/CLI/MCP response, so it must never carry credentials. (#115)
176 return self::get_public( $slug );
177 }
178
179 /**
180 * The public, safe-to-serialize view of a module's settings: identical to
181 * get() except every secret field is replaced by a masked hint (first/last
182 * few chars, never the middle). This is what the REST GET handler, the MCP
183 * read tools, and the dashboard bootstrap payload return — get() itself
184 * stays plaintext for the engine.
185 *
186 * @return array<string,mixed>
187 */
188 public static function get_public( string $slug ): array {
189 $module = Module_Registry::get( $slug );
190 if ( ! $module ) {
191 return array();
192 }
193 $settings = self::get( $slug );
194 foreach ( $module->settings_schema() as $key => $spec ) {
195 if ( self::is_secret_field( $key, $spec ) && array_key_exists( $key, $settings ) ) {
196 $settings[ $key ] = self::mask_secret_value( (string) $settings[ $key ] );
197 }
198 }
199 return $settings;
200 }
201
202 /**
203 * Record changed schema fields as one activity event. No-op when
204 * nothing actually changed (idempotent re-saves stay silent).
205 *
206 * @param string $slug Module slug.
207 * @param array $before Settings before the write.
208 * @param array $after Settings after the write.
209 * @param array $schema Full settings schema — used for each field's label.
210 */
211 private static function log_changes( string $slug, array $before, array $after, array $schema ): void {
212 if ( ! class_exists( '\\XSpeed\\Activity_Log' ) ) {
213 return;
214 }
215 $diffs = array();
216 foreach ( array_keys( $schema ) as $key ) {
217 $old = $before[ $key ] ?? null;
218 $new = $after[ $key ] ?? null;
219 if ( $old === $new ) {
220 continue;
221 }
222
223 // The schema already declares a human label for every field — the
224 // same one rendered a few inches away on the settings screen. The
225 // feed used the raw storage key instead, so users read
226 // `disable_dashicons_frontend` rather than "Disable Dashicons on
227 // Frontend". Fall back to the key when a schema has no label, so
228 // an entry is never blank. (#88)
229 $label = isset( $schema[ $key ]['label'] ) && is_string( $schema[ $key ]['label'] ) && '' !== $schema[ $key ]['label']
230 ? $schema[ $key ]['label']
231 : $key;
232
233 if ( self::is_redacted_key( $key ) ) {
234 // Never record the value itself — the annotation is served to
235 // the dashboard by the trend endpoints, so anything written
236 // here is readable by any user who can load the dashboard.
237 $diffs[] = sprintf( '%s changed', $label );
238 continue;
239 }
240 $diffs[] = sprintf( '%s %s→%s', $label, self::describe_value( $old ), self::describe_value( $new ) );
241 }
242 if ( empty( $diffs ) ) {
243 return;
244 }
245 Activity_Log::record(
246 'settings_changed',
247 sprintf( '%s: %s (via %s)', self::module_label( $slug ), implode( ', ', array_slice( $diffs, 0, 5 ) ), self::source_channel() )
248 );
249 }
250
251 /**
252 * A module's display name for the activity feed, e.g. `gzip` →
253 * "Compression".
254 *
255 * Resolved through the module registry rather than a lookup table here,
256 * so Pro modules (feed-cache, search-cache, …) get their labels from the
257 * same path — Pro persists through this class and contributes no logging
258 * code of its own.
259 *
260 * Falls back to the raw slug when the module isn't registered or declares
261 * no label; an entry is never blank.
262 */
263 private static function module_label( string $slug ): string {
264 if ( ! class_exists( '\\XSpeed\\Module_Registry' ) ) {
265 return $slug;
266 }
267 $module = Module_Registry::get( $slug );
268 if ( ! $module ) {
269 return $slug;
270 }
271 $meta = $module->ui_metadata();
272 return ( isset( $meta['label'] ) && is_string( $meta['label'] ) && '' !== $meta['label'] )
273 ? $meta['label']
274 : $slug;
275 }
276
277 /**
278 * Setting keys whose VALUE must never reach the activity log. The log is
279 * surfaced by the dashboard trend endpoints, so anything recorded here is
280 * readable by any user who can load the dashboard.
281 *
282 * Matched on the key name rather than the value, because a credential is
283 * indistinguishable from an ordinary string once it's been stringified.
284 * Pure — unit-tested.
285 *
286 * @param string $key Schema key, e.g. 'api_token'.
287 */
288 public static function is_secret_key( string $key ): bool {
289 // `license_key` is matched explicitly: the pattern requires `api_key`
290 // rather than a bare `key` so that `key_prefix` (an ordinary,
291 // useful-to-see setting) isn't swallowed, which left a real license
292 // key printing in plaintext.
293 return 1 === preg_match( '/(token|password|secret|api_key|license_key|passwd|private_key|credential)/i', $key );
294 }
295
296 /**
297 * Setting keys whose value is withheld from the activity feed.
298 *
299 * Secrets (above) plus infrastructure IDENTIFIERS. `redis_password` was
300 * correctly redacted while `redis_user`, `redis_host` and `key_prefix`
301 * were written out in full — and the feed is served to any user who can
302 * load the dashboard, not just admins (see the trend endpoints).
303 *
304 * A Redis hostname and username are most of a credential, and they
305 * describe internal infrastructure that has no business being readable by
306 * a subscriber. The feed's job — "this setting changed, when, and by
307 * whom" — is served without printing the value. (#88)
308 *
309 * Deliberately matched on the key NAME: once stringified, a hostname is
310 * indistinguishable from any other short string. Pure — unit-tested.
311 *
312 * @param string $key Schema key, e.g. 'redis_host'.
313 */
314 public static function is_redacted_key( string $key ): bool {
315 if ( self::is_secret_key( $key ) ) {
316 return true;
317 }
318
319 // Deliberately an explicit list rather than a broad word match. A
320 // pattern like /(host|user|prefix|port)/ also swallows
321 // `bypass_user_agents`, `preconnect_hosts` and `excluded_urls` —
322 // ordinary user-facing settings whose values are exactly what makes
323 // the feed useful. Over-redacting is a quieter failure than leaking,
324 // but it is still a failure.
325 //
326 // Scoped to connection details and account identifiers. A new backend
327 // or provider setting must be added here consciously — see the
328 // schema-coverage test that walks every registered module and fails
329 // on an unreviewed key.
330 $identifiers = array(
331 // Object-cache backends.
332 'redis_host',
333 'redis_port',
334 'redis_user',
335 'redis_socket',
336 'redis_database',
337 'memcached_host',
338 'memcached_port',
339 'memcached_user',
340 'key_prefix',
341 // Cloudflare. The same reasoning that withholds redis_user /
342 // redis_host applies at least as strongly here: an account email
343 // plus a full Zone ID together identify the account and the exact
344 // zone. api_token / api_key are already covered by
345 // is_secret_key(); these two were the gap.
346 'email',
347 'zone_id',
348 );
349
350 /**
351 * Setting keys whose value is withheld from the activity feed.
352 *
353 * @param string[] $identifiers Keys to redact, on top of is_secret_key().
354 */
355 $identifiers = (array) apply_filters( 'xspeed_activity_redacted_keys', $identifiers );
356
357 return in_array( strtolower( $key ), array_map( 'strtolower', $identifiers ), true );
358 }
359
360 /**
361 * Whether a schema field holds credential material. A field is secret when
362 * it declares `type => 'secret'` (the explicit, preferred marker) OR its key
363 * name matches the credential pattern (is_secret_key) — the backstop that
364 * catches a credential a module author forgot to type, so a leak can't open
365 * just because a field was declared `string`.
366 *
367 * @param string $key Schema field key.
368 * @param array $spec Field spec from settings_schema().
369 */
370 public static function is_secret_field( string $key, array $spec ): bool {
371 return ( ( $spec['type'] ?? '' ) === 'secret' ) || self::is_secret_key( $key );
372 }
373
374 /**
375 * The subset of $input keys that are secret fields for this module's schema.
376 * Used by the MCP update_settings tool to name exactly which fields it
377 * refused. Returns [] for an unknown module.
378 *
379 * @param string $slug Module slug.
380 * @param array<string,mixed> $input Proposed settings patch.
381 * @return string[]
382 */
383 public static function secret_keys_in( string $slug, array $input ): array {
384 $module = Module_Registry::get( $slug );
385 if ( ! $module ) {
386 return array();
387 }
388 $schema = $module->settings_schema();
389 $out = array();
390 foreach ( $input as $key => $value ) {
391 if ( isset( $schema[ $key ] ) && self::is_secret_field( $key, $schema[ $key ] ) ) {
392 $out[] = $key;
393 }
394 }
395 return $out;
396 }
397
398 /**
399 * Masked hint for a stored secret: first 4 + bullets + last 4 (mirrors the
400 * support-snapshot license masking), or all-bullets for a short secret, or
401 * '' when unset. Enough to confirm "a key is saved, ending 4f2a" without
402 * disclosing it. Deterministic — unit-tested.
403 */
404 public static function mask_secret_value( string $value ): string {
405 if ( '' === $value ) {
406 return '';
407 }
408 if ( strlen( $value ) <= 8 ) {
409 return str_repeat( '', 8 );
410 }
411 return substr( $value, 0, 4 ) . self::SECRET_MASK_BULLETS . substr( $value, -4 );
412 }
413
414 /**
415 * Whether an incoming write value is the masked placeholder the client is
416 * echoing back, rather than a real new secret — i.e. it still carries the
417 * mask bullets. A genuine credential never contains the bullet run, so this
418 * can't swallow a real key. update() uses it to keep the stored secret.
419 *
420 * An EMPTY string is NOT a mask echo — it's a deliberate clear, so it flows
421 * through to storage and removes the credential. The dashboard always
422 * re-sends the masked hint (with bullets) on an unrelated save, never an
423 * empty string, so this still can't wipe a key by accident. (#115, QA B7)
424 */
425 public static function is_masked_secret( string $value ): bool {
426 return false !== strpos( $value, self::SECRET_MASK_BULLETS );
427 }
428
429 /**
430 * Encrypt a plaintext secret for storage. Idempotent: an already-encrypted
431 * value (carrying the marker) is returned unchanged, so module migrations
432 * can call this over existing rows without double-wrapping. Empty stays
433 * empty. Used by update() and by the per-module encrypt-on-upgrade
434 * migrations. (#115)
435 */
436 public static function encrypt_for_storage( string $value ): string {
437 if ( '' === $value || 0 === strpos( $value, self::SECRET_CIPHER_PREFIX ) ) {
438 return $value;
439 }
440 return self::encrypt( $value );
441 }
442
443 /**
444 * 32-byte encryption key derived from this site's WordPress salts, so the
445 * ciphertext is bound to the install and never stored alongside the data.
446 * Rotating the salts makes existing secrets undecryptable — decrypt() then
447 * returns '' (treated as "unset", the user re-enters the key) rather than
448 * fataling. Uses AUTH_KEY + SECURE_AUTH_SALT, falling back to wp_salt().
449 */
450 private static function secret_key(): string {
451 $material = '';
452 if ( defined( 'AUTH_KEY' ) ) {
453 $material .= (string) AUTH_KEY;
454 }
455 if ( defined( 'SECURE_AUTH_SALT' ) ) {
456 $material .= (string) SECURE_AUTH_SALT;
457 }
458 if ( '' === $material && function_exists( 'wp_salt' ) ) {
459 $material = (string) wp_salt( 'secure_auth' );
460 }
461 return sodium_crypto_generichash( 'xspeed-secret-v1|' . $material, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
462 }
463
464 /**
465 * Authenticated-encrypt a non-empty plaintext with libsodium's secretbox
466 * (XSalsa20-Poly1305). The random nonce is prepended to the ciphertext and
467 * the whole thing base64'd behind the version marker. libsodium ships in
468 * PHP core from 7.2 (our floor is 7.4); if it were somehow unavailable we
469 * store plaintext rather than fatal — masking on read still applies.
470 */
471 private static function encrypt( string $plain ): string {
472 if ( ! function_exists( 'sodium_crypto_secretbox' ) ) {
473 return $plain;
474 }
475 try {
476 $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
477 $cipher = sodium_crypto_secretbox( $plain, $nonce, self::secret_key() );
478 } catch ( \Throwable $e ) {
479 return $plain;
480 }
481 return self::SECRET_CIPHER_PREFIX . base64_encode( $nonce . $cipher ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- transport encoding for ciphertext, not obfuscation.
482 }
483
484 /**
485 * Reverse encrypt(). A value without the marker is legacy plaintext (or
486 * empty) and is returned as-is — the encryption rollout is lazy, so reads
487 * keep working before the first re-save. A marked value that fails to
488 * decrypt (salts rotated, row tampered) returns '' so the caller behaves as
489 * "no credential set", never a fatal.
490 */
491 private static function decrypt( string $stored ): string {
492 if ( 0 !== strpos( $stored, self::SECRET_CIPHER_PREFIX ) ) {
493 return $stored;
494 }
495 if ( ! function_exists( 'sodium_crypto_secretbox_open' ) ) {
496 return '';
497 }
498 $raw = base64_decode( substr( $stored, strlen( self::SECRET_CIPHER_PREFIX ) ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding our own ciphertext envelope.
499 if ( false === $raw || strlen( $raw ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) {
500 return '';
501 }
502 $nonce = substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
503 $cipher = substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
504 try {
505 $plain = sodium_crypto_secretbox_open( $cipher, $nonce, self::secret_key() );
506 } catch ( \Throwable $e ) {
507 return '';
508 }
509 return ( false === $plain ) ? '' : $plain;
510 }
511
512 /**
513 * Whether the current write is an MCP write that may NOT touch secret
514 * fields — i.e. it came in over MCP and the connection lacks the `configure`
515 * grant. Keeps credential writes off the default MCP surface (#116). Guarded
516 * by class_exists so Settings_Manager never hard-depends on the MCP module.
517 */
518 private static function mcp_write_blocked(): bool {
519 if ( ! class_exists( '\\XSpeed\\Modules\\Mcp\\Mcp_Tools' ) ) {
520 return false;
521 }
522 return \XSpeed\Modules\Mcp\Mcp_Tools::in_dispatch()
523 && ! \XSpeed\Modules\Mcp\Mcp_Tools::can_configure();
524 }
525
526 /** Compact human form of a setting value for the change log. */
527 private static function describe_value( $value ): string {
528 if ( is_bool( $value ) ) {
529 return $value ? 'on' : 'off';
530 }
531 if ( is_array( $value ) ) {
532 return count( $value ) . ' item' . ( 1 === count( $value ) ? '' : 's' );
533 }
534 if ( null === $value ) {
535 return '';
536 }
537 $str = (string) $value;
538 return strlen( $str ) > 40 ? substr( $str, 0, 39 ) . '' : $str;
539 }
540
541 /**
542 * Which surface performed this write. MCP is detected via the tool
543 * dispatcher's in-flight flag; the dashboard UI writes through REST.
544 */
545 private static function source_channel(): string {
546 if ( class_exists( '\\XSpeed\\Modules\\Mcp\\Mcp_Tools' ) && \XSpeed\Modules\Mcp\Mcp_Tools::in_dispatch() ) {
547 return 'mcp';
548 }
549 if ( defined( 'WP_CLI' ) && WP_CLI ) {
550 return 'cli';
551 }
552 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
553 return 'dashboard';
554 }
555 return 'admin';
556 }
557
558 /**
559 * Run any pending schema migrations for a module. Called by
560 * Module_Registry before boot(). Idempotent — migrations only run once
561 * per version bump because we persist `_version` after each successful
562 * migration step.
563 */
564 public static function run_migrations( Module $module ): void {
565 $migrations = $module->migrations();
566 if ( empty( $migrations ) ) {
567 return;
568 }
569 $option_key = self::option_key( $module->slug() );
570 $stored = get_option( $option_key, null );
571 if ( null === $stored ) {
572 return; // fresh install — no data to migrate.
573 }
574 if ( ! is_array( $stored ) ) {
575 $stored = array();
576 }
577 $from = isset( $stored['_version'] ) ? (string) $stored['_version'] : '0.0.0';
578
579 // Sort migrations by version ascending.
580 uksort(
581 $migrations,
582 static function ( $a, $b ) {
583 return version_compare( (string) $a, (string) $b );
584 }
585 );
586
587 $dirty = false;
588 foreach ( $migrations as $target => $callable ) {
589 $target = (string) $target;
590 if ( version_compare( $from, $target, '>=' ) ) {
591 continue;
592 }
593 $migrated = call_user_func( $callable, $stored );
594 if ( is_array( $migrated ) ) {
595 $stored = $migrated;
596 $stored['_version'] = $target;
597 $from = $target;
598 $dirty = true;
599 }
600 }
601
602 if ( $dirty ) {
603 update_option( $option_key, $stored );
604 }
605 }
606
607 /**
608 * Coerce a stored value to the schema's declared type — used on read
609 * to defend against options edited by hand or imported across versions.
610 */
611 private static function coerce( $value, array $spec ) {
612 $type = $spec['type'] ?? 'string';
613 switch ( $type ) {
614 case 'bool':
615 return (bool) $value;
616 case 'int':
617 $v = (int) $value;
618 if ( isset( $spec['min'] ) ) {
619 $v = max( (int) $spec['min'], $v );
620 }
621 if ( isset( $spec['max'] ) ) {
622 $v = min( (int) $spec['max'], $v );
623 }
624 return $v;
625 case 'enum':
626 return in_array( $value, $spec['options'] ?? array(), true )
627 ? $value
628 : ( $spec['default'] ?? null );
629 case 'list':
630 if ( ! is_array( $value ) ) {
631 return $spec['default'] ?? array();
632 }
633 return array_values( array_filter( $value, 'is_scalar' ) );
634 case 'url':
635 $url = esc_url_raw( (string) $value );
636 return $url ?: ( $spec['default'] ?? '' );
637 case 'media':
638 // Media-library image URL. Empty is a valid "no image" state.
639 // esc_url_raw alone lets through any safe URL (…/evil.txt,
640 // non-images) which then renders as a broken <img>; require it
641 // to look like an image and drop anything else to empty.
642 $media = esc_url_raw( (string) $value );
643 return ( '' === $media || self::is_image_url( $media ) ) ? $media : '';
644 case 'secret':
645 // A credential (API token, password, …). Stored encrypted at
646 // rest (SECRET_CIPHER_PREFIX). Reading decrypts to plaintext so
647 // the engine — Cloudflare purge, Redis auth — gets the real
648 // value; the masking that keeps it out of REST/MCP/dashboard
649 // payloads happens later, at the output boundary (get_public),
650 // never here. Legacy unencrypted values pass straight through.
651 return self::decrypt( (string) $value );
652 case 'string':
653 default:
654 return sanitize_text_field( (string) $value );
655 }
656 }
657
658 /**
659 * Validate one field; returns [ coerced_value, was_valid ]. Distinct
660 * from coerce() because validate is strict (out-of-range int is
661 * INVALID) while coerce is forgiving (clamps to range).
662 */
663 private static function validate_field( $value, array $spec ): array {
664 $type = $spec['type'] ?? 'string';
665 switch ( $type ) {
666 case 'bool':
667 // Strictly validate (don't blindly (bool)-cast). A plain cast
668 // treated every non-empty string as true, so a client sending
669 // the string "false" (or any junk text) silently ENABLED the
670 // toggle. filter_var with FILTER_NULL_ON_FAILURE accepts the
671 // real bool-ish forms (true/false, 1/0, "1"/"0", "true"/
672 // "false", "yes"/"no", "on"/"off") and returns null for
673 // anything else — which we report as invalid so the previous
674 // stored value is kept, mirroring int/enum. (FBS-82158)
675 $b = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
676 if ( null === $b ) {
677 return array( null, false );
678 }
679 return array( $b, true );
680 case 'int':
681 if ( ! is_numeric( $value ) ) {
682 return array( null, false );
683 }
684 $v = (int) $value;
685 if ( isset( $spec['min'] ) && $v < (int) $spec['min'] ) {
686 return array( null, false );
687 }
688 if ( isset( $spec['max'] ) && $v > (int) $spec['max'] ) {
689 return array( null, false );
690 }
691 return array( $v, true );
692 case 'enum':
693 $ok = in_array( $value, $spec['options'] ?? array(), true );
694 return array( $ok ? $value : null, $ok );
695 case 'list':
696 if ( ! is_array( $value ) ) {
697 return array( null, false );
698 }
699 $item_type = $spec['item_type'] ?? 'string';
700 $out = array();
701 foreach ( $value as $item ) {
702 // Skip non-scalar items (e.g. a nested array). Casting one
703 // with (string) emits an "Array to string conversion"
704 // warning and stores the garbage literal "Array" — coerce()
705 // already filters these via is_scalar; mirror it here.
706 // (FBS-82172 Bug 4)
707 if ( ! is_scalar( $item ) ) {
708 continue;
709 }
710 if ( 'url' === $item_type ) {
711 $u = esc_url_raw( (string) $item );
712 if ( $u ) {
713 $out[] = $u;
714 }
715 } else {
716 $out[] = sanitize_text_field( (string) $item );
717 }
718 }
719 return array( $out, true );
720 case 'url':
721 $u = esc_url_raw( (string) $value );
722 return array( $u, (bool) $u );
723 case 'media':
724 // Empty (cleared logo) is valid; any non-empty value must be a
725 // safe URL after esc_url_raw AND look like an image, so a
726 // non-image URL (…/evil.txt) is rejected rather than stored to
727 // render as a broken <img>.
728 $m = esc_url_raw( (string) $value );
729 if ( '' === (string) $value ) {
730 return array( '', true );
731 }
732 $ok = '' !== $m && self::is_image_url( $m );
733 return array( $ok ? $m : '', $ok );
734 case 'secret':
735 // Validated like a string; encryption is applied uniformly in
736 // update() after this returns, so a secret carried over from the
737 // current stored value gets encrypted the same way a freshly
738 // entered one does. Masked placeholders never reach here — update()
739 // filters them out before validating. (#115)
740 return array( sanitize_text_field( (string) $value ), true );
741 case 'string':
742 default:
743 return array( sanitize_text_field( (string) $value ), true );
744 }
745 }
746
747 /**
748 * Whether a URL looks like an image — used to gate `media` fields so a
749 * non-image URL can't be stored and later rendered as a broken <img>
750 * (e.g. the white-label brand logo, FBS-82222). Tests the path extension
751 * against the known image types (query/fragment tolerated). Not a content
752 * check — a cheap, deterministic guard that pairs with the front-end
753 * onError fallback; the Media Library picker already yields conforming
754 * http(s) upload URLs. (data: URIs are stripped by esc_url_raw upstream,
755 * since `data` isn't an allowed protocol, so they never reach here.)
756 */
757 private static function is_image_url( string $url ): bool {
758 $url = trim( $url );
759 if ( '' === $url ) {
760 return false;
761 }
762 // Drop the query string + fragment so ?ver=… / #frag don't defeat the
763 // extension test (e.g. logo.webp?v=2). Plain string ops — no WP URL
764 // parser dependency on this low-level coercion path.
765 $path = (string) preg_replace( '/[?#].*$/', '', $url );
766 return (bool) preg_match( '/\.(jpe?g|png|gif|svg|webp|avif|ico|bmp)$/i', $path );
767 }
768
769 private static function defaults_from_schema( array $schema ): array {
770 $out = array();
771 foreach ( $schema as $key => $spec ) {
772 $out[ $key ] = $spec['default'] ?? null;
773 }
774 return $out;
775 }
776
777 private static function option_key( string $slug ): string {
778 return self::OPTION_PREFIX . $slug;
779 }
780 }
781