PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.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 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / ObjectCache / ObjectCacheModule.php

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

390 lines 13.5 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
24 final class ObjectCacheModule extends Module {
25
26 public const SLUG = 'object-cache';
27 public const TIER = self::TIER_FREE;
28 public const VERSION = '1.1.0';
29
30 public function ui_metadata(): array {
31 return array(
32 'label' => 'Object Cache',
33 'icon' => 'Server',
34 'description' => 'Configure a persistent object cache (Redis / Memcached) and generate a paste-ready wp-config.php snippet.',
35 'custom_panel' => 'ObjectCachePanel',
36 );
37 }
38
39 /**
40 * @inheritDoc
41 *
42 * Nothing exempt. Object caching sits beside a page cache and competes for
43 * nothing, and the drop-in is only re-synced when ours is already installed
44 * (see boot()), so the flag installs nothing on its own — all true, and all
45 * a weak reason to leave a switch on that nobody asked for.
46 */
47 public function conflict_safe_exempt(): array {
48 return array();
49 }
50
51 public function settings_schema(): array {
52 return array(
53 'backend' => array(
54 'type' => 'enum',
55 'default' => 'redis',
56 'options' => array( 'redis', 'memcached' ),
57 'option_labels' => array(
58 'redis' => 'Redis',
59 'memcached' => 'Memcached',
60 ),
61 'label' => 'Backend',
62 'description' => 'Which cache server you intend to use. Affects the generated wp-config snippet.',
63 ),
64 'redis_host' => array(
65 'type' => 'string',
66 'default' => '127.0.0.1',
67 'label' => 'Redis Host',
68 'description' => 'Hostname or IP of the Redis server. Use 127.0.0.1 for a local socket on the same machine as PHP.',
69 ),
70 'redis_port' => array(
71 'type' => 'int',
72 'default' => 6379,
73 'min' => 1,
74 'max' => 65535,
75 'label' => 'Redis Port',
76 'description' => 'Default Redis port is 6379.',
77 ),
78 'redis_user' => array(
79 'type' => 'string',
80 'default' => '',
81 'label' => 'Redis User',
82 'description' => 'Optional. Set this only if your host provisioned a dedicated Redis ACL user (Redis 6+) — e.g. some managed hosts issue a Redis User alongside the password. Leave blank to authenticate as the default user (legacy password-only Redis).',
83 ),
84 'redis_password' => array(
85 'type' => 'secret',
86 'default' => '',
87 'label' => 'Redis Password',
88 'description' => 'Leave blank if your Redis server runs without auth.',
89 ),
90 'redis_database' => array(
91 'type' => 'int',
92 'default' => 0,
93 'min' => 0,
94 'max' => 15,
95 'label' => 'Redis Database',
96 'description' => 'Redis logical DB number (0-15). Use a dedicated DB per site if Redis is shared.',
97 ),
98 'memcached_host' => array(
99 'type' => 'string',
100 'default' => '127.0.0.1',
101 'label' => 'Memcached Host',
102 'description' => 'Used when Backend = Memcached.',
103 ),
104 'memcached_port' => array(
105 'type' => 'int',
106 'default' => 11211,
107 'min' => 1,
108 'max' => 65535,
109 'label' => 'Memcached Port',
110 'description' => 'Default Memcached port is 11211.',
111 ),
112 'key_prefix' => array(
113 'type' => 'string',
114 'default' => '',
115 'label' => 'Cache Key Prefix',
116 'description' => 'Unique salt for this site\'s cache keys. Critical when multiple WP sites share one Redis/Memcached server. On ACL/namespaced Redis (e.g. xCloud), this MUST match the host\'s "Redis Object Cache Key" — otherwise cache writes are denied (NOPERM) and nothing persists.',
117 ),
118 'connection_timeout' => array(
119 'type' => 'int',
120 'default' => 1,
121 'min' => 0,
122 'max' => 60,
123 'label' => 'Connection Timeout (seconds)',
124 'unit' => 'seconds',
125 'description' => 'How long to wait for a connection. Keep low (1-2s) so a misconfigured cache never stalls the page.',
126 ),
127 'persistent' => array(
128 'type' => 'bool',
129 'default' => true,
130 'label' => 'Persistent Connections',
131 'description' => 'Reuse the connection across PHP requests when supported. Generally a win unless the cache server complains about idle connections.',
132 ),
133 );
134 }
135
136 /**
137 * Encrypt the pre-1.1.0 plaintext redis_password on upgrade — it became a
138 * `secret`-typed field (encrypted at rest). Idempotent. (#115)
139 */
140 public function migrations(): array {
141 return array(
142 '1.1.0' => static function ( array $opts ): array {
143 if ( isset( $opts['redis_password'] ) && is_string( $opts['redis_password'] ) && '' !== $opts['redis_password'] ) {
144 $opts['redis_password'] = \XSpeed\Settings_Manager::encrypt_for_storage( $opts['redis_password'] );
145 }
146 return $opts;
147 },
148 );
149 }
150
151 public function boot(): void {
152 // Keep the deployed drop-in in sync with the shipped template. It is
153 // copied into wp-content/object-cache.php on enable and then never
154 // touched again — so a fix shipped in a plugin update (e.g. the
155 // stale-alloptions eviction on failed backend writes, issue #41)
156 // would never reach existing installs. Version-gated so the file
157 // comparison runs once per plugin version, not on every admin load.
158 add_action(
159 'admin_init',
160 static function (): void {
161 if ( get_option( 'xspeed_oc_dropin_synced', '' ) === XSPEED_VERSION ) {
162 return;
163 }
164 if ( Object_Cache::is_our_dropin_present() ) {
165 Object_Cache::install_dropin();
166 }
167 update_option( 'xspeed_oc_dropin_synced', XSPEED_VERSION );
168 }
169 );
170 }
171
172 public function rest_routes(): array {
173 $default = parent::rest_routes();
174 return array_merge(
175 $default,
176 array(
177 array(
178 'path' => '/detect',
179 'methods' => 'GET',
180 'callback' => array( $this, 'rest_detect' ),
181 ),
182 array(
183 'path' => '/flush',
184 'methods' => 'POST',
185 'callback' => array( $this, 'rest_flush' ),
186 ),
187 array(
188 'path' => '/snippet',
189 'methods' => 'GET',
190 'callback' => array( $this, 'rest_snippet' ),
191 ),
192 array(
193 'path' => '/test-connection',
194 'methods' => 'POST',
195 'callback' => array( $this, 'rest_test_connection' ),
196 ),
197 array(
198 'path' => '/enable',
199 'methods' => 'POST',
200 'callback' => array( $this, 'rest_enable' ),
201 ),
202 array(
203 'path' => '/disable',
204 'methods' => 'POST',
205 'callback' => array( $this, 'rest_disable' ),
206 ),
207 )
208 );
209 }
210
211 public function rest_detect( \WP_REST_Request $request ) {
212 return rest_ensure_response( Object_Cache::detect() );
213 }
214
215 public function rest_flush( \WP_REST_Request $request ) {
216 $ok = Object_Cache::flush();
217 if ( $ok && class_exists( '\\XSpeed\\Activity_Log' ) ) {
218 \XSpeed\Activity_Log::record(
219 'object_cache_flushed',
220 'Object cache flushed.',
221 \XSpeed\Activity_Log::INFO
222 );
223 }
224 return rest_ensure_response( array( 'ok' => $ok ) );
225 }
226
227 public function rest_snippet( \WP_REST_Request $request ) {
228 return rest_ensure_response(
229 array( 'snippet' => Object_Cache::render_config_snippet( $this->get_settings() ) )
230 );
231 }
232
233 /**
234 * Merge any settings sent in the request body over the saved settings, so
235 * the UI can "Test connection" with unsaved values. Only known keys pass.
236 */
237 private function settings_with_overrides( \WP_REST_Request $request ): array {
238 $body = $request->get_json_params();
239 return self::merge_overrides(
240 $this->get_settings(),
241 is_array( $body ) ? $body : array(),
242 $this->settings_schema()
243 );
244 }
245
246 /**
247 * Overlay request-body values onto the stored settings for a one-off "Test
248 * connection" — but NEVER let a masked secret echoed from the panel overwrite
249 * the real stored value. The panel holds `Redi••••CRET`; without this guard,
250 * clicking Test connection authenticates Redis with the mask and a correct
251 * password reports as wrong. A genuinely new (typed) password still applies,
252 * and an explicit empty value still tests the no-auth case. Static + pure so
253 * it's unit-testable without a REST request. (QA B3)
254 *
255 * @param array<string,mixed> $settings Stored, decrypted settings.
256 * @param array<string,mixed> $body Request overrides.
257 * @param array<string,array> $schema The module schema (for secret detection).
258 * @return array<string,mixed>
259 */
260 public static function merge_overrides( array $settings, array $body, array $schema ): array {
261 foreach ( $settings as $key => $value ) {
262 if ( ! array_key_exists( $key, $body ) ) {
263 continue;
264 }
265 if ( isset( $schema[ $key ] )
266 && \XSpeed\Settings_Manager::is_secret_field( $key, $schema[ $key ] )
267 && \XSpeed\Settings_Manager::is_masked_secret( (string) $body[ $key ] ) ) {
268 continue;
269 }
270 $settings[ $key ] = $body[ $key ];
271 }
272 return $settings;
273 }
274
275 public function rest_test_connection( \WP_REST_Request $request ) {
276 return rest_ensure_response( Object_Cache::test_connection( $this->settings_with_overrides( $request ) ) );
277 }
278
279 public function rest_enable( \WP_REST_Request $request ) {
280 // Persist any settings sent with the enable call first, then act on them.
281 $body = $request->get_json_params();
282 if ( is_array( $body ) && ! empty( $body ) ) {
283 \XSpeed\Settings_Manager::update( self::SLUG, $body );
284 }
285 $result = Object_Cache::enable( $this->get_settings() );
286
287 if ( $result['ok'] && class_exists( '\\XSpeed\\Activity_Log' ) ) {
288 \XSpeed\Activity_Log::record(
289 'object_cache_enabled',
290 'Object cache enabled (' . ( $result['test']['backend'] ?? '' ) . ').',
291 \XSpeed\Activity_Log::INFO
292 );
293 }
294 return rest_ensure_response( $result );
295 }
296
297 public function rest_disable( \WP_REST_Request $request ) {
298 $result = Object_Cache::disable();
299 if ( $result['ok'] && class_exists( '\\XSpeed\\Activity_Log' ) ) {
300 \XSpeed\Activity_Log::record(
301 'object_cache_disabled',
302 'Object cache disabled.',
303 \XSpeed\Activity_Log::INFO
304 );
305 }
306 return rest_ensure_response( $result );
307 }
308
309 public function cli_commands(): array {
310 return array(
311 array(
312 'name' => 'xspeed objcache',
313 'callback' => array( $this, 'cli_handler' ),
314 'shortdesc' => 'Show object cache status, flush, or print the wp-config snippet.',
315 '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.',
316 'synopsis' => array(
317 array(
318 'type' => 'positional',
319 'name' => 'action',
320 'options' => array( 'status', 'flush', 'snippet', 'enable', 'disable', 'test' ),
321 'optional' => true,
322 ),
323 ),
324 ),
325 );
326 }
327
328 public function cli_handler( array $args, array $assoc ): void {
329 $action = $args[0] ?? 'status';
330 switch ( $action ) {
331 case 'status':
332 $d = Object_Cache::detect();
333 \WP_CLI::log( 'drop-in installed: ' . ( $d['drop_in_installed'] ? 'yes' : 'no' ) );
334 \WP_CLI::log( 'label: ' . $d['drop_in_label'] );
335 \WP_CLI::log( 'backend: ' . $d['backend'] );
336 \WP_CLI::log( 'ext object cache: ' . ( $d['wp_cache_active'] ? 'yes' : 'no' ) );
337 return;
338 case 'flush':
339 $ok = Object_Cache::flush();
340 $ok ? \WP_CLI::success( 'Flushed.' ) : \WP_CLI::error( 'Flush failed.' );
341 return;
342 case 'snippet':
343 \WP_CLI::log( Object_Cache::render_config_snippet( $this->get_settings() ) );
344 return;
345 case 'test':
346 $t = Object_Cache::test_connection( $this->get_settings() );
347 $t['ok'] ? \WP_CLI::success( $t['message'] ) : \WP_CLI::error( $t['message'] );
348 return;
349 case 'enable':
350 $r = Object_Cache::enable( $this->get_settings() );
351 $r['ok'] ? \WP_CLI::success( $r['message'] ) : \WP_CLI::error( $r['message'] );
352 return;
353 case 'disable':
354 $r = Object_Cache::disable();
355 $r['ok'] ? \WP_CLI::success( $r['message'] ) : \WP_CLI::error( $r['message'] );
356 return;
357 default:
358 \WP_CLI::error( "Unknown action: $action" );
359 }
360 }
361
362 /**
363 * The object cache is on when OUR drop-in is installed and actually
364 * persisting -- not when a backend host is merely typed into the
365 * settings. `detect()` reads the running instance, so a drop-in that is
366 * installed but degraded (connected to nothing) correctly reports off
367 * rather than claiming a cache the site is not getting. (#363)
368 */
369 public function is_active(): ?bool {
370 $state = Object_Cache::detect();
371 return ! empty( $state['persistent'] );
372 }
373
374 /**
375 * Configured is not the same as working, and the difference is the whole
376 * point here -- a drop-in connected to nothing reports on to WordPress
377 * while persisting no data. Report what is actually happening.
378 */
379 public function active_reason(): ?string {
380 $state = Object_Cache::detect();
381 if ( ! empty( $state['persistent'] ) ) {
382 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' );
383 }
384 if ( ! empty( $state['degraded'] ) ) {
385 return __( 'The drop-in is installed but is not storing anything, so this counts as off. Check the connection settings below.', 'xspeed' );
386 }
387 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' );
388 }
389 }
390