PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.1 All 27 releases
xspeed / includes / class-settings-manager.php

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

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