PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
xspeed / includes / modules / ObjectCache / ObjectCacheModule.php

ObjectCacheModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.1, at includes/modules/ObjectCache/ObjectCacheModule.php

1,061 lines 43.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Object Cache module — status, settings, wp-config snippet, flush.
4 *
5 * Tier: Free per FEATURES.md "Object Cache" §1-11 (LiteSpeed parity).
6 *
7 * We don't ship our own object-cache.php drop-in in this Free release
8 * (see Object_Cache class docblock for rationale). The settings here
9 * are surfaced via the wp-config snippet; advanced consumers (Pro,
10 * external drop-ins like Redis Object Cache) can read them too.
11 *
12 * @package XSpeed
13 */
14
15 declare(strict_types=1);
16
17 namespace XSpeed\Modules\ObjectCache;
18
19 defined( 'ABSPATH' ) || exit;
20
21 use XSpeed\Module;
22 use XSpeed\Object_Cache;
23 use XSpeed\Object_Cache_Takeover;
24
25 final class ObjectCacheModule extends Module {
26
27 public const SLUG = 'object-cache';
28 public const TIER = self::TIER_FREE;
29 public const VERSION = '1.1.0';
30
31 public function ui_metadata(): array {
32 return array(
33 'label' => __( 'Object Cache', 'xspeed' ),
34 'icon' => 'Server',
35 'description' => __( 'Stores database query results in Redis or Memcached so pages build faster.', 'xspeed' ),
36 'group' => 'cache',
37 'custom_panel' => 'ObjectCachePanel',
38 );
39 }
40
41 /**
42 * @inheritDoc
43 *
44 * Nothing exempt. Object caching sits beside a page cache and competes for
45 * nothing, and the drop-in is only re-synced when ours is already installed
46 * (see boot()), so the flag installs nothing on its own — all true, and all
47 * a weak reason to leave a switch on that nobody asked for.
48 */
49 public function conflict_safe_exempt(): array {
50 return array();
51 }
52
53 public function settings_schema(): array {
54 return array(
55 'backend' => array(
56 'type' => 'enum',
57 'default' => 'redis',
58 'options' => array( 'redis', 'memcached' ),
59 'option_labels' => array(
60 'redis' => 'Redis',
61 'memcached' => 'Memcached',
62 ),
63 'label' => __( 'Cache server', 'xspeed' ),
64 'description' => __( 'The cache server your host provides. Ask your host if you are not sure.', 'xspeed' ),
65 ),
66 'redis_host' => array(
67 'type' => 'string',
68 'default' => '127.0.0.1',
69 'constants' => array( 'XSPEED_OC_HOST', 'WP_REDIS_HOST' ),
70 'label' => __( 'Redis host', 'xspeed' ),
71 'description' => __( 'Address of the Redis server. Use 127.0.0.1 when Redis runs on the same server as your site.', 'xspeed' ),
72 'dependsOn' => array( 'field' => 'backend', 'value' => 'redis' ),
73 ),
74 'redis_port' => array(
75 'type' => 'int',
76 'default' => 6379,
77 'min' => 1,
78 'max' => 65535,
79 'constants' => array( 'XSPEED_OC_PORT', 'WP_REDIS_PORT' ),
80 'label' => __( 'Redis port', 'xspeed' ),
81 'description' => __( 'The default Redis port is 6379.', 'xspeed' ),
82 'advanced' => true,
83 'dependsOn' => array( 'field' => 'backend', 'value' => 'redis' ),
84 ),
85 'redis_user' => array(
86 'type' => 'string',
87 'default' => '',
88 // WP_REDIS_PASSWORD trails the dedicated names because in its
89 // array form -- how xCloud and Cloudways hand out ACL
90 // credentials -- it carries the username too. It is pair-only:
91 // as a plain string it is a password, never a username.
92 'constants' => array( 'XSPEED_OC_USER', 'WP_REDIS_USER', 'WP_REDIS_PASSWORD' ),
93 'constants_pair_only' => array( 'WP_REDIS_PASSWORD' ),
94 'constant_pair' => 'user',
95 'label' => __( 'Redis username', 'xspeed' ),
96 'description' => __( 'Only needed if your host gave you a Redis username along with the password. Leave blank otherwise.', 'xspeed' ),
97 'dependsOn' => array( 'field' => 'backend', 'value' => 'redis' ),
98 ),
99 'redis_password' => array(
100 'type' => 'secret',
101 'default' => '',
102 'constants' => array( 'XSPEED_OC_PASSWORD', 'WP_REDIS_PASSWORD' ),
103 'constant_pair' => 'password',
104 'label' => __( 'Redis password', 'xspeed' ),
105 'description' => __( 'Leave blank if your Redis server has no password.', 'xspeed' ),
106 'dependsOn' => array( 'field' => 'backend', 'value' => 'redis' ),
107 ),
108 'redis_database' => array(
109 'type' => 'int',
110 'default' => 0,
111 'min' => 0,
112 'max' => 15,
113 'constants' => array( 'XSPEED_OC_DATABASE', 'WP_REDIS_DATABASE' ),
114 'label' => __( 'Redis database', 'xspeed' ),
115 'description' => __( 'Database number, 0 to 15. If several sites share one Redis server, give each site its own number.', 'xspeed' ),
116 'advanced' => true,
117 'dependsOn' => array( 'field' => 'backend', 'value' => 'redis' ),
118 ),
119 'memcached_host' => array(
120 'type' => 'string',
121 'default' => '127.0.0.1',
122 // Memcached names its OWN constants. Sharing XSPEED_OC_HOST/PORT
123 // with Redis meant enabling Redis rewrote the Memcached host and
124 // port with Redis's, and locked them -- switching backend later
125 // then failed with no way to fix it on screen. (#398)
126 'constants' => array( 'XSPEED_OC_MC_HOST', 'XSPEED_OC_HOST' ),
127 // XSPEED_OC_HOST/PORT are the pre-split names, kept so an install
128 // configured before the split keeps its host on upgrade. Gated on
129 // the backend actually being Memcached, or a Redis site would read
130 // the Redis port here. Fades out on the next save. (#398)
131 'constants_when' => array(
132 'XSPEED_OC_HOST' => array( 'constant' => 'XSPEED_OC_BACKEND', 'is' => 'memcached' ),
133 ),
134
135 // Memcached has no constant convention the way Redis has
136 // WP_REDIS_*; $memcached_servers IS the convention (W3TC, the
137 // Memcached Object Cache drop-in), and hosts write it. The
138 // drop-in already honoured it while the panel did not. (#398)
139 'global_source' => array(
140 'var' => 'memcached_servers',
141 'reader' => array( '\\XSpeed\\Object_Cache', 'first_memcached_server' ),
142 'slot' => 0,
143 ),
144 'label' => __( 'Memcached host', 'xspeed' ),
145 'description' => __( 'Address of the Memcached server. Use 127.0.0.1 when it runs on the same server as your site.', 'xspeed' ),
146 'dependsOn' => array( 'field' => 'backend', 'value' => 'memcached' ),
147 ),
148 'memcached_port' => array(
149 'type' => 'int',
150 'default' => 11211,
151 'min' => 1,
152 'max' => 65535,
153 'constants' => array( 'XSPEED_OC_MC_PORT', 'XSPEED_OC_PORT' ),
154 // XSPEED_OC_HOST/PORT are the pre-split names, kept so an install
155 // configured before the split keeps its host on upgrade. Gated on
156 // the backend actually being Memcached, or a Redis site would read
157 // the Redis port here. Fades out on the next save. (#398)
158 'constants_when' => array(
159 'XSPEED_OC_PORT' => array( 'constant' => 'XSPEED_OC_BACKEND', 'is' => 'memcached' ),
160 ),
161
162 'global_source' => array(
163 'var' => 'memcached_servers',
164 'reader' => array( '\\XSpeed\\Object_Cache', 'first_memcached_server' ),
165 'slot' => 1,
166 ),
167 'label' => __( 'Memcached port', 'xspeed' ),
168 'description' => __( 'The default Memcached port is 11211.', 'xspeed' ),
169 'advanced' => true,
170 'dependsOn' => array( 'field' => 'backend', 'value' => 'memcached' ),
171 ),
172 'key_prefix' => array(
173 'type' => 'string',
174 'default' => '',
175 // ONE field for both backends on purpose: the drop-in has one
176 // salt and applies it identically either way (full_key()), so
177 // splitting it would be two controls over one value.
178 //
179 // Both names are honoured whichever backend is selected.
180 // WP_REDIS_PREFIX reads oddly on a Memcached site, but a site
181 // that has defined it has said what namespace it wants, and
182 // ignoring that to keep the label tidy would be the panel
183 // disagreeing with the drop-in -- the exact bug this closes.
184 // The field's own description carries the explanation. (#398)
185 //
186 // WP_CACHE_KEY_SALT is deliberately NOT declared here (#430):
187 // it is WordPress's own cache-uniqueness salt, present and
188 // random on nearly every install, not a namespace declaration.
189 // Listing it pinned this field on that random value and locked
190 // editing -- while the override path already refused to treat
191 // it as a foreign authority, so "Manage here" could never
192 // unlock the field. With no salt of our own the field stays
193 // blank and editable; the drop-in still honours a defined
194 // WP_CACHE_KEY_SALT as its last-resort salt at runtime.
195 'constants' => array( 'XSPEED_OC_SALT', 'WP_REDIS_PREFIX' ),
196 'label' => __( 'Cache key prefix', 'xspeed' ),
197 'description' => __( 'Leave blank and xSpeed picks a unique prefix for this site. Some managed Redis hosts, such as xCloud, give you a key to paste here, or nothing gets saved.', 'xspeed' ),
198 // Not advanced: on ACL hosts it is required, and the write-denied
199 // notice (Object_Cache::write_denied_message) sends users here.
200 ),
201 'connection_timeout' => array(
202 'type' => 'int',
203 'default' => 1,
204 'min' => 0,
205 'max' => 60,
206 'constants' => array( 'XSPEED_OC_TIMEOUT', 'WP_REDIS_TIMEOUT' ),
207 'label' => __( 'Connection timeout (seconds)', 'xspeed' ),
208 'unit' => 'seconds',
209 'description' => __( 'How long to wait for the cache server. Keep it at 1 or 2 seconds so a broken cache server never holds up a page.', 'xspeed' ),
210 'advanced' => true,
211 ),
212 'persistent' => array(
213 'type' => 'bool',
214 'default' => true,
215 'constants' => array( 'XSPEED_OC_PERSISTENT', 'WP_REDIS_PERSISTENT' ),
216 'label' => __( 'Persistent connections', 'xspeed' ),
217 'description' => __( 'Keep the connection open between page loads. Turn off only if your cache server reports too many idle connections.', 'xspeed' ),
218 'advanced' => true,
219 ),
220 );
221 }
222
223 /**
224 * Encrypt the pre-1.1.0 plaintext redis_password on upgrade — it became a
225 * `secret`-typed field (encrypted at rest). Idempotent. (#115)
226 */
227 public function migrations(): array {
228 return array(
229 '1.1.0' => static function ( array $opts ): array {
230 if ( isset( $opts['redis_password'] ) && is_string( $opts['redis_password'] ) && '' !== $opts['redis_password'] ) {
231 $opts['redis_password'] = \XSpeed\Settings_Manager::encrypt_for_storage( $opts['redis_password'] );
232 }
233 return $opts;
234 },
235 );
236 }
237
238 /** Counts failed drop-in sync attempts so a permanent failure stops retrying. */
239 private const SYNC_ATTEMPTS_OPTION = 'xspeed_oc_sync_attempts';
240
241 /**
242 * Verdict of the write probe run after the last save. Read by the status
243 * card so a backend that silently refuses writes cannot keep reporting a
244 * healthy cache. (#398)
245 */
246 private const WRITE_PROBE_OPTION = 'xspeed_oc_write_probe';
247
248 /**
249 * The recorded write failure, if it is still true right now.
250 *
251 * The probe is written on save, so anything that fixes the backend WITHOUT
252 * a save -- `objcache revert` rescuing a bad key prefix is the case that
253 * matters -- left the warning standing over a cache that had recovered. Any
254 * reader would then cry wolf until the next save. Re-checking before
255 * reporting keeps one stored probe honest for both readers, and clears it
256 * so the recheck happens once rather than on every status call. (#398)
257 *
258 * @return array{ok:bool,message:string,at:int}|null Null when writes are fine.
259 */
260 private static function live_write_failure(): ?array {
261 $probe = get_option( self::WRITE_PROBE_OPTION, array() );
262 if ( ! is_array( $probe ) || ! array_key_exists( 'ok', $probe ) || ! empty( $probe['ok'] ) ) {
263 return null;
264 }
265 if ( ! Object_Cache::is_our_dropin_present() ) {
266 delete_option( self::WRITE_PROBE_OPTION );
267 return null;
268 }
269
270 /*
271 * Throttle the recheck. This reader sits on rest_detect(), which the
272 * panel polls, and on every `objcache status` -- and the recheck is a
273 * real TCP connect plus a SET/GET/DEL round trip. On a backend that is
274 * DOWN rather than merely refusing writes the probe never clears, so
275 * without this every poll blocks for the full timeout and the object
276 * cache screen feels hung at exactly the moment someone is trying to
277 * fix it. A stale-by-a-minute warning is the cheaper error. (#398)
278 */
279 $checked = (int) ( $probe['rechecked_at'] ?? 0 );
280 if ( $checked > 0 && ( time() - $checked ) < MINUTE_IN_SECONDS ) {
281 return array(
282 'ok' => false,
283 'message' => (string) ( $probe['message'] ?? '' ),
284 'at' => (int) ( $probe['at'] ?? 0 ),
285 );
286 }
287
288 /*
289 * Recheck against the STORED row, not the resolved settings. Within the
290 * request that saved the value, our block has been rewritten but PHP
291 * has already defined those constants and cannot redefine them -- so
292 * the resolved read still returns the PRE-save value. Rechecking that
293 * tests a configuration the site is no longer being asked to use, and a
294 * healthy answer would clear a probe that is genuinely true, putting
295 * the silent-dead-cache bug straight back. The row is what the next
296 * request's block is built from, so it is what the probe describes.
297 * (#398)
298 */
299 $opts = \XSpeed\Settings_Manager::stored_with_defaults( self::SLUG );
300 $opts['connection_timeout'] = min( 2, max( 1, (int) ( $opts['connection_timeout'] ?? 1 ) ) );
301 $now = Object_Cache::test_connection( $opts );
302 if ( ! empty( $now['ok'] ) ) {
303 delete_option( self::WRITE_PROBE_OPTION );
304 return null;
305 }
306
307 $still = array(
308 'ok' => false,
309 'message' => (string) ( $now['message'] ?? ( $probe['message'] ?? '' ) ),
310 'at' => (int) ( $probe['at'] ?? 0 ),
311 'rechecked_at' => time(),
312 );
313 update_option( self::WRITE_PROBE_OPTION, $still, false );
314
315 unset( $still['rechecked_at'] );
316 return $still;
317 }
318
319 /** Give up after this many failed syncs for one plugin version. */
320 private const MAX_SYNC_ATTEMPTS = 5;
321
322 public function boot(): void {
323 // A saved override is promoted into our wp-config block, then the field
324 // re-locks reading OUR constant. This is what makes "edit once, lock
325 // again" honest: the drop-in loads before WordPress and cannot read an
326 // option row, so a value that stayed in the database would be a setting
327 // the panel showed and the runtime ignored. (#398)
328 /*
329 * Mirror every save into our own store, so the drop-in -- which loads
330 * before WordPress and cannot read an option row -- sees what the panel
331 * just wrote. Only OUR block is rewritten: a define the host owns is
332 * left alone by wp_config_block()'s pinned_elsewhere() check, and when
333 * wp-config.php is read-only the write lands in the sidecar instead.
334 *
335 * Without this a save updated the option row while the block kept the
336 * previous value, and the constant outranks the row -- so the panel
337 * reported success and the site went on using the old setting. (#398)
338 */
339 /*
340 * Answer the backend gate from the sidecar when no constant defines it.
341 * The legacy Memcached fallback (XSPEED_OC_HOST/PORT) is gated on the
342 * backend being Memcached, and on a read-only-wp-config host that fact
343 * lives in the sidecar -- so without this the panel reported the
344 * default host while the drop-in used the real one. (#398)
345 */
346 /*
347 * Answer settings reads from the sidecar. On a host where wp-config.php
348 * is read-only the sidecar IS the configuration, so a panel that read
349 * only the option row would show the stored values while the drop-in
350 * ran on the sidecar's -- the panel/runtime split this change exists to
351 * close. Constants still win; this sits between them and the row. (#398)
352 */
353 add_filter(
354 'xspeed_setting_external_source',
355 static function ( $value, string $slug, string $key ) {
356 if ( null !== $value || self::SLUG !== $slug ) {
357 return $value;
358 }
359 $sidecar = Object_Cache::read_sidecar();
360 return array_key_exists( $key, $sidecar ) ? $sidecar[ $key ] : null;
361 },
362 10,
363 3
364 );
365
366 add_filter(
367 'xspeed_constant_gate_value',
368 static function ( $value, string $constant ) {
369 if ( null !== $value || 'XSPEED_OC_BACKEND' !== $constant ) {
370 return $value;
371 }
372 $sidecar = Object_Cache::read_sidecar();
373 return $sidecar['backend'] ?? null;
374 },
375 10,
376 2
377 );
378
379 add_action(
380 'xspeed_settings_saved',
381 static function ( string $slug, array $clean ): void {
382 if ( self::SLUG !== $slug ) {
383 return;
384 }
385 // Only when we already own a block or a sidecar. Creating one
386 // on a site that never enabled the object cache would write to
387 // wp-config.php for a feature that is switched off.
388 /*
389 * Mirror whenever the drop-in is installed -- that, not the
390 * presence of a store, is what makes a DB-only value a setting
391 * the panel shows and the runtime ignores. Keying on the store
392 * instead could strand a site whose block write once failed:
393 * with no block and no sidecar the guard would return early
394 * forever and saves would stop being mirrored. (#398)
395 */
396 if ( ! Object_Cache::is_our_dropin_present() ) {
397 return;
398 }
399 Object_Cache::write_wp_config( $clean );
400
401 /*
402 * Prove the backend still ACCEPTS WRITES with the settings just
403 * saved. On a namespaced/ACL Redis a key prefix outside the
404 * granted namespace is refused with NOPERM, and the drop-in
405 * swallows that -- so the panel reported success, the status
406 * card kept saying "On", and the site paid for a cache that
407 * stored nothing. Only an explicit Test connection revealed it.
408 *
409 * Recorded rather than thrown: the save itself DID land, and
410 * failing it would leave the panel and the row disagreeing. The
411 * status card reads this and says so. (#398)
412 */
413 if ( Object_Cache::is_our_dropin_present() ) {
414 /*
415 * Clamp the probe's timeout. It runs inside an admin POST,
416 * and connection_timeout is the user's own setting -- a
417 * save that points at a black-holed IP would otherwise
418 * block the request for as long as they typed. The probe is
419 * a check, not the connection the site runs on, so a short
420 * ceiling costs nothing.
421 */
422 $probe_opts = $clean;
423 $probe_opts['connection_timeout'] = min( 2, max( 1, (int) ( $clean['connection_timeout'] ?? 1 ) ) );
424 $probe = Object_Cache::test_connection( $probe_opts );
425 update_option(
426 self::WRITE_PROBE_OPTION,
427 array(
428 'ok' => ! empty( $probe['ok'] ),
429 'message' => (string) ( $probe['message'] ?? '' ),
430 'at' => time(),
431 ),
432 false
433 );
434 }
435 },
436 10,
437 2
438 );
439
440 /*
441 * Ownership of a field changed hands, so our block no longer reflects
442 * what should be in it. On a revert this is the step that actually
443 * frees the field: Settings_Manager::revert() has dropped our stored
444 * value, and rewriting the block from the settings as they NOW resolve
445 * is what removes our define and lets the host's constant win again.
446 *
447 * Without this listener the hook fired into nothing, the define stayed,
448 * and "Use the host value" was a no-op in the panel while the CLI --
449 * which did the same work inline -- worked. (#398)
450 */
451 add_action(
452 'xspeed_setting_override_changed',
453 static function ( string $slug, string $key, bool $on ): void {
454 unset( $key );
455 if ( self::SLUG !== $slug || ! Object_Cache::is_our_dropin_present() ) {
456 return;
457 }
458 /*
459 * Only on a REVERT, and only when nothing else is mid-flight.
460 *
461 * update() also lifts an override as its last act, having just
462 * promoted the typed value into our block -- a rewrite here
463 * would resolve the host's constant again and erase the define
464 * that save had only just written, silently undoing the edit.
465 * A revert is the one case where erasing our define IS the
466 * point. (#398)
467 */
468 if ( $on || \XSpeed\Settings_Manager::is_promoting( self::SLUG ) ) {
469 return;
470 }
471 Object_Cache::write_wp_config( \XSpeed\Settings_Manager::get( self::SLUG ) );
472 },
473 10,
474 3
475 );
476
477 add_action(
478 'xspeed_settings_promote_to_config',
479 static function ( string $slug, array $values ): void {
480 if ( self::SLUG !== $slug ) {
481 return;
482 }
483 // The block is CREATED here if absent, unlike the passive
484 // rewrites elsewhere. This write is the thing the admin just
485 // asked for and was warned about; refusing it because the
486 // object cache is not enabled yet would make the save a silent
487 // no-op -- the failure mode this whole contract exists to
488 // prevent. (#398)
489 // Forced: these fields are exactly the ones a foreign define
490 // still pins, which is why they were overridden in the first
491 // place. Without the force list pinned_elsewhere() would drop
492 // them and the save would write nothing.
493 Object_Cache::write_wp_config(
494 array_merge( \XSpeed\Settings_Manager::get( self::SLUG ), $values ),
495 array_keys( $values )
496 );
497 },
498 10,
499 2
500 );
501
502
503 // Keep the deployed drop-in in sync with the shipped template. It is
504 // copied into wp-content/object-cache.php on enable and then never
505 // touched again — so a fix shipped in a plugin update (e.g. the
506 // stale-alloptions eviction on failed backend writes, issue #41)
507 // would never reach existing installs. Version-gated so the file
508 // comparison runs once per plugin version, not on every admin load.
509 add_action(
510 'admin_init',
511 static function (): void {
512 if ( get_option( 'xspeed_oc_dropin_synced', '' ) === XSPEED_VERSION ) {
513 return;
514 }
515 $ok = true;
516 if ( Object_Cache::is_our_dropin_present() ) {
517 // Both steps run regardless of each other — they fail
518 // independently (drop-in needs wp-content writable, the
519 // backfill needs wp-config writable), and short-circuiting
520 // would skip a backfill that could have succeeded.
521 $installed = Object_Cache::install_dropin();
522 $salted = self::backfill_key_salt();
523 $ok = $installed && $salted;
524 }
525 // Only stamp the version when the sync actually succeeded. A
526 // transient failure (wp-config momentarily unwritable, a
527 // filesystem hiccup) then gets retried on a later admin load
528 // rather than being recorded as migrated and left unsalted.
529 //
530 // Bounded, though: where the failure is permanent — a host that
531 // ships a read-only wp-config — retrying forever would run
532 // WP_Filesystem work on every single admin page load for no
533 // gain. It is safe to stop, because the drop-in derives its own
534 // salt when the constant is absent, so such an install is
535 // namespaced either way; the constant is only the faster path.
536 if ( $ok ) {
537 update_option( 'xspeed_oc_dropin_synced', XSPEED_VERSION );
538 delete_option( self::SYNC_ATTEMPTS_OPTION );
539 return;
540 }
541
542 $attempts = (int) get_option( self::SYNC_ATTEMPTS_OPTION, 0 ) + 1;
543 if ( $attempts >= self::MAX_SYNC_ATTEMPTS ) {
544 update_option( 'xspeed_oc_dropin_synced', XSPEED_VERSION );
545 delete_option( self::SYNC_ATTEMPTS_OPTION );
546 return;
547 }
548 update_option( self::SYNC_ATTEMPTS_OPTION, $attempts );
549 }
550 );
551 }
552
553 /**
554 * Backfill a per-site key salt on installs enabled before the salt became
555 * mandatory.
556 *
557 * Enabling with a blank Cache Key Prefix used to write no salt constant at
558 * all, leaving every key namespaced as `:{blog}:{group}:{key}` — identical
559 * on every install. Two sites sharing one Redis/Memcached server then read
560 * each other's `blog-details` / `blog-lookup` entries, and the second site
561 * resolves to (and redirects to) the first.
562 *
563 * Rewriting wp-config re-emits the block with a derived salt.
564 *
565 * Order matters, and NOT the way it first appears. Flushing before the
566 * rewrite looks right — it would drop the old unnamespaced entries rather
567 * than stranding them — but the cache object serving this request was
568 * constructed from the OLD, salt-less config. Asking it to flush is asking
569 * an unsalted object to purge, which on Redis used to mean FLUSHDB and on
570 * Memcached means flush_all(): either one destroys every neighbouring site
571 * sharing the server. That is the exact failure this migration exists to
572 * prevent, so we never flush through the stale object.
573 *
574 * Writing the config first means the NEXT request loads a properly salted
575 * drop-in and simply starts using the new namespace. The old unnamespaced
576 * keys are orphaned rather than deleted; they expire on their own, and they
577 * are unreachable in the meantime because nothing builds those keys any
578 * more.
579 *
580 * @return bool True when the install is namespaced afterwards.
581 */
582 private static function backfill_key_salt(): bool {
583 $module = new self();
584 $settings = $module->get_settings();
585 $written = defined( 'XSPEED_OC_SALT' ) ? (string) constant( 'XSPEED_OC_SALT' ) : '';
586
587 // Nothing written yet — the original backfill case (an install that
588 // enabled the object cache before a salt was emitted at all).
589 if ( '' === $written ) {
590 return Object_Cache::write_wp_config( $settings );
591 }
592
593 // A salt IS written. Re-sync only when the user's typed Cache Key
594 // Prefix disagrees with it, which is the ACL-remediation path: on a
595 // namespaced host the admin types the host's "Redis Object Cache Key"
596 // to stop NOPERM denials, but saving settings does not touch
597 // wp-config — only enable() and this backfill do. Without this
598 // comparison the drop-in kept reading the old constant while the
599 // probe key used the new prefix, so Test connection reported success
600 // while real writes were still denied: the same probe-vs-reality
601 // divergence issue 2 set out to remove, just on a narrower path.
602 //
603 // Deliberately compared against the TYPED prefix, not
604 // effective_salt(): effective_salt() returns the written constant
605 // when the prefix is blank (so a warm cache is never orphaned by a
606 // re-derivation), which would make this a no-op comparison.
607 $typed = isset( $settings['key_prefix'] ) ? (string) $settings['key_prefix'] : '';
608 if ( '' === $typed || $typed === $written ) {
609 return true; // Correctly namespaced — leave the warm cache alone.
610 }
611
612 return Object_Cache::write_wp_config( $settings );
613 }
614
615 public function rest_routes(): array {
616 $default = parent::rest_routes();
617 return array_merge(
618 $default,
619 array(
620 array(
621 'path' => '/detect',
622 'methods' => 'GET',
623 'callback' => array( $this, 'rest_detect' ),
624 ),
625 array(
626 'path' => '/flush',
627 'methods' => 'POST',
628 'callback' => array( $this, 'rest_flush' ),
629 ),
630 array(
631 'path' => '/snippet',
632 'methods' => 'GET',
633 'callback' => array( $this, 'rest_snippet' ),
634 ),
635 array(
636 'path' => '/test-connection',
637 'methods' => 'POST',
638 'callback' => array( $this, 'rest_test_connection' ),
639 ),
640 array(
641 'path' => '/enable',
642 'methods' => 'POST',
643 'callback' => array( $this, 'rest_enable' ),
644 ),
645 array(
646 'path' => '/disable',
647 'methods' => 'POST',
648 'callback' => array( $this, 'rest_disable' ),
649 ),
650 )
651 );
652 }
653
654 public function rest_detect( \WP_REST_Request $request ) {
655 /*
656 * Carry the write probe alongside detection. `detect()` answers "is a
657 * drop-in installed and which backend" -- it cannot see that the
658 * backend is CONNECTED but refusing writes, which is what a key prefix
659 * outside an ACL namespace does (NOPERM, swallowed by the drop-in). The
660 * probe recorded on save knows; until this it had no reader outside
661 * WP-CLI, so the panel kept saying "Redis ready" over a cache that
662 * stored nothing. (#398)
663 */
664 $detect = Object_Cache::detect( true );
665 $failure = self::live_write_failure();
666 if ( null !== $failure ) {
667 $detect['write_probe'] = $failure;
668 }
669 return rest_ensure_response( $detect );
670 }
671
672 public function rest_flush( \WP_REST_Request $request ) {
673 $ok = Object_Cache::flush();
674 if ( $ok && class_exists( '\\XSpeed\\Activity_Log' ) ) {
675 \XSpeed\Activity_Log::record(
676 'object_cache_flushed',
677 'Object cache flushed.',
678 \XSpeed\Activity_Log::INFO
679 );
680 }
681 return rest_ensure_response( array( 'ok' => $ok ) );
682 }
683
684 public function rest_snippet( \WP_REST_Request $request ) {
685 return rest_ensure_response(
686 array( 'snippet' => Object_Cache::render_config_snippet( $this->get_settings() ) )
687 );
688 }
689
690 /**
691 * Merge any settings sent in the request body over the saved settings, so
692 * the UI can "Test connection" with unsaved values. Only known keys pass.
693 */
694 private function settings_with_overrides( \WP_REST_Request $request ): array {
695 $body = $request->get_json_params();
696 return self::merge_overrides(
697 $this->get_settings(),
698 is_array( $body ) ? $body : array(),
699 $this->settings_schema()
700 );
701 }
702
703 /**
704 * Overlay request-body values onto the stored settings for a one-off "Test
705 * connection" — but NEVER let a masked secret echoed from the panel overwrite
706 * the real stored value. The panel holds `Redi••••CRET`; without this guard,
707 * clicking Test connection authenticates Redis with the mask and a correct
708 * password reports as wrong. A genuinely new (typed) password still applies,
709 * and an explicit empty value still tests the no-auth case. Static + pure so
710 * it's unit-testable without a REST request. (QA B3)
711 *
712 * @param array<string,mixed> $settings Stored, decrypted settings.
713 * @param array<string,mixed> $body Request overrides.
714 * @param array<string,array> $schema The module schema (for secret detection).
715 * @return array<string,mixed>
716 */
717 public static function merge_overrides( array $settings, array $body, array $schema ): array {
718 foreach ( $settings as $key => $value ) {
719 if ( ! array_key_exists( $key, $body ) ) {
720 continue;
721 }
722 if ( isset( $schema[ $key ] )
723 && \XSpeed\Settings_Manager::is_secret_field( $key, $schema[ $key ] )
724 && \XSpeed\Settings_Manager::is_masked_secret( (string) $body[ $key ] ) ) {
725 continue;
726 }
727 // A field pinned by a wp-config.php constant is not overridable. Two
728 // reasons, either sufficient: "Test connection" must exercise the
729 // config the drop-in actually uses, or it answers a question nobody
730 // asked; and an overridable host turns this admin endpoint into a
731 // request-forgery probe against arbitrary internal addresses. The
732 // panel posts every field it rendered, so the pinned value arrives
733 // in the body on a normal Test click too. (#398)
734 // write_blocking_constant(), not effective_constant(): a value we
735 // wrote ourselves is this module's own storage, so the admin may
736 // still type over it. Only somebody else's define is protected.
737 if ( isset( $schema[ $key ] )
738 && null !== \XSpeed\Settings_Manager::write_blocking_constant( self::SLUG, $key, $schema[ $key ] ) ) {
739 continue;
740 }
741 $settings[ $key ] = $body[ $key ];
742 }
743 return $settings;
744 }
745
746 public function rest_test_connection( \WP_REST_Request $request ) {
747 return rest_ensure_response( Object_Cache::test_connection( $this->settings_with_overrides( $request ) ) );
748 }
749
750 public function rest_enable( \WP_REST_Request $request ) {
751 // Persist any settings sent with the enable call first, then act on them.
752 $body = $request->get_json_params();
753 // `takeover` is an action, not a setting: take it out before the body
754 // is saved as the settings row. Sent in the body rather than the query
755 // string because on plain permalinks the REST base already carries
756 // `?rest_route=`. (#686)
757 $takeover = is_array( $body ) && ! empty( $body['takeover'] )
758 ? rest_sanitize_boolean( $body['takeover'] )
759 : rest_sanitize_boolean( $request->get_param( 'takeover' ) );
760 if ( is_array( $body ) ) {
761 unset( $body['takeover'] );
762 }
763 if ( is_array( $body ) && ! empty( $body ) ) {
764 \XSpeed\Settings_Manager::update( self::SLUG, $body );
765 }
766 $result = Object_Cache::enable( $this->get_settings(), array( 'takeover' => $takeover ) );
767
768 if ( $result['ok'] && class_exists( '\\XSpeed\\Activity_Log' ) ) {
769 \XSpeed\Activity_Log::record(
770 'object_cache_enabled',
771 'Object cache enabled (' . ( $result['test']['backend'] ?? '' ) . ').',
772 \XSpeed\Activity_Log::INFO
773 );
774 }
775 return rest_ensure_response( $result );
776 }
777
778 public function rest_disable( \WP_REST_Request $request ) {
779 $result = Object_Cache::disable(
780 array( 'restore' => rest_sanitize_boolean( $request->get_param( 'restore' ) ) )
781 );
782 if ( $result['ok'] && class_exists( '\\XSpeed\\Activity_Log' ) ) {
783 \XSpeed\Activity_Log::record(
784 'object_cache_disabled',
785 'Object cache disabled.',
786 \XSpeed\Activity_Log::INFO
787 );
788 }
789 return rest_ensure_response( $result );
790 }
791
792 public function cli_commands(): array {
793 return array(
794 array(
795 'name' => 'xspeed objcache',
796 'callback' => array( $this, 'cli_handler' ),
797 'shortdesc' => 'Show object cache status, read or write a setting, flush, or print the wp-config snippet.',
798 'ai_hint' => 'Is a persistent object cache (Redis/Memcached) connected and working? Use for slow admin pages, high database load, or "should I add Redis" questions — it reports the backend, connection health and hit rate. `get`/`set <key> [value]` read and write individual settings; `status` also reports whether each value comes from wp-config.php or the database.',
799 'synopsis' => array(
800 array(
801 'type' => 'positional',
802 'name' => 'action',
803 'options' => array( 'status', 'flush', 'snippet', 'enable', 'disable', 'test', 'get', 'set', 'override', 'revert' ),
804 'optional' => true,
805 ),
806 array(
807 'type' => 'positional',
808 'name' => 'key',
809 'optional' => true,
810 ),
811 array(
812 'type' => 'positional',
813 'name' => 'value',
814 'optional' => true,
815 ),
816 array(
817 'type' => 'flag',
818 'name' => 'takeover',
819 'description' => 'With enable: switch from the plugin that owns object-cache.php (backs it up, turns that plugin off, installs xSpeed, undoes everything on failure).',
820 'optional' => true,
821 ),
822 array(
823 'type' => 'flag',
824 'name' => 'restore',
825 'description' => 'With disable: put back the plugin xSpeed switched from.',
826 'optional' => true,
827 ),
828 ),
829 ),
830 );
831 }
832
833 public function cli_handler( array $args, array $assoc ): void {
834 $action = $args[0] ?? 'status';
835 switch ( $action ) {
836 case 'status':
837 $d = Object_Cache::detect();
838 \WP_CLI::log( 'drop-in installed: ' . ( $d['drop_in_installed'] ? 'yes' : 'no' ) );
839 \WP_CLI::log( 'label: ' . $d['drop_in_label'] );
840 \WP_CLI::log( 'backend: ' . $d['backend'] );
841 \WP_CLI::log( 'ext object cache: ' . ( $d['wp_cache_active'] ? 'yes' : 'no' ) );
842
843 // A backend that connects but refuses WRITES looks identical to
844 // a healthy one everywhere else -- that is the whole failure.
845 $probe = self::live_write_failure();
846 if ( null !== $probe ) {
847 \WP_CLI::warning(
848 'writes refused: ' . ( $probe['message'] ?: 'unknown error' )
849 . ' — the cache is connected but storing nothing.'
850 );
851 }
852
853 // Per value, where it came from. On a host-provisioned site the
854 // difference between "wp-config.php" and "database" is the whole
855 // question when the cache is not behaving. (#398)
856 \WP_CLI::log( '' );
857 \WP_CLI::log( 'settings:' );
858 $settings = \XSpeed\Settings_Manager::get_public( self::SLUG );
859 $origins = \XSpeed\Settings_Manager::origins( self::SLUG );
860 $schema = $this->settings_schema();
861 foreach ( $schema as $key => $spec ) {
862 $origin = $origins[ $key ] ?? array(
863 'source' => 'default',
864 'constant' => null,
865 );
866 $source = 'constant' === $origin['source']
867 ? 'wp-config.php: ' . $origin['constant']
868 : $origin['source'];
869 \WP_CLI::log(
870 sprintf(
871 ' %-20s %-24s [%s]',
872 $key,
873 self::scalar_for_display( $settings[ $key ] ?? null ),
874 $source
875 )
876 );
877 }
878 return;
879 case 'override':
880 case 'revert':
881 $key = $args[1] ?? '';
882 $schema = $this->settings_schema();
883 if ( '' === $key || ! array_key_exists( $key, $schema ) ) {
884 \WP_CLI::error( 'Unknown setting: ' . ( '' === $key ? '(none given)' : $key ) . '. Run `wp xspeed objcache status` for the list.' );
885 }
886 $on = 'override' === $action;
887 if ( $on && null === \XSpeed\Settings_Manager::constant_source( $schema[ $key ] ) ) {
888 \WP_CLI::error( sprintf( '"%s" is not defined in wp-config.php, so there is nothing to override.', $key ) );
889 }
890 /*
891 * Reverting hands a field BACK to the host, so it needs a host
892 * define to hand it back to, and it has to REMOVE our own
893 * define rather than only dropping the override entry -- ours
894 * outranks the host's, so leaving it in place meant the field
895 * kept our value and a later credential rotation was ignored
896 * for good. Both rules live in Settings_Manager::revert() so
897 * this command and the panel's button cannot drift. (#398)
898 */
899 $foreign = null;
900 if ( $on ) {
901 \XSpeed\Settings_Manager::set_override( self::SLUG, $key, true );
902 } else {
903 $reverted = \XSpeed\Settings_Manager::revert( self::SLUG, $key );
904 if ( is_wp_error( $reverted ) ) {
905 \WP_CLI::error(
906 'xspeed_nothing_to_revert' === $reverted->get_error_code()
907 ? sprintf(
908 '"%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 with `wp xspeed objcache set %s <value>`.',
909 $key,
910 $key
911 )
912 : $reverted->get_error_message()
913 );
914 }
915 $foreign = $reverted;
916 }
917
918 $origin = \XSpeed\Settings_Manager::origins( self::SLUG )[ $key ] ?? array( 'source' => 'db' );
919 \WP_CLI::success(
920 $on
921 ? sprintf( '%s is now managed here. Set it with `wp xspeed objcache set %s <value>`.', $key, $key )
922 : sprintf( '%s handed back to your host\'s %s.', $key, (string) $foreign )
923 );
924 return;
925 case 'get':
926 $key = $args[1] ?? '';
927 if ( '' === $key || ! array_key_exists( $key, $this->settings_schema() ) ) {
928 \WP_CLI::error( 'Unknown setting: ' . ( '' === $key ? '(none given)' : $key ) . '. Run `wp xspeed objcache status` for the list.' );
929 }
930 // get_public(), so a credential is never printed to a terminal
931 // or captured in a CI log.
932 $settings = \XSpeed\Settings_Manager::get_public( self::SLUG );
933 \WP_CLI::log( self::scalar_for_display( $settings[ $key ] ?? null ) );
934 return;
935 case 'set':
936 $key = $args[1] ?? '';
937 if ( '' === $key || ! array_key_exists( $key, $this->settings_schema() ) ) {
938 \WP_CLI::error( 'Unknown setting: ' . ( '' === $key ? '(none given)' : $key ) . '. Run `wp xspeed objcache status` for the list.' );
939 }
940 if ( ! array_key_exists( 2, $args ) ) {
941 \WP_CLI::error( 'No value given. Usage: wp xspeed objcache set <key> <value>' );
942 }
943
944 // Fail loudly rather than writing a row that get() will never
945 // read back. A silent no-op is the worst outcome here: the
946 // automation reports success and nothing changed. (#398)
947 $locked = \XSpeed\Settings_Manager::locked_in_input( self::SLUG, array( $key => $args[2] ) );
948 if ( isset( $locked[ $key ] ) ) {
949 \WP_CLI::error(
950 sprintf(
951 '"%1$s" is defined in wp-config.php as %2$s, so it cannot be set here. Edit that constant, or run `wp xspeed objcache override %1$s` to manage it here instead.',
952 $key,
953 $locked[ $key ]
954 )
955 );
956 }
957
958 $this->update_settings( array( $key => $args[2] ) );
959
960 // Read back rather than echoing the input: coercion may have
961 // clamped or rejected it, and reporting the input would claim a
962 // write that did not land as typed.
963 //
964 // From the OPTION ROW, not get_public(): the save also rewrites
965 // our wp-config block, but PHP has already defined those
966 // constants for this request and cannot redefine them -- so
967 // get_public() would resolve the constant and report the value
968 // from BEFORE the write, making a successful save look ignored.
969 // The row is what the next request's block was built from. (#398)
970 $after = \XSpeed\Settings_Manager::get_public( self::SLUG, true );
971 \WP_CLI::success( $key . ' = ' . self::scalar_for_display( $after[ $key ] ?? null ) );
972 return;
973 case 'flush':
974 $ok = Object_Cache::flush();
975 $ok ? \WP_CLI::success( 'Flushed.' ) : \WP_CLI::error( 'Flush failed.' );
976 return;
977 case 'snippet':
978 \WP_CLI::log( Object_Cache::render_config_snippet( $this->get_settings() ) );
979 return;
980 case 'test':
981 $t = Object_Cache::test_connection( $this->get_settings() );
982 $t['ok'] ? \WP_CLI::success( $t['message'] ) : \WP_CLI::error( $t['message'] );
983 return;
984 case 'enable':
985 $r = Object_Cache::enable(
986 $this->get_settings(),
987 array( 'takeover' => ! empty( $assoc['takeover'] ) )
988 );
989 if ( ! $r['ok'] && ! empty( $r['needs_takeover'] ) && Object_Cache_Takeover::STRATEGY_REFUSE !== ( $r['owner']['strategy'] ?? '' ) ) {
990 // This handler also answers MCP (through Cli_Bridge), where
991 // the switch is an argument, not a flag.
992 \WP_CLI::error( $r['message'] . ' To switch, run again with --takeover (MCP: takeover: true).' );
993 }
994 $r['ok'] ? \WP_CLI::success( $r['message'] ) : \WP_CLI::error( $r['message'] );
995 return;
996 case 'disable':
997 $r = Object_Cache::disable( array( 'restore' => ! empty( $assoc['restore'] ) ) );
998 // Disabling worked; a restore that could not complete is a
999 // warning, not a failed command.
1000 if ( $r['ok'] && false === ( $r['restored'] ?? null ) ) {
1001 \WP_CLI::warning( $r['message'] );
1002 \WP_CLI::success( 'Object cache disabled.' );
1003 return;
1004 }
1005 $r['ok'] ? \WP_CLI::success( $r['message'] ) : \WP_CLI::error( $r['message'] );
1006 return;
1007 default:
1008 \WP_CLI::error( "Unknown action: $action" );
1009 }
1010 }
1011
1012 /**
1013 * Render one setting for a terminal. Bools read as true/false rather than
1014 * 1/"", and an empty string is shown as (empty) so a blank line is never
1015 * mistaken for a missing key.
1016 *
1017 * @param mixed $value Setting value, already masked if secret.
1018 */
1019 private static function scalar_for_display( $value ): string {
1020 if ( is_bool( $value ) ) {
1021 return $value ? 'true' : 'false';
1022 }
1023 if ( null === $value ) {
1024 return '(unset)';
1025 }
1026 if ( is_array( $value ) ) {
1027 return (string) wp_json_encode( $value );
1028 }
1029 $value = (string) $value;
1030 return '' === $value ? '(empty)' : $value;
1031 }
1032
1033 /**
1034 * The object cache is on when OUR drop-in is installed and actually
1035 * persisting -- not when a backend host is merely typed into the
1036 * settings. `detect()` reads the running instance, so a drop-in that is
1037 * installed but degraded (connected to nothing) correctly reports off
1038 * rather than claiming a cache the site is not getting. (#363)
1039 */
1040 public function is_active(): ?bool {
1041 $state = Object_Cache::detect();
1042 return ! empty( $state['persistent'] );
1043 }
1044
1045 /**
1046 * Configured is not the same as working, and the difference is the whole
1047 * point here -- a drop-in connected to nothing reports on to WordPress
1048 * while persisting no data. Report what is actually happening.
1049 */
1050 public function active_reason(): ?string {
1051 $state = Object_Cache::detect();
1052 if ( ! empty( $state['persistent'] ) ) {
1053 return __( 'The object cache drop-in is installed and storing data. This is measured from the running cache, not from the settings on this page.', 'xspeed' );
1054 }
1055 if ( ! empty( $state['degraded'] ) ) {
1056 return __( 'The drop-in is installed but is not storing anything, so this counts as off. Check the connection settings below.', 'xspeed' );
1057 }
1058 return __( 'No object cache is running. Entering a host below does not switch it on by itself -- the drop-in has to be installed and connect successfully.', 'xspeed' );
1059 }
1060 }
1061