PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 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 All 36 releases
xspeed / includes / class-settings-manager.php

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

1,919 lines 74.0 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 // A field whose value is pinned by a wp-config.php constant reads
66 // back from that constant, whatever the option row says. This is
67 // the single resolution point every consumer -- module, REST, CLI,
68 // MCP, and Pro via its own Settings_Manager::get() calls -- passes
69 // through, so status, test and enable can never disagree with what
70 // the drop-in actually connected to. (#398)
71 $constant = self::effective_constant( $slug, $key, $spec );
72 if ( null !== $constant ) {
73 $clean[ $key ] = self::coerce( self::constant_value( $key, $constant, $spec ), $spec );
74 continue;
75 }
76 /*
77 * A module whose runtime reads its config before WordPress loads
78 * may keep it OUTSIDE the option row -- the object cache writes a
79 * sidecar when wp-config.php is read-only. Ask for that value
80 * before falling back to the row, or the panel reports the stored
81 * settings while the drop-in runs on the sidecar's. (#398)
82 */
83 $external = apply_filters( 'xspeed_setting_external_source', null, $slug, $key, $spec );
84 if ( null !== $external ) {
85 $clean[ $key ] = self::coerce( $external, $spec );
86 continue;
87 }
88
89 $clean[ $key ] = array_key_exists( $key, $merged )
90 ? self::coerce( $merged[ $key ], $spec )
91 : ( $spec['default'] ?? null );
92 }
93
94 // Carry through any out-of-schema keys the module explicitly preserves
95 // (e.g. the REST-cache route `rules` array) so a schema-driven save
96 // doesn't silently drop them. (FBS-82408)
97 foreach ( $module->preserved_keys() as $key ) {
98 if ( array_key_exists( $key, $stored ) ) {
99 $clean[ $key ] = $stored[ $key ];
100 }
101 }
102
103 return $clean;
104 }
105
106 /**
107 * The constant that pins a field's value, or null when none is defined.
108 *
109 * A field opts in by declaring `constants` in its schema spec -- an ordered
110 * list of constant names, most specific first (our own `XSPEED_*` before a
111 * community convention like `WP_REDIS_*`). The first DEFINED name wins.
112 *
113 * `defined()` is the whole test, deliberately: a constant defined as an
114 * empty string is an answer ("this server has no auth"), not an absence.
115 * Falling through to the DB there would silently re-introduce a credential
116 * the operator removed on purpose. (#398)
117 *
118 * @param array $spec Field spec from settings_schema().
119 * @return string|null Winning constant name, or null.
120 */
121 public static function constant_source( array $spec ): ?string {
122 // A field can also be pinned by a wp-config.php GLOBAL rather than a
123 // constant -- $memcached_servers is the de-facto standard for Memcached
124 // the way WP_REDIS_* is for Redis, and hosts write it. It is checked
125 // first because a site that sets it means it; `global_source` names the
126 // variable and the element to read. (#398)
127 $global = self::global_source( $spec );
128 if ( null !== $global ) {
129 return $global;
130 }
131
132 $pair_only = (array) ( $spec['constants_pair_only'] ?? array() );
133 foreach ( (array) ( $spec['constants'] ?? array() ) as $name ) {
134 if ( ! is_string( $name ) || '' === $name || ! defined( $name ) ) {
135 continue;
136 }
137 // Named in `constants_pair_only`, this constant answers for the
138 // field ONLY in its array form, where it carries both halves of a
139 // credential. A username field reading a plain-string
140 // WP_REDIS_PASSWORD would otherwise authenticate with the password
141 // as the username.
142 if ( in_array( $name, $pair_only, true ) && ! is_array( constant( $name ) ) ) {
143 continue;
144 }
145 /*
146 * A legacy name kept only for backward compatibility, valid while
147 * another constant holds a particular value. XSPEED_OC_PORT used to
148 * serve BOTH backends; Memcached fields still read it so an install
149 * configured before the split keeps working -- but only when
150 * Memcached is actually the backend, or a Redis site would show the
151 * Redis port in its Memcached field. (#398)
152 */
153 $when = (array) ( $spec['constants_when'] ?? array() );
154 if ( isset( $when[ $name ] ) ) {
155 $gate = $when[ $name ];
156 $on = $gate['constant'] ?? '';
157 /*
158 * Resolved through the same filter the rest of the module uses,
159 * not `defined()` alone: on a host where wp-config.php is
160 * read-only the backend lives in the sidecar and no constant is
161 * defined at all. Reading only the constant there rejected the
162 * legacy name, so the panel showed the default host while the
163 * drop-in -- which does consult the sidecar -- used the real
164 * one. Panel and runtime disagreeing is the bug this change
165 * exists to remove. (#398)
166 */
167 $actual = defined( $on ) ? constant( $on ) : null;
168
169 /**
170 * Filter: xspeed_constant_gate_value
171 *
172 * @param mixed $actual Value of the gating constant, or null.
173 * @param string $on Gating constant name.
174 */
175 $actual = apply_filters( 'xspeed_constant_gate_value', $actual, $on );
176 $want = $gate['is'] ?? null;
177 /*
178 * `case_insensitive` is for gates on another plugin's
179 * convention that is read that way at its source: Redis
180 * Object Cache matches WP_REDIS_SCHEME with strcasecmp(), so
181 * `UNIX` is a socket there and has to be one here. (#566)
182 */
183 $match = ! empty( $gate['case_insensitive'] ) && is_string( $actual ) && is_string( $want )
184 ? 0 === strcasecmp( $actual, $want )
185 : $actual === $want;
186 if ( ! $match ) {
187 continue;
188 }
189 }
190 return $name;
191 }
192 return null;
193 }
194
195 /**
196 * The wp-config.php GLOBAL pinning this field, as a display name, or null.
197 *
198 * Declared as `global_source => [ 'var' => 'memcached_servers', 'path' =>
199 * [ 0, 0 ] ]` — the variable name plus the path to the element this field
200 * reads. Returned in `$var` form ("$memcached_servers") because that is
201 * what the panel shows and what the reader types into a search box.
202 *
203 * Memcached has no constant convention the way Redis has WP_REDIS_*; the
204 * global IS the convention, used by W3TC and the Memcached Object Cache
205 * drop-in alike, and hosts write it. Without this the drop-in honoured it
206 * while the panel did not -- the same two-truths bug this change closes
207 * for Redis. (#398)
208 */
209 public static function global_source( array $spec ): ?string {
210 $decl = $spec['global_source'] ?? null;
211 if ( ! is_array( $decl ) || empty( $decl['var'] ) ) {
212 return null;
213 }
214 return null === self::global_value( $spec ) ? null : '$' . (string) $decl['var'];
215 }
216
217 /**
218 * The value a `global_source` field resolves to, or null when the global is
219 * absent or does not carry the declared path.
220 *
221 * @return mixed|null
222 */
223 private static function global_value( array $spec ) {
224 $decl = $spec['global_source'] ?? null;
225 if ( ! is_array( $decl ) || empty( $decl['var'] ) ) {
226 return null;
227 }
228 $var = (string) $decl['var'];
229 if ( ! array_key_exists( $var, $GLOBALS ) ) {
230 return null;
231 }
232 $value = $GLOBALS[ $var ];
233
234 /*
235 * A `reader` names a function that knows the global's real shape, for a
236 * convention a fixed path cannot express. $memcached_servers ships in
237 * two forms -- a [host, port] pair and a "host:port" string under a
238 * `default` key -- and walking `[0][0]` reads the second as the whole
239 * string, or misses it entirely. The drop-in has to answer identically,
240 * so both call the same function rather than each carrying a walker.
241 * (#398)
242 */
243 $reader = $decl['reader'] ?? null;
244 if ( null !== $reader ) {
245 if ( ! is_callable( $reader ) ) {
246 return null;
247 }
248 $slot = (int) ( $decl['slot'] ?? 0 );
249 $pair = $reader( $value );
250 return is_array( $pair ) && isset( $pair[ $slot ] ) ? $pair[ $slot ] : null;
251 }
252
253 foreach ( (array) ( $decl['path'] ?? array() ) as $step ) {
254 if ( ! is_array( $value ) || ! array_key_exists( $step, $value ) ) {
255 return null;
256 }
257 $value = $value[ $step ];
258 }
259 // An array here means the path did not reach a scalar -- a malformed
260 // $memcached_servers, say. Treat it as absent rather than coercing it
261 // to the string "Array" the way the Redis password bug once did.
262 return is_array( $value ) ? null : $value;
263 }
264
265 /**
266 * The constant that EFFECTIVELY pins a field, or null.
267 *
268 * `constant_source()` answers "is a constant defined for this field";
269 * this answers "does that constant still win", which is the question every
270 * caller actually has. They differ for one reason: an admin can
271 * deliberately override a pinned field (DESIGN.md §24.36), after which the
272 * constant is shadowed by our own and the field behaves normally again.
273 *
274 * Read/lock/refuse paths all go through here, so an override cannot be
275 * honoured in one place and ignored in another. (#398)
276 *
277 * @param string $slug Module slug.
278 * @param string $key Field key.
279 * @param array $spec Field spec from settings_schema().
280 */
281 public static function effective_constant( string $slug, string $key, array $spec ): ?string {
282 if ( self::is_overridden( $slug, $key ) ) {
283 return null;
284 }
285
286 /*
287 * A HOST define outranks one we wrote ourselves, whatever order the
288 * schema lists them in. Ours is not a lock -- it is only where the
289 * drop-in can read the option row -- so letting it win meant a host
290 * that added or ROTATED a define after we had written ours was ignored
291 * for good, with nothing on screen to say so. The site kept using our
292 * stale snapshot until someone happened to save in the panel.
293 *
294 * Deliberate overrides are unaffected: is_overridden() has already
295 * returned above, which is the one case where an admin has asked for
296 * our value to win. (#398)
297 */
298 $constant = self::constant_source( $spec );
299 if ( null !== $constant && in_array( $constant, self::self_written_constants(), true ) ) {
300 $foreign = self::foreign_constant( $slug, $key );
301 if ( null !== $foreign ) {
302 return $foreign;
303 }
304 }
305 return $constant;
306 }
307
308 /**
309 * Option key holding the fields an admin deliberately took back from
310 * wp-config.php. Kept OUTSIDE the module's settings row so a schema-driven
311 * save can never drop it, and so it survives a module version migration.
312 */
313 private const OVERRIDE_OPTION = 'xspeed_overridden_constants';
314
315 /**
316 * True while update() is settling overrides at the end of a save.
317 *
318 * A save PROMOTES an overridden field: the typed value is written into the
319 * module's own constants and the override then lifts. That lift fires
320 * `xspeed_setting_override_changed` like any other, so a listener that
321 * rewrites config on a revert would run here too -- resolving the host's
322 * constant again and erasing the define the save had just written, undoing
323 * the edit with a success message on screen. Listeners check this to tell
324 * "handed back to the host" from "promoted into our own block". (#398)
325 *
326 * Keyed by module slug, never a single global: saving module A must not
327 * silence a revert that A's own save handler performs on module B. A bare
328 * flag would swallow it as a promotion and leave our define in place --
329 * the panel-revert no-op, reintroduced through a side door.
330 *
331 * @var array<string,bool>
332 */
333 private static array $promoting = array();
334
335 /** @see self::$promoting */
336 public static function is_promoting( string $slug ): bool {
337 return ! empty( self::$promoting[ $slug ] );
338 }
339
340 /**
341 * Has an admin deliberately overridden this constant-pinned field?
342 *
343 * Overriding is a considered act: the panel states that the host set the
344 * value and that overriding shadows their future changes, and only then
345 * unlocks the field (DESIGN.md §24.36). Once taken, the field behaves like
346 * any other -- editable, stored in the option row, and exempt from the
347 * enable() guard that otherwise protects a host's define. (#398)
348 */
349 public static function is_overridden( string $slug, string $key ): bool {
350 $all = get_option( self::OVERRIDE_OPTION, array() );
351 if ( ! is_array( $all ) || ! in_array( $key, (array) ( $all[ $slug ] ?? array() ), true ) ) {
352 return false;
353 }
354
355 // An override only means something while a FOREIGN constant is actually
356 // pinning the field. Two ways the entry goes stale, both silent:
357 //
358 // - The host removes their define. There is nothing left to override,
359 // and a lingering entry would disable the lock the moment they put
360 // it back -- the field would stay editable with nothing on screen
361 // saying why.
362 // - enable() writes our own XSPEED_OC_* copy of the admin's value. If
363 // that counted as "still overriding", Revert would hand the field to
364 // our own snapshot instead of back to the host, and the row would
365 // name a constant xSpeed wrote rather than the one being displaced.
366 //
367 // So the override is scoped to a foreign constant being present. (#398)
368 return null !== self::foreign_constant( $slug, $key );
369 }
370
371 /**
372 * The constant pinning this field that xSpeed did not write itself.
373 *
374 * Our own `XSPEED_*` defines are ours to rewrite and never something an
375 * admin needs protecting from; only somebody else's define -- the host's
376 * `WP_REDIS_*` -- is what an override displaces. (#398)
377 */
378 public static function foreign_constant( string $slug, string $key ): ?string {
379 $module = Module_Registry::get( $slug );
380 if ( ! $module ) {
381 return null;
382 }
383 $spec = $module->settings_schema()[ $key ] ?? null;
384 if ( ! is_array( $spec ) ) {
385 return null;
386 }
387 $ours = self::self_written_constants();
388 foreach ( (array) ( $spec['constants'] ?? array() ) as $name ) {
389 /*
390 * Ownership is decided by WHERE the define sits, not by its name --
391 * the same rule our_constants() implements. Skipping every
392 * `XSPEED_*` by prefix got the hand-pasted case backwards: a user
393 * who pastes our snippet themselves owns those defines, and
394 * treating them as ours let wp_config_block() emit a SECOND define
395 * of the same constant inside our block. PHP then warns
396 * "already defined" on every request, the user's value wins because
397 * it came first, and the panel shows ours. (#398)
398 */
399 if ( ! is_string( $name ) || in_array( $name, $ours, true ) ) {
400 continue;
401 }
402 /*
403 * WP_CACHE_KEY_SALT is not a foreign authority to displace ours
404 * (#430). Unlike WP_REDIS_PREFIX, it is not a host declaration of
405 * the cache namespace -- it is WordPress's own cache-uniqueness
406 * salt, present on nearly every install and usually random. Treating
407 * it as one made effective_constant() prefer a random WordPress salt
408 * over the correct XSPEED_OC_SALT that xCloud writes beside it, so
409 * every write landed outside the ACL namespace (NOPERM). Mirrors the
410 * drop-in's salt resolution so panel and runtime agree.
411 */
412 if ( 'WP_CACHE_KEY_SALT' === $name ) {
413 continue;
414 }
415 $single = self::constant_source( array( 'constants' => array( $name ) ) + $spec );
416 if ( null !== $single ) {
417 return $single;
418 }
419 }
420 return null;
421 }
422
423 /**
424 * Is this field listed in the override option, regardless of whether a
425 * constant is currently pinning it?
426 *
427 * `is_overridden()` asks the question callers usually mean -- "is this
428 * override doing anything right now" -- which is false once the constant
429 * it displaced is gone. This is the raw list membership, for the two places
430 * that manage the list itself.
431 */
432 private static function has_override_entry( string $slug, string $key ): bool {
433 $all = get_option( self::OVERRIDE_OPTION, array() );
434 return is_array( $all ) && in_array( $key, (array) ( $all[ $slug ] ?? array() ), true );
435 }
436
437 /**
438 * Every overridden field for a module.
439 *
440 * @return string[]
441 */
442 public static function overridden_keys( string $slug ): array {
443 $all = get_option( self::OVERRIDE_OPTION, array() );
444 if ( ! is_array( $all ) ) {
445 return array();
446 }
447 return array_values( array_filter( (array) ( $all[ $slug ] ?? array() ), 'is_string' ) );
448 }
449
450 /**
451 * Take a pinned field back, or hand it to the constant again.
452 *
453 * Reverting deliberately leaves the stored value in place: the constant
454 * outranks it the moment the override lifts, so the row is inert, and
455 * keeping it means a second override does not start from a blank box.
456 *
457 * @param bool $on True to override, false to revert.
458 */
459 public static function set_override( string $slug, string $key, bool $on ): void {
460 $module = Module_Registry::get( $slug );
461 if ( ! $module || ! array_key_exists( $key, $module->settings_schema() ) ) {
462 return;
463 }
464 // Seed the option row from the value currently in force, BEFORE the
465 // override lifts. Without this the field falls back to its schema
466 // default the moment it unlocks -- so an admin who took the field over
467 // to tweak the host would find it silently reset to 127.0.0.1, and a
468 // save would write that. Take-over means "carry on from here", not
469 // "start again". Skipped when a row already exists, so a second
470 // override still resumes from what was last typed. (#398)
471 if ( $on && ! self::has_override_entry( $slug, $key ) ) {
472 $stored = get_option( self::option_key( $slug ), array() );
473 if ( ! is_array( $stored ) ) {
474 $stored = array();
475 }
476 if ( ! array_key_exists( $key, $stored ) ) {
477 $spec = $module->settings_schema()[ $key ];
478 $constant = self::constant_source( $spec );
479 if ( null !== $constant ) {
480 $stored[ $key ] = self::coerce( self::constant_value( $key, $constant, $spec ), $spec );
481 if ( 'secret' === ( $spec['type'] ?? '' ) ) {
482 $stored[ $key ] = self::encrypt_for_storage( (string) $stored[ $key ] );
483 }
484 // Stamp the version this row was written against. Without it
485 // run_migrations() sees a row with no `_version`, reads that
486 // as 0.0.0, and replays EVERY migration over data that never
487 // held a pre-migration shape -- a seed is not an upgrade.
488 if ( ! isset( $stored['_version'] ) ) {
489 $stored['_version'] = $module->version();
490 }
491 update_option( self::option_key( $slug ), $stored );
492 }
493 }
494 }
495
496 $all = get_option( self::OVERRIDE_OPTION, array() );
497 if ( ! is_array( $all ) ) {
498 $all = array();
499 }
500 $keys = array_values( array_filter( (array) ( $all[ $slug ] ?? array() ), 'is_string' ) );
501
502 if ( $on ) {
503 if ( ! in_array( $key, $keys, true ) ) {
504 $keys[] = $key;
505 }
506 } else {
507 $keys = array_values( array_diff( $keys, array( $key ) ) );
508 }
509
510 if ( empty( $keys ) ) {
511 unset( $all[ $slug ] );
512 } else {
513 $all[ $slug ] = $keys;
514 }
515 update_option( self::OVERRIDE_OPTION, $all );
516
517 /**
518 * A field's ownership just changed hands.
519 *
520 * A module that writes its own constants into wp-config.php has to act
521 * here, or a revert is a dead end: Object_Cache's enable() emits the
522 * admin's overridden value as XSPEED_OC_*, which outranks the host's
523 * define, so dropping the override entry alone leaves the field pinned
524 * to our stale snapshot with no route back. The listener rewrites its
525 * block from the settings as they now resolve. (#398)
526 *
527 * @param string $slug Module slug.
528 * @param string $key Field key.
529 * @param bool $on True when taken over, false when handed back.
530 */
531 do_action( 'xspeed_setting_override_changed', $slug, $key, $on );
532 }
533
534 /**
535 * Hand a field back to the host's constant, completely.
536 *
537 * `set_override( ..., false )` only drops the override entry, which is
538 * enough when the constant that pinned the field is someone else's. It is
539 * NOT enough once we have written our own define: ours outranks the host's,
540 * so the field keeps our stale value, a later credential rotation is
541 * ignored for good, and the panel's "you can revert at any time" is a lie.
542 * The CLI already did the full hand-back inline; REST did not, so the
543 * button in the panel was a no-op on exactly the sites this matters on.
544 * One implementation, both callers. (#398)
545 *
546 * Returns the host constant the field was handed back to, or a WP_Error
547 * when there is nothing to hand it back TO -- reverting onto our own define
548 * would rewrite the same value into our own block and report success over a
549 * no-op, which is the behaviour this replaces.
550 *
551 * @return string|\WP_Error Foreign constant name, or an error.
552 */
553 public static function revert( string $slug, string $key ) {
554 $module = Module_Registry::get( $slug );
555 if ( ! $module || ! array_key_exists( $key, $module->settings_schema() ) ) {
556 return new \WP_Error(
557 'xspeed_unknown_setting',
558 sprintf(
559 /* translators: %s: setting key. */
560 __( 'Unknown setting: %s', 'xspeed' ),
561 $key
562 ),
563 array( 'status' => 400 )
564 );
565 }
566
567 $foreign = self::foreign_constant( $slug, $key );
568 if ( null === $foreign ) {
569 return new \WP_Error(
570 'xspeed_nothing_to_revert',
571 sprintf(
572 /* translators: %s: setting key. */
573 __( '"%s" is not set in wp-config.php by your host, so there is nothing to revert to. Set it to the value you want instead.', 'xspeed' ),
574 $key
575 ),
576 array( 'status' => 409 )
577 );
578 }
579
580 self::set_override( $slug, $key, false );
581
582 /*
583 * Drop our stored value as well as the override entry. Leaving the row
584 * in place is what kept our define being re-emitted on the next block
585 * write, so the field never actually returned to the host. The
586 * `xspeed_setting_override_changed` action fired by set_override() is
587 * what rewrites the block from the settings as they now resolve.
588 */
589 $row = get_option( self::option_key( $slug ), array() );
590 if ( is_array( $row ) && array_key_exists( $key, $row ) ) {
591 unset( $row[ $key ] );
592 update_option( self::option_key( $slug ), $row );
593 }
594
595 return $foreign;
596 }
597
598 /**
599 * A constant's value as this field should read it.
600 *
601 * Almost always the constant verbatim. The exception is a constant that
602 * carries a credential PAIR in one define -- managed hosts provisioning
603 * Redis ACL users ship
604 *
605 * define( 'WP_REDIS_PASSWORD', array( 'acl_user', 's3cret' ) );
606 *
607 * and casting that to string yields "Array" plus a notice, so the site
608 * authenticates with garbage. A field says which half it wants by
609 * declaring `constant_pair => 'user'|'password'`; the split is positional
610 * so an associative pair works too. Declared in the schema rather than
611 * keyed off field names, so the next module with a paired credential
612 * inherits it. (#398)
613 *
614 * @param string $key Schema field key (for context in filters).
615 * @param string $constant Winning constant name.
616 * @param array $spec Field spec from settings_schema().
617 * @return mixed
618 */
619 private static function constant_value( string $key, string $constant, array $spec ) {
620 // A `$`-prefixed source is a wp-config global, not a constant.
621 $value = 0 === strpos( $constant, '$' )
622 ? self::global_value( $spec )
623 : constant( $constant );
624 $part = $spec['constant_pair'] ?? '';
625
626 if ( ! is_array( $value ) || ( 'user' !== $part && 'password' !== $part ) ) {
627 return $value;
628 }
629
630 // Only scalars: a nested array would stringify to "Array" plus a PHP
631 // warning, so the site would authenticate with that literal. Dropping
632 // non-scalars means a malformed define reads as absent rather than as
633 // a wrong credential.
634 $parts = array_values( array_filter( $value, 'is_scalar' ) );
635 if ( 'user' === $part ) {
636 // A one-element array is a password with no ACL user.
637 return count( $parts ) > 1 ? (string) $parts[0] : '';
638 }
639 return (string) ( count( $parts ) > 1 ? $parts[1] : ( $parts[0] ?? '' ) );
640 }
641
642 /**
643 * Where each of a module's settings actually came from.
644 *
645 * Returns one entry per schema field:
646 * [ 'source' => 'constant'|'db'|'default', 'constant' => ?string ]
647 *
648 * One map feeds every surface that has to be honest about provenance --
649 * the panel's read-only indicator, `wp xspeed objcache status`, and the
650 * refusal message when a write targets a pinned field -- so they cannot
651 * drift apart. (#398)
652 *
653 * @return array<string,array{source:string,constant:?string}>
654 */
655 /**
656 * Constant names xSpeed wrote itself, as opposed to ones the host defined.
657 *
658 * Ownership is decided by WHERE the define sits -- inside our fenced block
659 * in wp-config.php, or anywhere else -- never by its name. A user who
660 * pastes our snippet by hand ends up with `XSPEED_OC_*` defines that are
661 * theirs, not ours, and must not be silently rewritten.
662 *
663 * Filterable so a module that owns constants can answer for itself without
664 * this class having to know about it.
665 *
666 * @return string[]
667 */
668 /**
669 * The constant that BLOCKS writing this field, or null when it is writable.
670 *
671 * Same resolution as effective_constant(), minus the constants xSpeed
672 * wrote itself: those are this plugin's own storage, so a save rewrites
673 * them rather than being refused. Only a define somebody else put in
674 * wp-config.php makes a field read-only.
675 *
676 * Read paths keep using effective_constant() -- what the site RUNS on is
677 * the winning constant either way; this only answers "may the panel write
678 * here". (#398)
679 *
680 * @param array $spec Field spec from settings_schema().
681 */
682 public static function write_blocking_constant( string $slug, string $key, array $spec ): ?string {
683 $constant = self::effective_constant( $slug, $key, $spec );
684 if ( null === $constant ) {
685 return null;
686 }
687 return in_array( $constant, self::self_written_constants(), true ) ? null : $constant;
688 }
689
690 public static function self_written_constants(): array {
691 $names = array();
692 if ( class_exists( '\\XSpeed\\Object_Cache' ) ) {
693 $names = \XSpeed\Object_Cache::our_constants();
694 }
695
696 /**
697 * Filter: xspeed_self_written_constants
698 *
699 * @param string[] $names Constant names xSpeed wrote into wp-config.php.
700 */
701 return (array) apply_filters( 'xspeed_self_written_constants', $names );
702 }
703
704 public static function origins( string $slug ): array {
705 $module = Module_Registry::get( $slug );
706 if ( ! $module ) {
707 return array();
708 }
709 $stored = get_option( self::option_key( $slug ), array() );
710 if ( ! is_array( $stored ) ) {
711 $stored = array();
712 }
713
714 $origins = array();
715 $ours = self::self_written_constants();
716 foreach ( $module->settings_schema() as $key => $spec ) {
717 $constant = self::effective_constant( $slug, $key, $spec );
718 /*
719 * A constant WE wrote is not a lock. It is this module's own
720 * storage -- wp-config.php is simply where the drop-in can read it
721 * before WordPress loads -- so the field stays editable and saving
722 * rewrites it.
723 *
724 * Reporting it as 'constant' is what made enabling the object cache
725 * lock its own settings screen, tell the user seven fields were
726 * "set in wp-config.php" when they had never opened that file, and
727 * leave the take-it-back control unable to unlock anything. (#398)
728 */
729 if ( null !== $constant && in_array( $constant, $ours, true ) ) {
730 $origins[ $key ] = array(
731 'source' => 'db',
732 'constant' => null,
733 'overriding' => self::foreign_constant( $slug, $key ),
734 );
735 continue;
736 }
737 if ( null !== $constant ) {
738 $origins[ $key ] = array(
739 'source' => 'constant',
740 'constant' => $constant,
741 'overriding' => null,
742 );
743 continue;
744 }
745 // An overridden field reports its real source -- the option row --
746 // but still names the constant it is shadowing, so the panel can
747 // say WHAT is being overridden and offer to hand it back. Without
748 // this the row is indistinguishable from a field no constant ever
749 // touched. (#398)
750 $origins[ $key ] = array(
751 'source' => array_key_exists( $key, $stored ) ? 'db' : 'default',
752 'constant' => null,
753 'overriding' => self::foreign_constant( $slug, $key ),
754 );
755 }
756 return $origins;
757 }
758
759 /**
760 * Field keys this module cannot persist because a constant pins them.
761 *
762 * @return string[]
763 */
764 public static function locked_keys( string $slug ): array {
765 $module = Module_Registry::get( $slug );
766 if ( ! $module ) {
767 return array();
768 }
769 $locked = array();
770 foreach ( $module->settings_schema() as $key => $spec ) {
771 if ( null !== self::write_blocking_constant( $slug, $key, $spec ) ) {
772 $locked[] = $key;
773 }
774 }
775 return $locked;
776 }
777
778 /**
779 * The subset of an incoming patch that targets constant-pinned fields.
780 *
781 * Callers use this to fail loudly. Writing such a key would persist a row
782 * that get() will never read back -- the worst outcome for an automation,
783 * which reports success and changes nothing. (#398)
784 *
785 * Only a key whose submitted value DIFFERS from the resolved one counts. The
786 * dashboard saves a panel by posting every field it rendered, so a pinned
787 * field rides along in the patch on every save; treating that echo as an
788 * attempted write would 409 the whole request and make the panel
789 * unsaveable on exactly the host-provisioned site this feature exists for.
790 * An echo of the effective value asks for no change, so it is allowed
791 * through and dropped harmlessly by update(). (#398)
792 *
793 * @param array<string,mixed> $input Proposed settings patch.
794 * @return array<string,string> Field key => winning constant name.
795 */
796 public static function locked_in_input( string $slug, array $input ): array {
797 $module = Module_Registry::get( $slug );
798 if ( ! $module ) {
799 return array();
800 }
801 $resolved = self::get( $slug );
802 $public = self::get_public( $slug );
803 $hits = array();
804 foreach ( $module->settings_schema() as $key => $spec ) {
805 if ( ! array_key_exists( $key, $input ) ) {
806 continue;
807 }
808 $constant = self::write_blocking_constant( $slug, $key, $spec );
809 if ( null === $constant ) {
810 continue;
811 }
812
813 // Compare against the coerced form, so 6379 and "6379" from a JSON
814 // body agree, and against the MASKED form too: a secret's effective
815 // value never leaves the server, so the client can only ever echo
816 // the mask it was given.
817 [ $coerced ] = self::validate_field( $input[ $key ], $spec );
818 $submitted = $input[ $key ];
819 $echoes = ( $coerced === ( $resolved[ $key ] ?? null ) )
820 || ( is_string( $submitted ) && $submitted === ( $public[ $key ] ?? null ) )
821 || ( self::is_secret_field( $key, $spec ) && is_string( $submitted ) && self::is_masked_secret( $submitted ) );
822
823 if ( ! $echoes ) {
824 $hits[ $key ] = $constant;
825 }
826 }
827 return $hits;
828 }
829
830 /**
831 * Validate input against the module's schema, merge over stored values,
832 * and persist. Returns the final clean array. Unknown keys are stripped
833 * silently. Out-of-range / wrong-type values fall back to the previous
834 * stored value (or default).
835 */
836 public static function update( string $slug, array $input ): array {
837 $module = Module_Registry::get( $slug );
838 if ( ! $module ) {
839 return array();
840 }
841 $schema = $module->settings_schema();
842 $current = self::get( $slug );
843
844 // An MCP agent must not silently rewrite credentials — repointing the
845 // Cloudflare or object-cache backend at an attacker endpoint — unless the
846 // connection was explicitly granted the `configure` scope. Strip secret
847 // fields from an unprivileged MCP write here so every write path (the
848 // update_settings tool AND run_command → CLI) is covered at one choke
849 // point. The tool handler surfaces the refusal as a clear error. (#116)
850 if ( self::mcp_write_blocked() ) {
851 foreach ( $schema as $key => $spec ) {
852 if ( self::is_secret_field( $key, $spec ) ) {
853 unset( $input[ $key ] );
854 }
855 }
856 }
857
858 $clean = $current;
859 foreach ( $schema as $key => $spec ) {
860 if ( ! array_key_exists( $key, $input ) ) {
861 continue;
862 }
863 // A constant pins this field, so the write cannot take effect. Drop
864 // it rather than storing a row get() will never read. REST and CLI
865 // call locked_in_input() first and refuse the request outright with
866 // the constant's name; this is the choke-point backstop for any
867 // caller that reaches update() directly. (#398)
868 if ( null !== self::write_blocking_constant( $slug, $key, $spec ) ) {
869 continue;
870 }
871 // A secret field whose incoming value is the masked placeholder means
872 // the client is echoing back what get_public() sent, not setting a new
873 // credential — keep the stored value so an unrelated save on the same
874 // panel never wipes the key. An empty value is NOT a mask echo: it's a
875 // deliberate clear and flows through to remove the credential. (#115)
876 if ( self::is_secret_field( $key, $spec ) && self::is_masked_secret( (string) $input[ $key ] ) ) {
877 continue;
878 }
879 [ $value, $valid ] = self::validate_field( $input[ $key ], $spec );
880 if ( $valid ) {
881 $clean[ $key ] = $value;
882 }
883 // Invalid → keep $current[$key]. We do not throw; REST layer can
884 // add its own strict-mode validation that 400s on invalid input.
885 }
886
887 // Carry through out-of-schema keys the module explicitly preserves when
888 // they arrive in the INPUT — not only when already stored. Otherwise a
889 // caller that routes through update() to SET a preserved key (e.g. a
890 // migration/profile writing `mobile_separate_review`) has it silently
891 // stripped, because it isn't in $current yet. (FBS-83144)
892 foreach ( $module->preserved_keys() as $key ) {
893 if ( array_key_exists( $key, $input ) ) {
894 $clean[ $key ] = $input[ $key ];
895 }
896 }
897
898 // Change annotation (issue #45): every real mutation — from the UI,
899 // REST, CLI, or an MCP agent — lands in the activity log with the
900 // old→new diff and its source channel, so the dashboard can tell the
901 // causal story ("expiry raised → hit ratio climbed").
902 //
903 // $clean holds plaintext secrets (carried from $current, which get()
904 // decrypts, or freshly validated). Log + diff BEFORE encrypting, so the
905 // change annotation compares like-for-like (log_changes redacts secret
906 // values by key anyway). The encrypted copy is persisted below. (#115)
907 //
908 // Pass the FULL schema, not just its keys — log_changes() needs each
909 // field's `label` to write "Disable Dashicons on Frontend" instead of
910 // `disable_dashicons_frontend`. The schema was already in scope here
911 // and was simply being discarded. (#88)
912 self::log_changes( $slug, $current, $clean, $schema );
913
914 // Encrypt at rest ONLY fields explicitly typed `secret`. This must match
915 // coerce(), which decrypts only for `type === 'secret'` — encrypting a
916 // merely name-matched `string` field (a credential a module author typed
917 // as string) would store ciphertext that the string coercer then hands
918 // back verbatim, breaking the engine. Such fields are still masked and
919 // write-preserved via the broader is_secret_field() (masking a plaintext
920 // is always safe); they just aren't encrypted until retyped to `secret`.
921 $stored = $clean;
922 foreach ( $schema as $key => $spec ) {
923 // A constant-pinned field never enters the option row. $clean carries
924 // its resolved value so the engine and the activity diff see the real
925 // config, but persisting it would copy a wp-config credential into the
926 // database -- exactly what sourcing it from a constant avoids -- and
927 // leave a stale row behind if the constant later changes. (#398)
928 if ( null !== self::write_blocking_constant( $slug, $key, $spec ) ) {
929 unset( $stored[ $key ] );
930 continue;
931 }
932 if ( 'secret' === ( $spec['type'] ?? '' ) ) {
933 $stored[ $key ] = self::encrypt_for_storage( (string) ( $stored[ $key ] ?? '' ) );
934 }
935 }
936 $stored['_version'] = $module->version();
937 update_option( self::option_key( $slug ), $stored );
938
939 /**
940 * Settings for this module were just saved.
941 *
942 * A module whose runtime reads the config before WordPress loads --
943 * the object-cache drop-in does -- mirrors the saved values into its
944 * own store HERE, so the panel, the CLI and the runtime cannot
945 * disagree. Without this a save reported success while the drop-in
946 * kept using the previous value. (#398)
947 *
948 * @param string $slug Module slug.
949 * @param array<string,mixed> $clean The settings as persisted.
950 */
951 do_action( 'xspeed_settings_saved', $slug, $clean );
952
953 // An override is a single edit, not a new permanent home for the value.
954 // The admin unlocked the field, typed, and saved; the value now belongs
955 // in wp-config.php beside the one it displaced, and the field goes back
956 // to being locked -- reading OUR constant instead of the host's.
957 //
958 // Lifting it here rather than leaving it standing is what keeps the
959 // model honest. A standing override means the option row is the source
960 // of truth for that field, and a row cannot be read by the drop-in,
961 // which loads before WordPress. Promote to a constant and the panel,
962 // the CLI and the runtime agree again. (#398)
963 $settled = array();
964 foreach ( array_keys( $input ) as $key ) {
965 if ( isset( $schema[ $key ] ) && self::is_overridden( $slug, $key ) ) {
966 $settled[ $key ] = $clean[ $key ] ?? null;
967 }
968 }
969 if ( ! empty( $settled ) ) {
970 /**
971 * Overridden fields were just saved and are about to re-lock.
972 *
973 * A module that owns constants in wp-config.php writes them HERE,
974 * while the values are still to hand -- once the override lifts,
975 * get() reads the displaced constant again and the typed value is
976 * gone. Passed explicitly for that reason rather than left to be
977 * re-read. (#398)
978 *
979 * @param string $slug Module slug.
980 * @param array<string,mixed> $values Field key => value just saved.
981 */
982 do_action( 'xspeed_settings_promote_to_config', $slug, $settled );
983
984 // Flagged so an override_changed listener can tell this lift --
985 // which promotes the value into our own block -- from a genuine
986 // hand-back to the host. See self::$promoting.
987 self::$promoting[ $slug ] = true;
988 try {
989 foreach ( array_keys( $settled ) as $key ) {
990 self::set_override( $slug, $key, false );
991 }
992 } finally {
993 unset( self::$promoting[ $slug ] );
994 }
995
996 // Keep the saved values in the option row for THIS request. The
997 // constant the module just wrote is in wp-config.php but not
998 // defined in the running process -- constants are read at boot --
999 // so get() would resolve back to the displaced value and hand the
1000 // caller a response showing the change had not happened. The row is
1001 // inert from the next request on, when the new constant loads and
1002 // outranks it. (#398)
1003 $row = get_option( self::option_key( $slug ), array() );
1004 if ( is_array( $row ) ) {
1005 $carry = $settled;
1006 foreach ( array_keys( $carry ) as $key ) {
1007 // Same encryption the main persist path applies -- this row
1008 // is short-lived but it is still the options table.
1009 if ( 'secret' === ( $schema[ $key ]['type'] ?? '' ) ) {
1010 $carry[ $key ] = self::encrypt_for_storage( (string) $carry[ $key ] );
1011 }
1012 }
1013 update_option( self::option_key( $slug ), array_merge( $row, $carry ) );
1014 }
1015 }
1016
1017 // Return the PUBLIC view: real non-secret values, masked secrets. This
1018 // is the REST/CLI/MCP response, so it must never carry credentials. (#115)
1019 return self::get_public( $slug );
1020 }
1021
1022 /**
1023 * The public, safe-to-serialize view of a module's settings: identical to
1024 * get() except every secret field is replaced by a masked hint (first/last
1025 * few chars, never the middle). This is what the REST GET handler, the MCP
1026 * read tools, and the dashboard bootstrap payload return — get() itself
1027 * stays plaintext for the engine.
1028 *
1029 * @return array<string,mixed>
1030 */
1031 /**
1032 * Settings as stored, with schema defaults filled in and constants ignored.
1033 *
1034 * The read-back companion to get(): same shape, but it never lets a
1035 * constant outrank the option row. See get_public()'s `$stored_only`.
1036 *
1037 * Public because any caller that has WRITTEN in this request needs it: our
1038 * block has been rewritten but PHP cannot redefine the constants already
1039 * loaded, so the resolved read still returns the pre-save value. The object
1040 * cache's write probe rechecks against this for exactly that reason. (#398)
1041 *
1042 * @return array<string,mixed>
1043 */
1044 public static function stored_with_defaults( string $slug ): array {
1045 $module = Module_Registry::get( $slug );
1046 if ( ! $module ) {
1047 return array();
1048 }
1049 $stored = get_option( self::option_key( $slug ), array() );
1050 if ( ! is_array( $stored ) ) {
1051 $stored = array();
1052 }
1053 $out = array();
1054 foreach ( $module->settings_schema() as $key => $spec ) {
1055 if ( array_key_exists( $key, $stored ) ) {
1056 $out[ $key ] = self::coerce( $stored[ $key ], $spec );
1057 continue;
1058 }
1059 /*
1060 * Not in the row. For a field a HOST constant pins that is the
1061 * normal state -- update() strips those before writing -- and the
1062 * schema default would be a lie (an empty password for a field the
1063 * site authenticates with). Report the effective value there.
1064 */
1065 $constant = self::effective_constant( $slug, $key, $spec );
1066 $out[ $key ] = null !== $constant
1067 ? self::constant_value( $key, $constant, $spec )
1068 : ( $spec['default'] ?? null );
1069 }
1070
1071 // Same carry-through get() does, so a caller asking for the stored
1072 // state does not silently lose a module's out-of-schema keys.
1073 foreach ( $module->preserved_keys() as $key ) {
1074 if ( array_key_exists( $key, $stored ) ) {
1075 $out[ $key ] = $stored[ $key ];
1076 }
1077 }
1078 return $out;
1079 }
1080
1081 public static function get_public( string $slug, bool $stored_only = false ): array {
1082 $module = Module_Registry::get( $slug );
1083 if ( ! $module ) {
1084 return array();
1085 }
1086 /*
1087 * `$stored_only` answers "what did we just persist", not "what is the
1088 * site running on". A caller that has written in THIS request needs it:
1089 * the save also rewrites our wp-config block, but the constants for
1090 * this request are already defined and PHP cannot redefine them, so the
1091 * normal read would resolve the pre-write constant and report the save
1092 * as a no-op. (#398)
1093 */
1094 $settings = $stored_only ? self::stored_with_defaults( $slug ) : self::get( $slug );
1095 foreach ( $module->settings_schema() as $key => $spec ) {
1096 if ( self::is_secret_field( $key, $spec ) && array_key_exists( $key, $settings ) ) {
1097 $settings[ $key ] = self::mask_secret_value( (string) $settings[ $key ] );
1098 }
1099 }
1100
1101 /*
1102 * Preserved keys are carried through by `get()` and have no schema, so
1103 * this loop never saw them — a module preserving a credential would
1104 * print it here in full. The MCP module preserves its pairing token,
1105 * which is what made that concrete.
1106 *
1107 * Matched by NAME, since a key with no spec has no declared type. A
1108 * scalar only: `mask_secret_value` takes a string, and a preserved key
1109 * can hold an array (the REST-cache `rules`), which is not a secret
1110 * and must not be flattened into one.
1111 */
1112 foreach ( $module->preserved_keys() as $key ) {
1113 if ( array_key_exists( $key, $settings ) && is_scalar( $settings[ $key ] ) && self::is_secret_key( $key ) ) {
1114 $settings[ $key ] = self::mask_secret_value( (string) $settings[ $key ] );
1115 }
1116 }
1117
1118 return $settings;
1119 }
1120
1121 /**
1122 * Record changed schema fields as one activity event. No-op when
1123 * nothing actually changed (idempotent re-saves stay silent).
1124 *
1125 * @param string $slug Module slug.
1126 * @param array $before Settings before the write.
1127 * @param array $after Settings after the write.
1128 * @param array $schema Full settings schema — used for each field's label.
1129 */
1130 private static function log_changes( string $slug, array $before, array $after, array $schema ): void {
1131 if ( ! class_exists( '\\XSpeed\\Activity_Log' ) ) {
1132 return;
1133 }
1134 $diffs = array();
1135 foreach ( array_keys( $schema ) as $key ) {
1136 $old = $before[ $key ] ?? null;
1137 $new = $after[ $key ] ?? null;
1138 if ( $old === $new ) {
1139 continue;
1140 }
1141
1142 // The schema already declares a human label for every field — the
1143 // same one rendered a few inches away on the settings screen. The
1144 // feed used the raw storage key instead, so users read
1145 // `disable_dashicons_frontend` rather than "Disable Dashicons on
1146 // Frontend". Fall back to the key when a schema has no label, so
1147 // an entry is never blank. (#88)
1148 $label = isset( $schema[ $key ]['label'] ) && is_string( $schema[ $key ]['label'] ) && '' !== $schema[ $key ]['label']
1149 ? $schema[ $key ]['label']
1150 : $key;
1151
1152 if ( self::is_redacted_key( $key ) ) {
1153 // Never record the value itself — the annotation is served to
1154 // the dashboard by the trend endpoints, so anything written
1155 // here is readable by any user who can load the dashboard.
1156 $diffs[] = sprintf( '%s changed', $label );
1157 continue;
1158 }
1159 $diffs[] = sprintf( '%s %s→%s', $label, self::describe_value( $old ), self::describe_value( $new ) );
1160 }
1161 if ( empty( $diffs ) ) {
1162 return;
1163 }
1164 Activity_Log::record(
1165 'settings_changed',
1166 sprintf( '%s: %s (via %s)', self::module_label( $slug ), implode( ', ', array_slice( $diffs, 0, 5 ) ), self::source_channel() )
1167 );
1168 }
1169
1170 /**
1171 * A module's display name for the activity feed, e.g. `gzip` →
1172 * "Compression".
1173 *
1174 * Resolved through the module registry rather than a lookup table here,
1175 * so Pro modules (feed-cache, search-cache, …) get their labels from the
1176 * same path — Pro persists through this class and contributes no logging
1177 * code of its own.
1178 *
1179 * Falls back to the raw slug when the module isn't registered or declares
1180 * no label; an entry is never blank.
1181 */
1182 private static function module_label( string $slug ): string {
1183 if ( ! class_exists( '\\XSpeed\\Module_Registry' ) ) {
1184 return $slug;
1185 }
1186 $module = Module_Registry::get( $slug );
1187 if ( ! $module ) {
1188 return $slug;
1189 }
1190 $meta = $module->ui_metadata();
1191 return ( isset( $meta['label'] ) && is_string( $meta['label'] ) && '' !== $meta['label'] )
1192 ? $meta['label']
1193 : $slug;
1194 }
1195
1196 /**
1197 * Setting keys whose VALUE must never reach the activity log. The log is
1198 * surfaced by the dashboard trend endpoints, so anything recorded here is
1199 * readable by any user who can load the dashboard.
1200 *
1201 * Matched on the key name rather than the value, because a credential is
1202 * indistinguishable from an ordinary string once it's been stringified.
1203 * Pure — unit-tested.
1204 *
1205 * @param string $key Schema key, e.g. 'api_token'.
1206 */
1207 public static function is_secret_key( string $key ): bool {
1208 // `license_key` is matched explicitly: the pattern requires `api_key`
1209 // rather than a bare `key` so that `key_prefix` (an ordinary,
1210 // useful-to-see setting) isn't swallowed, which left a real license
1211 // key printing in plaintext.
1212 return 1 === preg_match( '/(token|password|secret|api_key|license_key|passwd|private_key|credential)/i', $key );
1213 }
1214
1215 /**
1216 * Setting keys whose value is withheld from the activity feed.
1217 *
1218 * Secrets (above) plus infrastructure IDENTIFIERS. `redis_password` was
1219 * correctly redacted while `redis_user`, `redis_host` and `key_prefix`
1220 * were written out in full — and the feed is served to any user who can
1221 * load the dashboard, not just admins (see the trend endpoints).
1222 *
1223 * A Redis hostname and username are most of a credential, and they
1224 * describe internal infrastructure that has no business being readable by
1225 * a subscriber. The feed's job — "this setting changed, when, and by
1226 * whom" — is served without printing the value. (#88)
1227 *
1228 * Deliberately matched on the key NAME: once stringified, a hostname is
1229 * indistinguishable from any other short string. Pure — unit-tested.
1230 *
1231 * @param string $key Schema key, e.g. 'redis_host'.
1232 */
1233 public static function is_redacted_key( string $key ): bool {
1234 if ( self::is_secret_key( $key ) ) {
1235 return true;
1236 }
1237
1238 // Deliberately an explicit list rather than a broad word match. A
1239 // pattern like /(host|user|prefix|port)/ also swallows
1240 // `bypass_user_agents`, `preconnect_hosts` and `excluded_urls` —
1241 // ordinary user-facing settings whose values are exactly what makes
1242 // the feed useful. Over-redacting is a quieter failure than leaking,
1243 // but it is still a failure.
1244 //
1245 // Scoped to connection details and account identifiers. A new backend
1246 // or provider setting must be added here consciously — see the
1247 // schema-coverage test that walks every registered module and fails
1248 // on an unreviewed key.
1249 $identifiers = array(
1250 // Object-cache backends.
1251 'redis_host',
1252 'redis_port',
1253 'redis_user',
1254 'redis_socket',
1255 'redis_database',
1256 'memcached_host',
1257 'memcached_port',
1258 'memcached_user',
1259 'key_prefix',
1260 // Cloudflare. The same reasoning that withholds redis_user /
1261 // redis_host applies at least as strongly here: an account email
1262 // plus a full Zone ID together identify the account and the exact
1263 // zone. api_token / api_key are already covered by
1264 // is_secret_key(); these two were the gap.
1265 'email',
1266 'zone_id',
1267 );
1268
1269 /**
1270 * Setting keys whose value is withheld from the activity feed.
1271 *
1272 * @param string[] $identifiers Keys to redact, on top of is_secret_key().
1273 */
1274 $identifiers = (array) apply_filters( 'xspeed_activity_redacted_keys', $identifiers );
1275
1276 return in_array( strtolower( $key ), array_map( 'strtolower', $identifiers ), true );
1277 }
1278
1279 /**
1280 * Whether a schema field holds credential material. A field is secret when
1281 * it declares `type => 'secret'` (the explicit, preferred marker) OR its key
1282 * name matches the credential pattern (is_secret_key) — the backstop that
1283 * catches a credential a module author forgot to type, so a leak can't open
1284 * just because a field was declared `string`.
1285 *
1286 * @param string $key Schema field key.
1287 * @param array $spec Field spec from settings_schema().
1288 */
1289 public static function is_secret_field( string $key, array $spec ): bool {
1290 return ( ( $spec['type'] ?? '' ) === 'secret' ) || self::is_secret_key( $key );
1291 }
1292
1293 /**
1294 * The subset of $input keys that are secret fields for this module's schema.
1295 * Used by the MCP update_settings tool to name exactly which fields it
1296 * refused. Returns [] for an unknown module.
1297 *
1298 * @param string $slug Module slug.
1299 * @param array<string,mixed> $input Proposed settings patch.
1300 * @return string[]
1301 */
1302 public static function secret_keys_in( string $slug, array $input ): array {
1303 $module = Module_Registry::get( $slug );
1304 if ( ! $module ) {
1305 return array();
1306 }
1307 $schema = $module->settings_schema();
1308 $out = array();
1309 foreach ( $input as $key => $value ) {
1310 if ( isset( $schema[ $key ] ) && self::is_secret_field( $key, $schema[ $key ] ) ) {
1311 $out[] = $key;
1312 }
1313 }
1314 return $out;
1315 }
1316
1317 /**
1318 * Classify an input payload against a module's schema WITHOUT writing
1319 * anything: which keys would be applied, which are unknown, and which are
1320 * in-schema but carry a value the validator rejects.
1321 *
1322 * update() walks the SCHEMA rather than the input, so a key with no schema
1323 * entry is never iterated — never written, never mentioned. And an
1324 * in-schema key whose value fails validation is dropped deliberately
1325 * ("REST layer can add its own strict-mode validation"), which CLI and MCP
1326 * never traverse. Both therefore reported success over a write that did
1327 * not happen; the realistic case is an agent sending
1328 * `cache_enabled` to the `cache` module — a no-op reported as done. (#206)
1329 *
1330 * Pure: no side effects, so callers can decide to refuse BEFORE writing.
1331 * update()'s own signature is deliberately unchanged — a dozen callers
1332 * depend on it returning the settings array.
1333 *
1334 * @param string $slug Module slug.
1335 * @param array<string,mixed> $input Proposed values.
1336 * @return array{applied:string[],unknown:string[],invalid:string[]}
1337 */
1338 public static function inspect_input( string $slug, array $input ): array {
1339 $out = array(
1340 'applied' => array(),
1341 'unknown' => array(),
1342 'invalid' => array(),
1343 // Keys a wp-config.php constant pins, so update() will drop them.
1344 // Reported here rather than only in the REST/CLI guards, because
1345 // every other caller -- the MCP update_settings tool above all --
1346 // reaches update() directly and would otherwise report a success
1347 // over a write that changed nothing. (#398)
1348 'locked' => array(),
1349 );
1350
1351 $module = Module_Registry::get( $slug );
1352 if ( ! $module ) {
1353 // Unknown module: the caller reports that separately, and every key
1354 // is by definition unapplied.
1355 $out['unknown'] = array_keys( $input );
1356 return $out;
1357 }
1358
1359 $schema = $module->settings_schema();
1360 $preserved = $module->preserved_keys();
1361
1362 foreach ( $input as $key => $value ) {
1363 // Out-of-schema keys a module explicitly preserves are written
1364 // verbatim by update(), so they count as applied, not unknown.
1365 if ( in_array( $key, $preserved, true ) ) {
1366 $out['applied'][] = $key;
1367 continue;
1368 }
1369 if ( ! isset( $schema[ $key ] ) ) {
1370 $out['unknown'][] = $key;
1371 continue;
1372 }
1373 $spec = $schema[ $key ];
1374 // A masked secret echo is a deliberate "keep what's stored", not a
1375 // failed write — update() skips it by design, so don't report it.
1376 if ( self::is_secret_field( $key, $spec ) && self::is_masked_secret( (string) $value ) ) {
1377 $out['applied'][] = $key;
1378 continue;
1379 }
1380 [ $coerced, $valid ] = self::validate_field( $value, $spec );
1381 // Pinned by a constant. An echo of the value already in effect asks
1382 // for no change, so it is not reported -- only a genuine attempt to
1383 // set something different.
1384 if ( null !== self::write_blocking_constant( $slug, $key, $spec ) ) {
1385 $resolved = self::get( $slug );
1386 if ( $coerced !== ( $resolved[ $key ] ?? null ) ) {
1387 $out['locked'][] = $key;
1388 continue;
1389 }
1390 $out['applied'][] = $key;
1391 continue;
1392 }
1393 if ( $valid ) {
1394 $out['applied'][] = $key;
1395 } else {
1396 $out['invalid'][] = $key;
1397 }
1398 }
1399
1400 return $out;
1401 }
1402
1403 /**
1404 * Where a key the caller asked for actually lives, when it isn't in the
1405 * module's schema. Turns "unknown key" into a pointer.
1406 *
1407 * `cache_enabled` is the case worth naming: it is deliberately outside the
1408 * cache module's schema because it drives the drop-in install, so the most
1409 * natural command an agent issues to turn caching on is a silent no-op.
1410 * (#206)
1411 *
1412 * @param string $key Rejected input key.
1413 * @return string Human-readable hint, or '' when there's nothing useful.
1414 */
1415 public static function hint_for_unknown_key( string $key ): string {
1416 $hints = array(
1417 '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.',
1418 'gzip_enabled' => 'this moved to the `gzip` module — try `--values=\'{"enabled":true}\'` against module `gzip`.',
1419 );
1420 return $hints[ $key ] ?? '';
1421 }
1422
1423 /**
1424 * Schema keys closest to a rejected key, so a typo gets a pointer rather
1425 * than a bare refusal. Levenshtein over the schema, nearest three. (#206)
1426 *
1427 * @param string $slug Module slug.
1428 * @param string $key Rejected input key.
1429 * @return string[] Suggested key names, nearest first.
1430 */
1431 public static function did_you_mean( string $slug, string $key ): array {
1432 $module = Module_Registry::get( $slug );
1433 if ( ! $module ) {
1434 return array();
1435 }
1436 $scored = array();
1437 foreach ( array_keys( $module->settings_schema() ) as $candidate ) {
1438 $distance = levenshtein( $key, (string) $candidate );
1439 // Only near-misses: beyond a third of the key's length it's a
1440 // different word, and listing it would be noise.
1441 if ( $distance <= max( 3, (int) floor( strlen( $key ) / 3 ) ) ) {
1442 $scored[ (string) $candidate ] = $distance;
1443 }
1444 }
1445 asort( $scored );
1446 return array_slice( array_keys( $scored ), 0, 3 );
1447 }
1448
1449 /**
1450 * Masked hint for a stored secret: first 4 + bullets + last 4 (mirrors the
1451 * support-snapshot license masking), or all-bullets for a short secret, or
1452 * '' when unset. Enough to confirm "a key is saved, ending 4f2a" without
1453 * disclosing it. Deterministic — unit-tested.
1454 */
1455 public static function mask_secret_value( string $value ): string {
1456 if ( '' === $value ) {
1457 return '';
1458 }
1459 if ( strlen( $value ) <= 8 ) {
1460 return str_repeat( '•', 8 );
1461 }
1462 return substr( $value, 0, 4 ) . self::SECRET_MASK_BULLETS . substr( $value, -4 );
1463 }
1464
1465 /**
1466 * Whether an incoming write value is the masked placeholder the client is
1467 * echoing back, rather than a real new secret — i.e. it still carries the
1468 * mask bullets. A genuine credential never contains the bullet run, so this
1469 * can't swallow a real key. update() uses it to keep the stored secret.
1470 *
1471 * An EMPTY string is NOT a mask echo — it's a deliberate clear, so it flows
1472 * through to storage and removes the credential. The dashboard always
1473 * re-sends the masked hint (with bullets) on an unrelated save, never an
1474 * empty string, so this still can't wipe a key by accident. (#115, QA B7)
1475 */
1476 public static function is_masked_secret( string $value ): bool {
1477 return false !== strpos( $value, self::SECRET_MASK_BULLETS );
1478 }
1479
1480 /**
1481 * Encrypt a plaintext secret for storage. Idempotent: an already-encrypted
1482 * value (carrying the marker) is returned unchanged, so module migrations
1483 * can call this over existing rows without double-wrapping. Empty stays
1484 * empty. Used by update() and by the per-module encrypt-on-upgrade
1485 * migrations. (#115)
1486 */
1487 public static function encrypt_for_storage( string $value ): string {
1488 if ( '' === $value || 0 === strpos( $value, self::SECRET_CIPHER_PREFIX ) ) {
1489 return $value;
1490 }
1491 return self::encrypt( $value );
1492 }
1493
1494 /**
1495 * 32-byte encryption key derived from this site's WordPress salts, so the
1496 * ciphertext is bound to the install and never stored alongside the data.
1497 * Rotating the salts makes existing secrets undecryptable — decrypt() then
1498 * returns '' (treated as "unset", the user re-enters the key) rather than
1499 * fataling. Uses AUTH_KEY + SECURE_AUTH_SALT, falling back to wp_salt().
1500 */
1501 private static function secret_key(): string {
1502 $material = '';
1503 if ( defined( 'AUTH_KEY' ) ) {
1504 $material .= (string) AUTH_KEY;
1505 }
1506 if ( defined( 'SECURE_AUTH_SALT' ) ) {
1507 $material .= (string) SECURE_AUTH_SALT;
1508 }
1509 if ( '' === $material && function_exists( 'wp_salt' ) ) {
1510 $material = (string) wp_salt( 'secure_auth' );
1511 }
1512 return sodium_crypto_generichash( 'xspeed-secret-v1|' . $material, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
1513 }
1514
1515 /**
1516 * Authenticated-encrypt a non-empty plaintext with libsodium's secretbox
1517 * (XSalsa20-Poly1305). The random nonce is prepended to the ciphertext and
1518 * the whole thing base64'd behind the version marker. libsodium ships in
1519 * PHP core from 7.2 (our floor is 7.4); if it were somehow unavailable we
1520 * store plaintext rather than fatal — masking on read still applies.
1521 */
1522 private static function encrypt( string $plain ): string {
1523 if ( ! function_exists( 'sodium_crypto_secretbox' ) ) {
1524 return $plain;
1525 }
1526 try {
1527 $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
1528 $cipher = sodium_crypto_secretbox( $plain, $nonce, self::secret_key() );
1529 } catch ( \Throwable $e ) {
1530 return $plain;
1531 }
1532 return self::SECRET_CIPHER_PREFIX . base64_encode( $nonce . $cipher ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- transport encoding for ciphertext, not obfuscation.
1533 }
1534
1535 /**
1536 * Reverse encrypt(). A value without the marker is legacy plaintext (or
1537 * empty) and is returned as-is — the encryption rollout is lazy, so reads
1538 * keep working before the first re-save. A marked value that fails to
1539 * decrypt (salts rotated, row tampered) returns '' so the caller behaves as
1540 * "no credential set", never a fatal.
1541 */
1542 private static function decrypt( string $stored ): string {
1543 if ( 0 !== strpos( $stored, self::SECRET_CIPHER_PREFIX ) ) {
1544 return $stored;
1545 }
1546 if ( ! function_exists( 'sodium_crypto_secretbox_open' ) ) {
1547 return '';
1548 }
1549 $raw = base64_decode( substr( $stored, strlen( self::SECRET_CIPHER_PREFIX ) ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding our own ciphertext envelope.
1550 if ( false === $raw || strlen( $raw ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) {
1551 return '';
1552 }
1553 $nonce = substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
1554 $cipher = substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
1555 try {
1556 $plain = sodium_crypto_secretbox_open( $cipher, $nonce, self::secret_key() );
1557 } catch ( \Throwable $e ) {
1558 return '';
1559 }
1560 return ( false === $plain ) ? '' : $plain;
1561 }
1562
1563 /**
1564 * Whether the current write is an MCP write that may NOT touch secret
1565 * fields — i.e. it came in over MCP and the connection lacks the `configure`
1566 * grant. Keeps credential writes off the default MCP surface (#116). Guarded
1567 * by class_exists so Settings_Manager never hard-depends on the MCP module.
1568 */
1569 private static function mcp_write_blocked(): bool {
1570 if ( ! class_exists( '\\XSpeed\\Modules\\Mcp\\Mcp_Tools' ) ) {
1571 return false;
1572 }
1573 return \XSpeed\Modules\Mcp\Mcp_Tools::in_dispatch()
1574 && ! \XSpeed\Modules\Mcp\Mcp_Tools::can_configure();
1575 }
1576
1577 /** Compact human form of a setting value for the change log. */
1578 private static function describe_value( $value ): string {
1579 if ( is_bool( $value ) ) {
1580 return $value ? 'on' : 'off';
1581 }
1582 if ( is_array( $value ) ) {
1583 return count( $value ) . ' item' . ( 1 === count( $value ) ? '' : 's' );
1584 }
1585 if ( null === $value ) {
1586 return '—';
1587 }
1588 $str = (string) $value;
1589 return strlen( $str ) > 40 ? substr( $str, 0, 39 ) . '…' : $str;
1590 }
1591
1592 /**
1593 * Which surface performed this write. MCP is detected via the tool
1594 * dispatcher's in-flight flag; the dashboard UI writes through REST.
1595 */
1596 private static function source_channel(): string {
1597 if ( class_exists( '\\XSpeed\\Modules\\Mcp\\Mcp_Tools' ) && \XSpeed\Modules\Mcp\Mcp_Tools::in_dispatch() ) {
1598 return 'mcp';
1599 }
1600 if ( defined( 'WP_CLI' ) && WP_CLI ) {
1601 return 'cli';
1602 }
1603 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
1604 return 'dashboard';
1605 }
1606 return 'admin';
1607 }
1608
1609 /**
1610 * Run any pending schema migrations for a module. Called by
1611 * Module_Registry before boot(). Idempotent — migrations only run once
1612 * per version bump because we persist `_version` after each successful
1613 * migration step.
1614 */
1615 public static function run_migrations( Module $module ): void {
1616 $migrations = $module->migrations();
1617 if ( empty( $migrations ) ) {
1618 return;
1619 }
1620 $option_key = self::option_key( $module->slug() );
1621 $stored = get_option( $option_key, null );
1622 if ( null === $stored ) {
1623 return; // fresh install — no data to migrate.
1624 }
1625 if ( ! is_array( $stored ) ) {
1626 $stored = array();
1627 }
1628 $from = isset( $stored['_version'] ) ? (string) $stored['_version'] : '0.0.0';
1629
1630 // Sort migrations by version ascending.
1631 uksort(
1632 $migrations,
1633 static function ( $a, $b ) {
1634 return version_compare( (string) $a, (string) $b );
1635 }
1636 );
1637
1638 $dirty = false;
1639 foreach ( $migrations as $target => $callable ) {
1640 $target = (string) $target;
1641 if ( version_compare( $from, $target, '>=' ) ) {
1642 continue;
1643 }
1644 $migrated = call_user_func( $callable, $stored );
1645 if ( is_array( $migrated ) ) {
1646 $stored = $migrated;
1647 $stored['_version'] = $target;
1648 $from = $target;
1649 $dirty = true;
1650 }
1651 }
1652
1653 if ( $dirty ) {
1654 update_option( $option_key, $stored );
1655 }
1656 }
1657
1658 /**
1659 * Coerce a stored value to the schema's declared type — used on read
1660 * to defend against options edited by hand or imported across versions.
1661 */
1662 private static function coerce( $value, array $spec ) {
1663 $type = $spec['type'] ?? 'string';
1664 switch ( $type ) {
1665 case 'bool':
1666 return (bool) $value;
1667 case 'int':
1668 $v = (int) $value;
1669 if ( isset( $spec['min'] ) ) {
1670 $v = max( (int) $spec['min'], $v );
1671 }
1672 if ( isset( $spec['max'] ) ) {
1673 $v = min( (int) $spec['max'], $v );
1674 }
1675 return $v;
1676 case 'enum':
1677 return in_array( $value, $spec['options'] ?? array(), true )
1678 ? $value
1679 : ( $spec['default'] ?? null );
1680 case 'list':
1681 if ( ! is_array( $value ) ) {
1682 return $spec['default'] ?? array();
1683 }
1684 return array_values( array_filter( $value, 'is_scalar' ) );
1685 case 'url':
1686 // A deliberately-cleared URL must read back as empty, not snap
1687 // to the schema default — `?:` swallowed the empty string and
1688 // resurrected the default on every read. (#197)
1689 if ( '' === trim( (string) $value ) ) {
1690 return '';
1691 }
1692 $url = esc_url_raw( (string) $value );
1693 if ( ! empty( $spec['endpoint'] ) && ! self::is_endpoint_url( $url ) ) {
1694 // A hand-edited or pre-validation stored value that can't
1695 // be called reads back as empty, so consumers see "no
1696 // endpoint configured" instead of silently failing on it.
1697 return $spec['default'] ?? '';
1698 }
1699 return $url ?: ( $spec['default'] ?? '' );
1700 case 'media':
1701 // Media-library image URL. Empty is a valid "no image" state.
1702 // esc_url_raw alone lets through any safe URL (…/evil.txt,
1703 // non-images) which then renders as a broken <img>; require it
1704 // to look like an image and drop anything else to empty.
1705 $media = esc_url_raw( (string) $value );
1706 return ( '' === $media || self::is_image_url( $media ) ) ? $media : '';
1707 case 'secret':
1708 // A credential (API token, password, …). Stored encrypted at
1709 // rest (SECRET_CIPHER_PREFIX). Reading decrypts to plaintext so
1710 // the engine — Cloudflare purge, Redis auth — gets the real
1711 // value; the masking that keeps it out of REST/MCP/dashboard
1712 // payloads happens later, at the output boundary (get_public),
1713 // never here. Legacy unencrypted values pass straight through.
1714 return self::decrypt( (string) $value );
1715 case 'string':
1716 default:
1717 return sanitize_text_field( (string) $value );
1718 }
1719 }
1720
1721 /**
1722 * Validate one field; returns [ coerced_value, was_valid ]. Distinct
1723 * from coerce() because validate is strict (out-of-range int is
1724 * INVALID) while coerce is forgiving (clamps to range).
1725 */
1726 private static function validate_field( $value, array $spec ): array {
1727 $type = $spec['type'] ?? 'string';
1728 switch ( $type ) {
1729 case 'bool':
1730 // Strictly validate (don't blindly (bool)-cast). A plain cast
1731 // treated every non-empty string as true, so a client sending
1732 // the string "false" (or any junk text) silently ENABLED the
1733 // toggle. filter_var with FILTER_NULL_ON_FAILURE accepts the
1734 // real bool-ish forms (true/false, 1/0, "1"/"0", "true"/
1735 // "false", "yes"/"no", "on"/"off") and returns null for
1736 // anything else — which we report as invalid so the previous
1737 // stored value is kept, mirroring int/enum. (FBS-82158)
1738 $b = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
1739 if ( null === $b ) {
1740 return array( null, false );
1741 }
1742 return array( $b, true );
1743 case 'int':
1744 if ( ! is_numeric( $value ) ) {
1745 return array( null, false );
1746 }
1747 $v = (int) $value;
1748 if ( isset( $spec['min'] ) && $v < (int) $spec['min'] ) {
1749 return array( null, false );
1750 }
1751 if ( isset( $spec['max'] ) && $v > (int) $spec['max'] ) {
1752 return array( null, false );
1753 }
1754 return array( $v, true );
1755 case 'enum':
1756 $ok = in_array( $value, $spec['options'] ?? array(), true );
1757 return array( $ok ? $value : null, $ok );
1758 case 'list':
1759 if ( ! is_array( $value ) ) {
1760 return array( null, false );
1761 }
1762 $item_type = $spec['item_type'] ?? 'string';
1763 $out = array();
1764 foreach ( $value as $item ) {
1765 // Skip non-scalar items (e.g. a nested array). Casting one
1766 // with (string) emits an "Array to string conversion"
1767 // warning and stores the garbage literal "Array" — coerce()
1768 // already filters these via is_scalar; mirror it here.
1769 // (FBS-82172 Bug 4)
1770 if ( ! is_scalar( $item ) ) {
1771 continue;
1772 }
1773 if ( 'url' === $item_type ) {
1774 $u = esc_url_raw( (string) $item );
1775 if ( $u ) {
1776 $out[] = $u;
1777 }
1778 } elseif ( 'path' === $item_type ) {
1779 $out[] = self::sanitize_path_pattern( (string) $item );
1780 } else {
1781 $out[] = sanitize_text_field( (string) $item );
1782 }
1783 }
1784 return array( $out, true );
1785 case 'url':
1786 // Empty is a valid "cleared" state, not invalid input — same
1787 // as `media` below. Reporting it invalid made the previous
1788 // stored value stick, so clearing a URL field appeared to
1789 // "come back" a moment later when the save echo landed. (#197)
1790 if ( '' === trim( (string) $value ) ) {
1791 return array( '', true );
1792 }
1793 $u = esc_url_raw( (string) $value );
1794 if ( ! empty( $spec['endpoint'] ) && ! self::is_endpoint_url( $u ) ) {
1795 return array( null, false );
1796 }
1797 return array( $u, (bool) $u );
1798 case 'media':
1799 // Empty (cleared logo) is valid; any non-empty value must be a
1800 // safe URL after esc_url_raw AND look like an image, so a
1801 // non-image URL (…/evil.txt) is rejected rather than stored to
1802 // render as a broken <img>.
1803 $m = esc_url_raw( (string) $value );
1804 if ( '' === (string) $value ) {
1805 return array( '', true );
1806 }
1807 $ok = '' !== $m && self::is_image_url( $m );
1808 return array( $ok ? $m : '', $ok );
1809 case 'secret':
1810 // Validated like a string; encryption is applied uniformly in
1811 // update() after this returns, so a secret carried over from the
1812 // current stored value gets encrypted the same way a freshly
1813 // entered one does. Masked placeholders never reach here — update()
1814 // filters them out before validating. (#115)
1815 return array( sanitize_text_field( (string) $value ), true );
1816 case 'string':
1817 default:
1818 return array( sanitize_text_field( (string) $value ), true );
1819 }
1820 }
1821
1822 /**
1823 * Whether a URL is something an HTTP client could actually call.
1824 *
1825 * `url` fields flagged `endpoint => true` in the schema hold URLs the
1826 * plugin will POST to (a critical-CSS generator, an unused-CSS
1827 * generator). esc_url_raw() alone is not enough for those:
1828 * `[email protected]` — an email address pasted into the field —
1829 * comes back as `http://[email protected]`, a syntactically valid
1830 * URL whose userinfo is the whole address, and the feature then fails
1831 * silently for as long as nobody rereads the field. Require a real
1832 * http(s) scheme and a host, and refuse userinfo outright: no
1833 * endpoint of ours authenticates that way, and accepting it is how
1834 * that address survived in production. (xspeed-pro#77)
1835 */
1836 public static function is_endpoint_url( string $url ): bool {
1837 if ( '' === $url ) {
1838 return false;
1839 }
1840 $parts = wp_parse_url( $url );
1841 if ( ! is_array( $parts ) ) {
1842 return false;
1843 }
1844 if ( ! in_array( $parts['scheme'] ?? '', array( 'http', 'https' ), true ) ) {
1845 return false;
1846 }
1847 if ( '' === (string) ( $parts['host'] ?? '' ) ) {
1848 return false;
1849 }
1850 if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
1851 return false;
1852 }
1853 return true;
1854 }
1855
1856 /**
1857 * sanitize_text_field() for a URL path pattern, minus one step: it keeps
1858 * percent-encoded octets.
1859 *
1860 * sanitize_text_field() deletes every `%XX`, so an excluded URL pasted as
1861 * `/%e7%ac%ac%e5%8d%81/` (how WordPress spells a Chinese slug) was saved
1862 * as `//`, and `/%e8%b3%bc%e7%89%a9%e8%bb%8a` as `/`, which "contains"-
1863 * matches every page and turned the page cache off for the whole site.
1864 * The `%` is swapped for a private-use character while the rest of
1865 * sanitize_text_field() runs (tags, line breaks, invalid UTF-8), then
1866 * swapped back.
1867 *
1868 * Escapes are stored in lower case, the spelling the page cache matches
1869 * paths in. An entry copied from Chrome's address bar arrives as
1870 * `/%E8%81%AF…/`; stored as typed, it never matched.
1871 */
1872 private static function sanitize_path_pattern( string $value ): string {
1873 $mark = "\u{E000}";
1874 $value = str_replace( $mark, '', $value );
1875 $value = str_replace( $mark, '%', sanitize_text_field( str_replace( '%', $mark, $value ) ) );
1876 return (string) preg_replace_callback(
1877 '/%[0-9a-fA-F]{2}/',
1878 static function ( array $m ): string {
1879 return strtolower( $m[0] );
1880 },
1881 $value
1882 );
1883 }
1884
1885 /**
1886 * Whether a URL looks like an image — used to gate `media` fields so a
1887 * non-image URL can't be stored and later rendered as a broken <img>
1888 * (e.g. the white-label brand logo, FBS-82222). Tests the path extension
1889 * against the known image types (query/fragment tolerated). Not a content
1890 * check — a cheap, deterministic guard that pairs with the front-end
1891 * onError fallback; the Media Library picker already yields conforming
1892 * http(s) upload URLs. (data: URIs are stripped by esc_url_raw upstream,
1893 * since `data` isn't an allowed protocol, so they never reach here.)
1894 */
1895 private static function is_image_url( string $url ): bool {
1896 $url = trim( $url );
1897 if ( '' === $url ) {
1898 return false;
1899 }
1900 // Drop the query string + fragment so ?ver=… / #frag don't defeat the
1901 // extension test (e.g. logo.webp?v=2). Plain string ops — no WP URL
1902 // parser dependency on this low-level coercion path.
1903 $path = (string) preg_replace( '/[?#].*$/', '', $url );
1904 return (bool) preg_match( '/\.(jpe?g|png|gif|svg|webp|avif|ico|bmp)$/i', $path );
1905 }
1906
1907 private static function defaults_from_schema( array $schema ): array {
1908 $out = array();
1909 foreach ( $schema as $key => $spec ) {
1910 $out[ $key ] = $spec['default'] ?? null;
1911 }
1912 return $out;
1913 }
1914
1915 private static function option_key( string $slug ): string {
1916 return self::OPTION_PREFIX . $slug;
1917 }
1918 }
1919