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
← All changes | includes/class-settings-manager.php +1603 -5 1.0.8 → 1.4.2 View file →
@@ -26,8 +26,23 @@
26 26
27 27 public const OPTION_PREFIX = 'xspeed_module_';
28 28
29 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 + /**
30 45 * Read settings for a module slug. Returns defaults merged with stored
31 46 * values + the schema applied (unknown keys stripped). Always safe to
32 47 * call before activation — returns pure defaults if nothing is stored.
33 48 */
@@ -46,8 +61,32 @@
46 61
47 62 // Strip keys not in schema; coerce types to what the schema declares.
48 63 $clean = array();
49 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 +
50 89 $clean[ $key ] = array_key_exists( $key, $merged )
51 90 ? self::coerce( $merged[ $key ], $spec )
52 91 : ( $spec['default'] ?? null );
53 92 }
@@ -64,8 +103,732 @@
64 103 return $clean;
65 104 }
66 105
67 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 + /**
68 831 * Validate input against the module's schema, merge over stored values,
69 832 * and persist. Returns the final clean array. Unknown keys are stripped
70 833 * silently. Out-of-range / wrong-type values fall back to the previous
71 834 * stored value (or default).
@@ -77,13 +840,43 @@
77 840 }
78 841 $schema = $module->settings_schema();
79 842 $current = self::get( $slug );
80 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 +
81 858 $clean = $current;
82 859 foreach ( $schema as $key => $spec ) {
83 860 if ( ! array_key_exists( $key, $input ) ) {
84 861 continue;
85 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 + }
86 879 [ $value, $valid ] = self::validate_field( $input[ $key ], $spec );
87 880 if ( $valid ) {
88 881 $clean[ $key ] = $value;
89 882 }
@@ -101,17 +894,720 @@
101 894 $clean[ $key ] = $input[ $key ];
102 895 }
103 896 }
104 897
105 - $clean['_version'] = $module->version();
106 - update_option( self::option_key( $slug ), $clean );
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 );
107 913
108 - // Strip the internal _version key from the returned array.
109 - unset( $clean['_version'] );
110 - return $clean;
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 );
111 1020 }
112 1021
113 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 + /**
114 1610 * Run any pending schema migrations for a module. Called by
115 1611 * Module_Registry before boot(). Idempotent — migrations only run once
116 1612 * per version bump because we persist `_version` after each successful
117 1613 * migration step.
@@ -186,9 +1682,21 @@
186 1682 return $spec['default'] ?? array();
187 1683 }
188 1684 return array_values( array_filter( $value, 'is_scalar' ) );
189 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 + }
190 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 + }
191 1699 return $url ?: ( $spec['default'] ?? '' );
192 1700 case 'media':
193 1701 // Media-library image URL. Empty is a valid "no image" state.
194 1702 // esc_url_raw alone lets through any safe URL (…/evil.txt,
@@ -195,8 +1703,16 @@
195 1703 // non-images) which then renders as a broken <img>; require it
196 1704 // to look like an image and drop anything else to empty.
197 1705 $media = esc_url_raw( (string) $value );
198 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 );
199 1715 case 'string':
200 1716 default:
201 1717 return sanitize_text_field( (string) $value );
202 1718 }
@@ -258,8 +1774,10 @@
258 1774 $u = esc_url_raw( (string) $item );
259 1775 if ( $u ) {
260 1776 $out[] = $u;
261 1777 }
1778 + } elseif ( 'path' === $item_type ) {
1779 + $out[] = self::sanitize_path_pattern( (string) $item );
262 1780 } else {
263 1781 $out[] = sanitize_text_field( (string) $item );
264 1782 }
265 1783 }
@@ -264,9 +1782,19 @@
264 1782 }
265 1783 }
266 1784 return array( $out, true );
267 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 + }
268 1793 $u = esc_url_raw( (string) $value );
1794 + if ( ! empty( $spec['endpoint'] ) && ! self::is_endpoint_url( $u ) ) {
1795 + return array( null, false );
1796 + }
269 1797 return array( $u, (bool) $u );
270 1798 case 'media':
271 1799 // Empty (cleared logo) is valid; any non-empty value must be a
272 1800 // safe URL after esc_url_raw AND look like an image, so a
@@ -277,12 +1805,82 @@
277 1805 return array( '', true );
278 1806 }
279 1807 $ok = '' !== $m && self::is_image_url( $m );
280 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 );
281 1816 case 'string':
282 1817 default:
283 1818 return array( sanitize_text_field( (string) $value ), true );
284 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 + );
285 1883 }
286 1884
287 1885 /**
288 1886 * Whether a URL looks like an image — used to gate `media` fields so a