PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-settings-manager.php +1468 -21 1.1.21.3.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,722 @@
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 + 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 + /**
68 821 * Validate input against the module's schema, merge over stored values,
69 822 * and persist. Returns the final clean array. Unknown keys are stripped
70 823 * silently. Out-of-range / wrong-type values fall back to the previous
71 824 * stored value (or default).
@@ -77,13 +830,43 @@
77 830 }
78 831 $schema = $module->settings_schema();
79 832 $current = self::get( $slug );
80 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 +
81 848 $clean = $current;
82 849 foreach ( $schema as $key => $spec ) {
83 850 if ( ! array_key_exists( $key, $input ) ) {
84 851 continue;
85 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 + }
86 869 [ $value, $valid ] = self::validate_field( $input[ $key ], $spec );
87 870 if ( $valid ) {
88 871 $clean[ $key ] = $value;
89 872 }
@@ -101,51 +884,252 @@
101 884 $clean[ $key ] = $input[ $key ];
102 885 }
103 886 }
104 887
105 - $clean['_version'] = $module->version();
106 - update_option( self::option_key( $slug ), $clean );
107 -
108 - // Strip the internal _version key from the returned array.
109 - unset( $clean['_version'] );
110 -
111 888 // Change annotation (issue #45): every real mutation — from the UI,
112 889 // REST, CLI, or an MCP agent — lands in the activity log with the
113 890 // old→new diff and its source channel, so the dashboard can tell the
114 891 // causal story ("expiry raised → hit ratio climbed").
115 - self::log_changes( $slug, $current, $clean, array_keys( $schema ) );
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 );
116 903
117 - return $clean;
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 );
118 1010 }
119 1011
120 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 + /**
121 1094 * Record changed schema fields as one activity event. No-op when
122 1095 * nothing actually changed (idempotent re-saves stay silent).
123 1096 *
124 - * @param string $slug Module slug.
125 - * @param array $before Settings before the write.
126 - * @param array $after Settings after the write.
127 - * @param string[] $schema_keys Keys eligible to diff.
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.
128 1101 */
129 - private static function log_changes( string $slug, array $before, array $after, array $schema_keys ): void {
1102 + private static function log_changes( string $slug, array $before, array $after, array $schema ): void {
130 1103 if ( ! class_exists( '\\XSpeed\\Activity_Log' ) ) {
131 1104 return;
132 1105 }
133 1106 $diffs = array();
134 - foreach ( $schema_keys as $key ) {
1107 + foreach ( array_keys( $schema ) as $key ) {
135 1108 $old = $before[ $key ] ?? null;
136 1109 $new = $after[ $key ] ?? null;
137 1110 if ( $old === $new ) {
138 1111 continue;
139 1112 }
140 - if ( self::is_secret_key( $key ) ) {
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 ) ) {
141 1125 // Never record the value itself — the annotation is served to
142 - // the dashboard by the trend endpoints, so a token written
143 - // here would be readable from the REST payload.
144 - $diffs[] = sprintf( '%s changed', $key );
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 );
145 1129 continue;
146 1130 }
147 - $diffs[] = sprintf( '%s %s→%s', $key, self::describe_value( $old ), self::describe_value( $new ) );
1131 + $diffs[] = sprintf( '%s %s→%s', $label, self::describe_value( $old ), self::describe_value( $new ) );
148 1132 }
149 1133 if ( empty( $diffs ) ) {
150 1134 return;
151 1135 }
@@ -150,13 +1134,39 @@
150 1134 return;
151 1135 }
152 1136 Activity_Log::record(
153 1137 'settings_changed',
154 - sprintf( '%s: %s (via %s)', $slug, implode( ', ', array_slice( $diffs, 0, 5 ) ), self::source_channel() )
1138 + sprintf( '%s: %s (via %s)', self::module_label( $slug ), implode( ', ', array_slice( $diffs, 0, 5 ) ), self::source_channel() )
155 1139 );
156 1140 }
157 1141
158 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 + /**
159 1169 * Setting keys whose VALUE must never reach the activity log. The log is
160 1170 * surfaced by the dashboard trend endpoints, so anything recorded here is
161 1171 * readable by any user who can load the dashboard.
162 1172 *
@@ -166,11 +1176,377 @@
166 1176 *
167 1177 * @param string $key Schema key, e.g. 'api_token'.
168 1178 */
169 1179 public static function is_secret_key( string $key ): bool {
170 - return 1 === preg_match( '/(token|password|secret|api_key|passwd|private_key|credential)/i', $key );
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 );
171 1185 }
172 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 +
173 1549 /** Compact human form of a setting value for the change log. */
174 1550 private static function describe_value( $value ): string {
175 1551 if ( is_bool( $value ) ) {
176 1552 return $value ? 'on' : 'off';
@@ -278,9 +1654,21 @@
278 1654 return $spec['default'] ?? array();
279 1655 }
280 1656 return array_values( array_filter( $value, 'is_scalar' ) );
281 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 + }
282 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 + }
283 1671 return $url ?: ( $spec['default'] ?? '' );
284 1672 case 'media':
285 1673 // Media-library image URL. Empty is a valid "no image" state.
286 1674 // esc_url_raw alone lets through any safe URL (…/evil.txt,
@@ -287,8 +1675,16 @@
287 1675 // non-images) which then renders as a broken <img>; require it
288 1676 // to look like an image and drop anything else to empty.
289 1677 $media = esc_url_raw( (string) $value );
290 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 );
291 1687 case 'string':
292 1688 default:
293 1689 return sanitize_text_field( (string) $value );
294 1690 }
@@ -356,9 +1752,19 @@
356 1752 }
357 1753 }
358 1754 return array( $out, true );
359 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 + }
360 1763 $u = esc_url_raw( (string) $value );
1764 + if ( ! empty( $spec['endpoint'] ) && ! self::is_endpoint_url( $u ) ) {
1765 + return array( null, false );
1766 + }
361 1767 return array( $u, (bool) $u );
362 1768 case 'media':
363 1769 // Empty (cleared logo) is valid; any non-empty value must be a
364 1770 // safe URL after esc_url_raw AND look like an image, so a
@@ -369,12 +1775,53 @@
369 1775 return array( '', true );
370 1776 }
371 1777 $ok = '' !== $m && self::is_image_url( $m );
372 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 );
373 1786 case 'string':
374 1787 default:
375 1788 return array( sanitize_text_field( (string) $value ), true );
376 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;
377 1824 }
378 1825
379 1826 /**
380 1827 * Whether a URL looks like an image — used to gate `media` fields so a