PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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 +912 -3 1.2.41.3.3 View file →
@@ -61,8 +61,32 @@
61 61
62 62 // Strip keys not in schema; coerce types to what the schema declares.
63 63 $clean = array();
64 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 +
65 89 $clean[ $key ] = array_key_exists( $key, $merged )
66 90 ? self::coerce( $merged[ $key ], $spec )
67 91 : ( $spec['default'] ?? null );
68 92 }
@@ -79,8 +103,722 @@
79 103 return $clean;
80 104 }
81 105
82 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 + /**
83 821 * Validate input against the module's schema, merge over stored values,
84 822 * and persist. Returns the final clean array. Unknown keys are stripped
85 823 * silently. Out-of-range / wrong-type values fall back to the previous
86 824 * stored value (or default).
@@ -111,8 +849,16 @@
111 849 foreach ( $schema as $key => $spec ) {
112 850 if ( ! array_key_exists( $key, $input ) ) {
113 851 continue;
114 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 + }
115 861 // A secret field whose incoming value is the masked placeholder means
116 862 // the client is echoing back what get_public() sent, not setting a new
117 863 // credential — keep the stored value so an unrelated save on the same
118 864 // panel never wipes the key. An empty value is NOT a mask echo: it's a
@@ -163,8 +909,17 @@
163 909 // write-preserved via the broader is_secret_field() (masking a plaintext
164 910 // is always safe); they just aren't encrypted until retyped to `secret`.
165 911 $stored = $clean;
166 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 + }
167 922 if ( 'secret' === ( $spec['type'] ?? '' ) ) {
168 923 $stored[ $key ] = self::encrypt_for_storage( (string) ( $stored[ $key ] ?? '' ) );
169 924 }
170 925 }
@@ -170,8 +925,86 @@
170 925 }
171 926 $stored['_version'] = $module->version();
172 927 update_option( self::option_key( $slug ), $stored );
173 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 +
174 1007 // Return the PUBLIC view: real non-secret values, masked secrets. This
175 1008 // is the REST/CLI/MCP response, so it must never carry credentials. (#115)
176 1009 return self::get_public( $slug );
177 1010 }
@@ -184,15 +1017,73 @@
184 1017 * stays plaintext for the engine.
185 1018 *
186 1019 * @return array<string,mixed>
187 1020 */
188 - public static function get_public( string $slug ): array {
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 {
189 1035 $module = Module_Registry::get( $slug );
190 1036 if ( ! $module ) {
191 1037 return array();
192 1038 }
193 - $settings = self::get( $slug );
1039 + $stored = get_option( self::option_key( $slug ), array() );
1040 + if ( ! is_array( $stored ) ) {
1041 + $stored = array();
1042 + }
1043 + $out = array();
194 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 ) {
195 1086 if ( self::is_secret_field( $key, $spec ) && array_key_exists( $key, $settings ) ) {
196 1087 $settings[ $key ] = self::mask_secret_value( (string) $settings[ $key ] );
197 1088 }
198 1089 }
@@ -420,8 +1311,14 @@
420 1311 $out = array(
421 1312 'applied' => array(),
422 1313 'unknown' => array(),
423 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(),
424 1321 );
425 1322
426 1323 $module = Module_Registry::get( $slug );
427 1324 if ( ! $module ) {
@@ -451,9 +1348,21 @@
451 1348 if ( self::is_secret_field( $key, $spec ) && self::is_masked_secret( (string) $value ) ) {
452 1349 $out['applied'][] = $key;
453 1350 continue;
454 1351 }
455 - [ , $valid ] = self::validate_field( $value, $spec );
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 + }
456 1365 if ( $valid ) {
457 1366 $out['applied'][] = $key;
458 1367 } else {
459 1368 $out['invalid'][] = $key;