PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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.2.0, at includes/class-settings-manager.php

908 lines 34.1 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 * Classify an input payload against a module's schema WITHOUT writing
400 * anything: which keys would be applied, which are unknown, and which are
401 * in-schema but carry a value the validator rejects.
402 *
403 * update() walks the SCHEMA rather than the input, so a key with no schema
404 * entry is never iterated — never written, never mentioned. And an
405 * in-schema key whose value fails validation is dropped deliberately
406 * ("REST layer can add its own strict-mode validation"), which CLI and MCP
407 * never traverse. Both therefore reported success over a write that did
408 * not happen; the realistic case is an agent sending
409 * `cache_enabled` to the `cache` module — a no-op reported as done. (#206)
410 *
411 * Pure: no side effects, so callers can decide to refuse BEFORE writing.
412 * update()'s own signature is deliberately unchanged — a dozen callers
413 * depend on it returning the settings array.
414 *
415 * @param string $slug Module slug.
416 * @param array<string,mixed> $input Proposed values.
417 * @return array{applied:string[],unknown:string[],invalid:string[]}
418 */
419 public static function inspect_input( string $slug, array $input ): array {
420 $out = array(
421 'applied' => array(),
422 'unknown' => array(),
423 'invalid' => array(),
424 );
425
426 $module = Module_Registry::get( $slug );
427 if ( ! $module ) {
428 // Unknown module: the caller reports that separately, and every key
429 // is by definition unapplied.
430 $out['unknown'] = array_keys( $input );
431 return $out;
432 }
433
434 $schema = $module->settings_schema();
435 $preserved = $module->preserved_keys();
436
437 foreach ( $input as $key => $value ) {
438 // Out-of-schema keys a module explicitly preserves are written
439 // verbatim by update(), so they count as applied, not unknown.
440 if ( in_array( $key, $preserved, true ) ) {
441 $out['applied'][] = $key;
442 continue;
443 }
444 if ( ! isset( $schema[ $key ] ) ) {
445 $out['unknown'][] = $key;
446 continue;
447 }
448 $spec = $schema[ $key ];
449 // A masked secret echo is a deliberate "keep what's stored", not a
450 // failed write — update() skips it by design, so don't report it.
451 if ( self::is_secret_field( $key, $spec ) && self::is_masked_secret( (string) $value ) ) {
452 $out['applied'][] = $key;
453 continue;
454 }
455 [ , $valid ] = self::validate_field( $value, $spec );
456 if ( $valid ) {
457 $out['applied'][] = $key;
458 } else {
459 $out['invalid'][] = $key;
460 }
461 }
462
463 return $out;
464 }
465
466 /**
467 * Where a key the caller asked for actually lives, when it isn't in the
468 * module's schema. Turns "unknown key" into a pointer.
469 *
470 * `cache_enabled` is the case worth naming: it is deliberately outside the
471 * cache module's schema because it drives the drop-in install, so the most
472 * natural command an agent issues to turn caching on is a silent no-op.
473 * (#206)
474 *
475 * @param string $key Rejected input key.
476 * @return string Human-readable hint, or '' when there's nothing useful.
477 */
478 public static function hint_for_unknown_key( string $key ): string {
479 $hints = array(
480 'cache_enabled' => 'page caching is not a module setting — it installs the drop-in. Use the dashboard toggle, the REST route /xspeed/v1/cache/toggle, or the MCP `toggle_cache` tool.',
481 'gzip_enabled' => 'this moved to the `gzip` module — try `--values=\'{"enabled":true}\'` against module `gzip`.',
482 );
483 return $hints[ $key ] ?? '';
484 }
485
486 /**
487 * Schema keys closest to a rejected key, so a typo gets a pointer rather
488 * than a bare refusal. Levenshtein over the schema, nearest three. (#206)
489 *
490 * @param string $slug Module slug.
491 * @param string $key Rejected input key.
492 * @return string[] Suggested key names, nearest first.
493 */
494 public static function did_you_mean( string $slug, string $key ): array {
495 $module = Module_Registry::get( $slug );
496 if ( ! $module ) {
497 return array();
498 }
499 $scored = array();
500 foreach ( array_keys( $module->settings_schema() ) as $candidate ) {
501 $distance = levenshtein( $key, (string) $candidate );
502 // Only near-misses: beyond a third of the key's length it's a
503 // different word, and listing it would be noise.
504 if ( $distance <= max( 3, (int) floor( strlen( $key ) / 3 ) ) ) {
505 $scored[ (string) $candidate ] = $distance;
506 }
507 }
508 asort( $scored );
509 return array_slice( array_keys( $scored ), 0, 3 );
510 }
511
512 /**
513 * Masked hint for a stored secret: first 4 + bullets + last 4 (mirrors the
514 * support-snapshot license masking), or all-bullets for a short secret, or
515 * '' when unset. Enough to confirm "a key is saved, ending 4f2a" without
516 * disclosing it. Deterministic — unit-tested.
517 */
518 public static function mask_secret_value( string $value ): string {
519 if ( '' === $value ) {
520 return '';
521 }
522 if ( strlen( $value ) <= 8 ) {
523 return str_repeat( '', 8 );
524 }
525 return substr( $value, 0, 4 ) . self::SECRET_MASK_BULLETS . substr( $value, -4 );
526 }
527
528 /**
529 * Whether an incoming write value is the masked placeholder the client is
530 * echoing back, rather than a real new secret — i.e. it still carries the
531 * mask bullets. A genuine credential never contains the bullet run, so this
532 * can't swallow a real key. update() uses it to keep the stored secret.
533 *
534 * An EMPTY string is NOT a mask echo — it's a deliberate clear, so it flows
535 * through to storage and removes the credential. The dashboard always
536 * re-sends the masked hint (with bullets) on an unrelated save, never an
537 * empty string, so this still can't wipe a key by accident. (#115, QA B7)
538 */
539 public static function is_masked_secret( string $value ): bool {
540 return false !== strpos( $value, self::SECRET_MASK_BULLETS );
541 }
542
543 /**
544 * Encrypt a plaintext secret for storage. Idempotent: an already-encrypted
545 * value (carrying the marker) is returned unchanged, so module migrations
546 * can call this over existing rows without double-wrapping. Empty stays
547 * empty. Used by update() and by the per-module encrypt-on-upgrade
548 * migrations. (#115)
549 */
550 public static function encrypt_for_storage( string $value ): string {
551 if ( '' === $value || 0 === strpos( $value, self::SECRET_CIPHER_PREFIX ) ) {
552 return $value;
553 }
554 return self::encrypt( $value );
555 }
556
557 /**
558 * 32-byte encryption key derived from this site's WordPress salts, so the
559 * ciphertext is bound to the install and never stored alongside the data.
560 * Rotating the salts makes existing secrets undecryptable — decrypt() then
561 * returns '' (treated as "unset", the user re-enters the key) rather than
562 * fataling. Uses AUTH_KEY + SECURE_AUTH_SALT, falling back to wp_salt().
563 */
564 private static function secret_key(): string {
565 $material = '';
566 if ( defined( 'AUTH_KEY' ) ) {
567 $material .= (string) AUTH_KEY;
568 }
569 if ( defined( 'SECURE_AUTH_SALT' ) ) {
570 $material .= (string) SECURE_AUTH_SALT;
571 }
572 if ( '' === $material && function_exists( 'wp_salt' ) ) {
573 $material = (string) wp_salt( 'secure_auth' );
574 }
575 return sodium_crypto_generichash( 'xspeed-secret-v1|' . $material, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
576 }
577
578 /**
579 * Authenticated-encrypt a non-empty plaintext with libsodium's secretbox
580 * (XSalsa20-Poly1305). The random nonce is prepended to the ciphertext and
581 * the whole thing base64'd behind the version marker. libsodium ships in
582 * PHP core from 7.2 (our floor is 7.4); if it were somehow unavailable we
583 * store plaintext rather than fatal — masking on read still applies.
584 */
585 private static function encrypt( string $plain ): string {
586 if ( ! function_exists( 'sodium_crypto_secretbox' ) ) {
587 return $plain;
588 }
589 try {
590 $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
591 $cipher = sodium_crypto_secretbox( $plain, $nonce, self::secret_key() );
592 } catch ( \Throwable $e ) {
593 return $plain;
594 }
595 return self::SECRET_CIPHER_PREFIX . base64_encode( $nonce . $cipher ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- transport encoding for ciphertext, not obfuscation.
596 }
597
598 /**
599 * Reverse encrypt(). A value without the marker is legacy plaintext (or
600 * empty) and is returned as-is — the encryption rollout is lazy, so reads
601 * keep working before the first re-save. A marked value that fails to
602 * decrypt (salts rotated, row tampered) returns '' so the caller behaves as
603 * "no credential set", never a fatal.
604 */
605 private static function decrypt( string $stored ): string {
606 if ( 0 !== strpos( $stored, self::SECRET_CIPHER_PREFIX ) ) {
607 return $stored;
608 }
609 if ( ! function_exists( 'sodium_crypto_secretbox_open' ) ) {
610 return '';
611 }
612 $raw = base64_decode( substr( $stored, strlen( self::SECRET_CIPHER_PREFIX ) ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding our own ciphertext envelope.
613 if ( false === $raw || strlen( $raw ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) {
614 return '';
615 }
616 $nonce = substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
617 $cipher = substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
618 try {
619 $plain = sodium_crypto_secretbox_open( $cipher, $nonce, self::secret_key() );
620 } catch ( \Throwable $e ) {
621 return '';
622 }
623 return ( false === $plain ) ? '' : $plain;
624 }
625
626 /**
627 * Whether the current write is an MCP write that may NOT touch secret
628 * fields — i.e. it came in over MCP and the connection lacks the `configure`
629 * grant. Keeps credential writes off the default MCP surface (#116). Guarded
630 * by class_exists so Settings_Manager never hard-depends on the MCP module.
631 */
632 private static function mcp_write_blocked(): bool {
633 if ( ! class_exists( '\\XSpeed\\Modules\\Mcp\\Mcp_Tools' ) ) {
634 return false;
635 }
636 return \XSpeed\Modules\Mcp\Mcp_Tools::in_dispatch()
637 && ! \XSpeed\Modules\Mcp\Mcp_Tools::can_configure();
638 }
639
640 /** Compact human form of a setting value for the change log. */
641 private static function describe_value( $value ): string {
642 if ( is_bool( $value ) ) {
643 return $value ? 'on' : 'off';
644 }
645 if ( is_array( $value ) ) {
646 return count( $value ) . ' item' . ( 1 === count( $value ) ? '' : 's' );
647 }
648 if ( null === $value ) {
649 return '';
650 }
651 $str = (string) $value;
652 return strlen( $str ) > 40 ? substr( $str, 0, 39 ) . '' : $str;
653 }
654
655 /**
656 * Which surface performed this write. MCP is detected via the tool
657 * dispatcher's in-flight flag; the dashboard UI writes through REST.
658 */
659 private static function source_channel(): string {
660 if ( class_exists( '\\XSpeed\\Modules\\Mcp\\Mcp_Tools' ) && \XSpeed\Modules\Mcp\Mcp_Tools::in_dispatch() ) {
661 return 'mcp';
662 }
663 if ( defined( 'WP_CLI' ) && WP_CLI ) {
664 return 'cli';
665 }
666 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
667 return 'dashboard';
668 }
669 return 'admin';
670 }
671
672 /**
673 * Run any pending schema migrations for a module. Called by
674 * Module_Registry before boot(). Idempotent — migrations only run once
675 * per version bump because we persist `_version` after each successful
676 * migration step.
677 */
678 public static function run_migrations( Module $module ): void {
679 $migrations = $module->migrations();
680 if ( empty( $migrations ) ) {
681 return;
682 }
683 $option_key = self::option_key( $module->slug() );
684 $stored = get_option( $option_key, null );
685 if ( null === $stored ) {
686 return; // fresh install — no data to migrate.
687 }
688 if ( ! is_array( $stored ) ) {
689 $stored = array();
690 }
691 $from = isset( $stored['_version'] ) ? (string) $stored['_version'] : '0.0.0';
692
693 // Sort migrations by version ascending.
694 uksort(
695 $migrations,
696 static function ( $a, $b ) {
697 return version_compare( (string) $a, (string) $b );
698 }
699 );
700
701 $dirty = false;
702 foreach ( $migrations as $target => $callable ) {
703 $target = (string) $target;
704 if ( version_compare( $from, $target, '>=' ) ) {
705 continue;
706 }
707 $migrated = call_user_func( $callable, $stored );
708 if ( is_array( $migrated ) ) {
709 $stored = $migrated;
710 $stored['_version'] = $target;
711 $from = $target;
712 $dirty = true;
713 }
714 }
715
716 if ( $dirty ) {
717 update_option( $option_key, $stored );
718 }
719 }
720
721 /**
722 * Coerce a stored value to the schema's declared type — used on read
723 * to defend against options edited by hand or imported across versions.
724 */
725 private static function coerce( $value, array $spec ) {
726 $type = $spec['type'] ?? 'string';
727 switch ( $type ) {
728 case 'bool':
729 return (bool) $value;
730 case 'int':
731 $v = (int) $value;
732 if ( isset( $spec['min'] ) ) {
733 $v = max( (int) $spec['min'], $v );
734 }
735 if ( isset( $spec['max'] ) ) {
736 $v = min( (int) $spec['max'], $v );
737 }
738 return $v;
739 case 'enum':
740 return in_array( $value, $spec['options'] ?? array(), true )
741 ? $value
742 : ( $spec['default'] ?? null );
743 case 'list':
744 if ( ! is_array( $value ) ) {
745 return $spec['default'] ?? array();
746 }
747 return array_values( array_filter( $value, 'is_scalar' ) );
748 case 'url':
749 // A deliberately-cleared URL must read back as empty, not snap
750 // to the schema default — `?:` swallowed the empty string and
751 // resurrected the default on every read. (#197)
752 if ( '' === trim( (string) $value ) ) {
753 return '';
754 }
755 $url = esc_url_raw( (string) $value );
756 return $url ?: ( $spec['default'] ?? '' );
757 case 'media':
758 // Media-library image URL. Empty is a valid "no image" state.
759 // esc_url_raw alone lets through any safe URL (…/evil.txt,
760 // non-images) which then renders as a broken <img>; require it
761 // to look like an image and drop anything else to empty.
762 $media = esc_url_raw( (string) $value );
763 return ( '' === $media || self::is_image_url( $media ) ) ? $media : '';
764 case 'secret':
765 // A credential (API token, password, …). Stored encrypted at
766 // rest (SECRET_CIPHER_PREFIX). Reading decrypts to plaintext so
767 // the engine — Cloudflare purge, Redis auth — gets the real
768 // value; the masking that keeps it out of REST/MCP/dashboard
769 // payloads happens later, at the output boundary (get_public),
770 // never here. Legacy unencrypted values pass straight through.
771 return self::decrypt( (string) $value );
772 case 'string':
773 default:
774 return sanitize_text_field( (string) $value );
775 }
776 }
777
778 /**
779 * Validate one field; returns [ coerced_value, was_valid ]. Distinct
780 * from coerce() because validate is strict (out-of-range int is
781 * INVALID) while coerce is forgiving (clamps to range).
782 */
783 private static function validate_field( $value, array $spec ): array {
784 $type = $spec['type'] ?? 'string';
785 switch ( $type ) {
786 case 'bool':
787 // Strictly validate (don't blindly (bool)-cast). A plain cast
788 // treated every non-empty string as true, so a client sending
789 // the string "false" (or any junk text) silently ENABLED the
790 // toggle. filter_var with FILTER_NULL_ON_FAILURE accepts the
791 // real bool-ish forms (true/false, 1/0, "1"/"0", "true"/
792 // "false", "yes"/"no", "on"/"off") and returns null for
793 // anything else — which we report as invalid so the previous
794 // stored value is kept, mirroring int/enum. (FBS-82158)
795 $b = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
796 if ( null === $b ) {
797 return array( null, false );
798 }
799 return array( $b, true );
800 case 'int':
801 if ( ! is_numeric( $value ) ) {
802 return array( null, false );
803 }
804 $v = (int) $value;
805 if ( isset( $spec['min'] ) && $v < (int) $spec['min'] ) {
806 return array( null, false );
807 }
808 if ( isset( $spec['max'] ) && $v > (int) $spec['max'] ) {
809 return array( null, false );
810 }
811 return array( $v, true );
812 case 'enum':
813 $ok = in_array( $value, $spec['options'] ?? array(), true );
814 return array( $ok ? $value : null, $ok );
815 case 'list':
816 if ( ! is_array( $value ) ) {
817 return array( null, false );
818 }
819 $item_type = $spec['item_type'] ?? 'string';
820 $out = array();
821 foreach ( $value as $item ) {
822 // Skip non-scalar items (e.g. a nested array). Casting one
823 // with (string) emits an "Array to string conversion"
824 // warning and stores the garbage literal "Array" — coerce()
825 // already filters these via is_scalar; mirror it here.
826 // (FBS-82172 Bug 4)
827 if ( ! is_scalar( $item ) ) {
828 continue;
829 }
830 if ( 'url' === $item_type ) {
831 $u = esc_url_raw( (string) $item );
832 if ( $u ) {
833 $out[] = $u;
834 }
835 } else {
836 $out[] = sanitize_text_field( (string) $item );
837 }
838 }
839 return array( $out, true );
840 case 'url':
841 // Empty is a valid "cleared" state, not invalid input — same
842 // as `media` below. Reporting it invalid made the previous
843 // stored value stick, so clearing a URL field appeared to
844 // "come back" a moment later when the save echo landed. (#197)
845 if ( '' === trim( (string) $value ) ) {
846 return array( '', true );
847 }
848 $u = esc_url_raw( (string) $value );
849 return array( $u, (bool) $u );
850 case 'media':
851 // Empty (cleared logo) is valid; any non-empty value must be a
852 // safe URL after esc_url_raw AND look like an image, so a
853 // non-image URL (…/evil.txt) is rejected rather than stored to
854 // render as a broken <img>.
855 $m = esc_url_raw( (string) $value );
856 if ( '' === (string) $value ) {
857 return array( '', true );
858 }
859 $ok = '' !== $m && self::is_image_url( $m );
860 return array( $ok ? $m : '', $ok );
861 case 'secret':
862 // Validated like a string; encryption is applied uniformly in
863 // update() after this returns, so a secret carried over from the
864 // current stored value gets encrypted the same way a freshly
865 // entered one does. Masked placeholders never reach here — update()
866 // filters them out before validating. (#115)
867 return array( sanitize_text_field( (string) $value ), true );
868 case 'string':
869 default:
870 return array( sanitize_text_field( (string) $value ), true );
871 }
872 }
873
874 /**
875 * Whether a URL looks like an image — used to gate `media` fields so a
876 * non-image URL can't be stored and later rendered as a broken <img>
877 * (e.g. the white-label brand logo, FBS-82222). Tests the path extension
878 * against the known image types (query/fragment tolerated). Not a content
879 * check — a cheap, deterministic guard that pairs with the front-end
880 * onError fallback; the Media Library picker already yields conforming
881 * http(s) upload URLs. (data: URIs are stripped by esc_url_raw upstream,
882 * since `data` isn't an allowed protocol, so they never reach here.)
883 */
884 private static function is_image_url( string $url ): bool {
885 $url = trim( $url );
886 if ( '' === $url ) {
887 return false;
888 }
889 // Drop the query string + fragment so ?ver=… / #frag don't defeat the
890 // extension test (e.g. logo.webp?v=2). Plain string ops — no WP URL
891 // parser dependency on this low-level coercion path.
892 $path = (string) preg_replace( '/[?#].*$/', '', $url );
893 return (bool) preg_match( '/\.(jpe?g|png|gif|svg|webp|avif|ico|bmp)$/i', $path );
894 }
895
896 private static function defaults_from_schema( array $schema ): array {
897 $out = array();
898 foreach ( $schema as $key => $spec ) {
899 $out[ $key ] = $spec['default'] ?? null;
900 }
901 return $out;
902 }
903
904 private static function option_key( string $slug ): string {
905 return self::OPTION_PREFIX . $slug;
906 }
907 }
908