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
← All changes | includes/class-module.php +530 -3 1.0.5 → 1.4.1 View file →
@@ -48,8 +48,24 @@
48 48 * supported `type` values (bool, int, string, enum, list, url). Each
49 49 * field declares `default` and optional `min` / `max` / `options` /
50 50 * `item_type`. Storage key is `xspeed_module_<slug>`.
51 51 *
52 + * A field may also declare `constants` -- an ordered list of wp-config.php
53 + * constant names that pin its value, most specific first:
54 + *
55 + * 'redis_host' => array(
56 + * 'type' => 'string',
57 + * 'default' => '127.0.0.1',
58 + * 'constants' => array( 'XSPEED_OC_HOST', 'WP_REDIS_HOST' ),
59 + * ),
60 + *
61 + * Resolution order is then: first DEFINED constant -> stored option ->
62 + * `default`. A constant defined as an empty string still wins -- it is an
63 + * answer, not an absence. A pinned field is never persisted, and writes
64 + * targeting it are refused rather than silently dropped; see
65 + * Settings_Manager::origins() / locked_in_input(). This lets a host
66 + * (xCloud provisioning Redis) configure a module with no admin visit. (#398)
67 + *
52 68 * @return array<string,array>
53 69 */
54 70 public function settings_schema(): array {
55 71 return array();
@@ -55,8 +71,41 @@
55 71 return array();
56 72 }
57 73
58 74 /**
75 + * Settings this module keeps at their own defaults when xSpeed cannot own
76 + * the page cache, even though they default to ON.
77 + *
78 + * Settings::conflict_safe_profile() switches off every bool so a
79 + * site that already has a caching plugin gets an xSpeed that does nothing
80 + * until asked. Two kinds of setting do not belong in that sweep: one where
81 + * OFF is the wrong answer (a consent requirement), and one that cannot act
82 + * at all while the feature above it is off, where writing false would
83 + * suggest a decision nobody made.
84 + *
85 + * Naming a field here is a decision, not a default: the sweep covers every
86 + * bool, so a field is only left alone because someone said so.
87 + *
88 + * @return string[] Field names from this module's settings_schema().
89 + */
90 + public function conflict_safe_exempt(): array {
91 + return array();
92 + }
93 +
94 + /**
95 + * Option keys a module stores OUTSIDE its settings_schema that must
96 + * survive a schema-driven save. Settings_Manager rebuilds the option
97 + * from the schema on get()/update(), which would otherwise drop these.
98 + * Example: the REST-cache module keeps its route `rules` array here so a
99 + * plain enabled/ttl save doesn't wipe the rules table. (FBS-82408)
100 + *
101 + * @return string[]
102 + */
103 + public function preserved_keys(): array {
104 + return array();
105 + }
106 +
107 + /**
59 108 * Schema migrations keyed by target version. Each value is a callable
60 109 * that receives the stored options array and returns the migrated
61 110 * array. Migrations run in version order on first load after upgrade.
62 111 *
@@ -99,18 +148,141 @@
99 148 'methods' => 'POST',
100 149 'callback' => array( $this, 'rest_update_settings' ),
101 150 'feature' => static::SLUG,
102 151 ),
152 + // Take a constant-pinned field back, or hand it to wp-config again.
153 + // On every module, because any field can declare `constants`. (#398)
154 + array(
155 + 'path' => '/override',
156 + 'methods' => 'POST',
157 + 'callback' => array( $this, 'rest_set_override' ),
158 + 'feature' => static::SLUG,
159 + ),
160 + // Per-field provenance on its own, for a panel that needs to
161 + // re-read it after an action that changes which fields are pinned
162 + // (enabling the object cache writes constants) without re-fetching
163 + // every module descriptor. (#398)
164 + array(
165 + 'path' => '/origins',
166 + 'methods' => 'GET',
167 + 'callback' => array( $this, 'rest_get_origins' ),
168 + ),
103 169 );
104 170 }
105 171
106 172 /**
173 + * Where each of this module's settings currently comes from.
174 + */
175 + public function rest_get_origins( \WP_REST_Request $request ) {
176 + return rest_ensure_response( Settings_Manager::origins( static::SLUG ) );
177 + }
178 +
179 + /**
180 + * Toggle a deliberate override of a constant-pinned field.
181 + *
182 + * Body: `{ "field": "redis_host", "override": true }`. Overriding does not
183 + * itself set a value -- it unlocks the field, and the admin's next save
184 + * writes it like any other setting. Reverting hands the field back to the
185 + * constant, which resumes winning immediately.
186 + */
187 + public function rest_set_override( \WP_REST_Request $request ) {
188 + if ( $this->is_license_locked() ) {
189 + return new \WP_Error(
190 + 'xspeed_license_required',
191 + sprintf(
192 + /* translators: %s: module slug. */
193 + __( '"%s" is a Pro module and this site has no valid license, so its settings cannot be changed.', 'xspeed' ),
194 + static::SLUG
195 + ),
196 + array( 'status' => 403 )
197 + );
198 + }
199 +
200 + $body = (array) $request->get_json_params();
201 + $field = isset( $body['field'] ) ? (string) $body['field'] : '';
202 + $on = ! empty( $body['override'] );
203 +
204 + $spec = $this->settings_schema()[ $field ] ?? null;
205 + if ( ! is_array( $spec ) ) {
206 + return new \WP_Error(
207 + 'xspeed_unknown_setting',
208 + sprintf(
209 + /* translators: %s: setting key. */
210 + __( 'Unknown setting: %s', 'xspeed' ),
211 + $field
212 + ),
213 + array( 'status' => 400 )
214 + );
215 + }
216 +
217 + // Overriding a field no constant pins is meaningless -- it is already
218 + // editable -- and would leave a stale entry that silently disables the
219 + // lock if a constant appeared later.
220 + if ( $on && null === Settings_Manager::constant_source( $spec ) ) {
221 + return new \WP_Error(
222 + 'xspeed_setting_not_pinned',
223 + sprintf(
224 + /* translators: %s: setting key. */
225 + __( '"%s" is not defined in wp-config.php, so there is nothing to override.', 'xspeed' ),
226 + $field
227 + ),
228 + array( 'status' => 409 )
229 + );
230 + }
231 +
232 + if ( $on ) {
233 + Settings_Manager::set_override( static::SLUG, $field, true );
234 + } else {
235 + /*
236 + * The full hand-back, not just dropping the override entry. Once we
237 + * have written our own define it outranks the host's, so clearing
238 + * the entry alone left the field on our stale value with no route
239 + * back -- the panel button did nothing while `wp xspeed objcache
240 + * revert`, which did the extra work inline, worked. Both now go
241 + * through Settings_Manager::revert(). (#398)
242 + */
243 + $reverted = Settings_Manager::revert( static::SLUG, $field );
244 + if ( is_wp_error( $reverted ) ) {
245 + return $reverted;
246 + }
247 + }
248 +
249 + if ( class_exists( '\\XSpeed\\Activity_Log' ) ) {
250 + Activity_Log::record(
251 + $on ? 'setting_override_taken' : 'setting_override_reverted',
252 + sprintf(
253 + $on
254 + /* translators: 1: setting key, 2: module slug. */
255 + ? __( 'Overrode "%1$s" on "%2$s" — the wp-config.php value no longer applies.', 'xspeed' )
256 + /* translators: 1: setting key, 2: module slug. */
257 + : __( 'Reverted "%1$s" on "%2$s" to the value set in wp-config.php.', 'xspeed' ),
258 + $field,
259 + static::SLUG
260 + ),
261 + Activity_Log::INFO
262 + );
263 + }
264 +
265 + return rest_ensure_response(
266 + array(
267 + 'ok' => true,
268 + 'field' => $field,
269 + 'override' => $on,
270 + 'settings' => Settings_Manager::get_public( static::SLUG ),
271 + 'origins' => Settings_Manager::origins( static::SLUG ),
272 + )
273 + );
274 + }
275 +
276 + /**
107 277 * Default GET handler — returns all settings (defaults + stored)
108 - * coerced against the schema. Modules can override but rarely need
109 - * to.
278 + * coerced against the schema, with secret fields masked. Uses the public
279 + * view (not get_settings()) so a credential never leaves in a REST payload;
280 + * the engine reads real values through get_settings()/get_setting(). (#115)
281 + * Modules can override but rarely need to.
110 282 */
111 283 public function rest_get_settings( \WP_REST_Request $request ) {
112 - return rest_ensure_response( $this->get_settings() );
284 + return rest_ensure_response( Settings_Manager::get_public( static::SLUG ) );
113 285 }
114 286
115 287 /**
116 288 * Default POST handler — validates the JSON body against the
@@ -121,12 +293,124 @@
121 293 $params = $request->get_json_params();
122 294 if ( ! is_array( $params ) ) {
123 295 $params = $request->get_params();
124 296 }
297 +
298 + // Say so, rather than returning 200 over a write that didn't happen.
299 + // update_settings() enforces the gate on every surface; REST is the
300 + // one with an error channel, so it reports the reason. (#143)
301 + if ( $this->is_license_locked() ) {
302 + return new \WP_Error(
303 + 'xspeed_license_required',
304 + sprintf(
305 + /* translators: %s: module slug. */
306 + __( '"%s" is a Pro module and this site has no valid license, so its settings cannot be changed.', 'xspeed' ),
307 + static::SLUG
308 + ),
309 + array( 'status' => 403 )
310 + );
311 + }
312 +
313 + /*
314 + * A module another module has taken over from.
315 + *
316 + * Distinct from the licence gate above: that one says "you have not
317 + * paid for this", and this one says "something else on this site is
318 + * already doing it, and running both would be worse than running
319 + * either". The Cloudflare module behind the Cloudflare Enterprise
320 + * edge is the case it was written for — two proxy layers, each with
321 + * its own copy, and purges that reach one of them.
322 + *
323 + * Only a write that turns the module ON is refused. Turning it off,
324 + * or changing anything else, is exactly what somebody resolving the
325 + * conflict needs to be able to do.
326 + */
327 + $blocked = $this->blocked_by();
328 + if ( null !== $blocked && ! empty( $params['enabled'] ) ) {
329 + return new \WP_Error(
330 + 'xspeed_module_blocked',
331 + $blocked,
332 + array( 'status' => 409 )
333 + );
334 + }
335 +
336 + // A field pinned by a wp-config.php constant cannot be written. Saying
337 + // so beats a 200 over a write that silently did nothing -- and naming
338 + // the constant tells the caller where to go and change it. (#398)
339 + $locked = Settings_Manager::locked_in_input( static::SLUG, is_array( $params ) ? $params : array() );
340 + if ( ! empty( $locked ) ) {
341 + $pairs = array();
342 + foreach ( $locked as $field => $constant ) {
343 + $pairs[] = $field . ' (' . $constant . ')';
344 + }
345 + return new \WP_Error(
346 + 'xspeed_setting_defined_in_wp_config',
347 + sprintf(
348 + /* translators: %s: comma-separated list of "field (CONSTANT_NAME)" pairs. */
349 + __( 'These settings are defined in wp-config.php and cannot be changed here: %s. Edit the constant, or remove it to manage the setting from this screen.', 'xspeed' ),
350 + implode( ', ', $pairs )
351 + ),
352 + array(
353 + 'status' => 409,
354 + 'fields' => $locked,
355 + )
356 + );
357 + }
358 +
125 359 return rest_ensure_response( $this->update_settings( $params ) );
126 360 }
127 361
128 362 /**
363 + * Why this module may not be switched on right now, or null.
364 + *
365 + * A seam for one module to stand another down. Free answers null for
366 + * everything and never blocks anything itself — it has no opinion about
367 + * which modules conflict, and an add-on that knows the answer says so.
368 + *
369 + * The reason is shown to the customer, so it says what to do rather than
370 + * naming an internal state.
371 + */
372 + final public function blocked_by(): ?string {
373 + /**
374 + * Filter: xspeed_module_blocked_by
375 + *
376 + * @param string|null $reason Why this module cannot be enabled.
377 + * @param string $slug The module being asked about.
378 + */
379 + $reason = apply_filters( 'xspeed_module_blocked_by', null, static::SLUG );
380 + return is_string( $reason ) && '' !== $reason ? $reason : null;
381 + }
382 +
383 + /**
384 + * Is this a Pro module whose settings are locked for want of a licence?
385 + *
386 + * Shared by the REST handler and the write guard so the two can never
387 + * disagree about what is locked.
388 + */
389 + final public function is_license_locked(): bool {
390 + // The licence module itself must stay writable — otherwise an expired
391 + // licence locks the user out of the very screen where a new key is
392 + // entered.
393 + if ( self::TIER_PRO !== $this->tier() || 'license' === static::SLUG ) {
394 + return false;
395 + }
396 +
397 + /**
398 + * Filter: xspeed_pro_licensed
399 + *
400 + * Answered by xspeed-pro — the same filter it already answers when
401 + * decorating module descriptors with the `locked` flag, so the write
402 + * gate and the UI lock can't disagree.
403 + *
404 + * Defaults to true so a Free-only install (where nothing hooks this)
405 + * is never gated by a question no one is present to answer.
406 + *
407 + * @param bool $licensed Whether Pro is licensed right now.
408 + */
409 + return ! (bool) apply_filters( 'xspeed_pro_licensed', true );
410 + }
411 +
412 + /**
129 413 * UI panel declarations consumed by the React dashboard via the
130 414 * bootstrap payload. Each entry: [
131 415 * 'section' => 'cache' | 'performance' | 'images' | ...,
132 416 * 'position' => int,
@@ -175,8 +459,147 @@
175 459 return array();
176 460 }
177 461
178 462 /**
463 + * Is this module actually doing something right now?
464 + *
465 + * "On" is not one shape across the plugin. Most modules carry an
466 + * `enabled` setting, but page caching lives in the GLOBAL option
467 + * (`xspeed_options.cache_enabled`), Minify and Lazy are on when any of
468 + * their individual flags is set, and MCP is on when it is connected.
469 + * The sidebar's "N on" badge counted only the literal `enabled` key, so
470 + * it under-reported: on a site with page caching, minification, lazy
471 + * loading and MCP all running it read "Cache 2 / Optimization 1" and
472 + * left the plugin's headline feature out of its own count. (#363)
473 + *
474 + * The default below keeps the historic behaviour for the modules that
475 + * genuinely do store `enabled`. A module whose "on" means something
476 + * else overrides this and answers for itself, which is what stops the
477 + * count drifting again the next time a module changes shape.
478 + *
479 + * Three-state on purpose:
480 + * true — on and doing work
481 + * false — off
482 + * null — no meaningful on/off (a status panel like Health). Callers
483 + * must exclude these rather than counting them as off.
484 + */
485 + public function is_active(): ?bool {
486 + $settings = $this->get_settings();
487 + return array_key_exists( 'enabled', $settings )
488 + ? (bool) $settings['enabled']
489 + : null;
490 + }
491 +
492 + /**
493 + * "On if any of my boolean flags is on" — the shape used by modules
494 + * that have no master switch, only a set of independent toggles
495 + * (Minify, Lazy, Bloat, Gzip).
496 + *
497 + * Derived from the module's OWN schema rather than a hardcoded key
498 + * list, so adding a flag to a module cannot silently fall out of its
499 + * active state the way a literal list would. Only `bool` fields count:
500 + * an int like `eager_first_n` or a list like `excluded_images` is
501 + * configuration for a feature, not evidence the feature is on.
502 + *
503 + * Returns null when the module declares no boolean flags at all, so a
504 + * caller can exclude it rather than record a misleading false.
505 + */
506 + final protected function any_bool_flag_on(): ?bool {
507 + $schema = $this->settings_schema();
508 + $settings = $this->get_settings();
509 +
510 + $found = false;
511 + foreach ( $schema as $key => $spec ) {
512 + if ( 'bool' !== ( $spec['type'] ?? '' ) ) {
513 + continue;
514 + }
515 + $found = true;
516 + if ( ! empty( $settings[ $key ] ) ) {
517 + return true;
518 + }
519 + }
520 +
521 + return $found ? false : null;
522 + }
523 +
524 + /**
525 + * Why is this module reported on or off? One short sentence for the (i)
526 + * beside the status pill.
527 + *
528 + * "On" is not one shape (see is_active()), so without this the pill is a
529 + * bare assertion the user cannot check. It is most opaque exactly where
530 + * the rule is least obvious: Media Optimization reads "On" while its two
531 + * most prominent switches, Lazy-load Images and Iframes, are both off --
532 + * because three other flags are on. The reason names them.
533 + *
534 + * Computed server-side alongside is_active() so the explanation cannot
535 + * drift from the verdict it explains. Returning null means "no reason to
536 + * add" and the (i) is not rendered.
537 + */
538 + public function active_reason(): ?string {
539 + // A module with its own `enabled` switch needs no explaining: the
540 + // pill and the switch say the same thing, and an (i) that only
541 + // restates the pill is noise on every one of those pages. Silence
542 + // here is what keeps the (i) meaningful where it does appear.
543 + if ( array_key_exists( 'enabled', $this->get_settings() ) ) {
544 + return null;
545 + }
546 +
547 + return $this->bool_flag_reason();
548 + }
549 +
550 + /**
551 + * What the customer still needs before this module can work, when that is
552 + * something to buy or activate, in a few words ("Add-on needed").
553 + *
554 + * The dashboard shows it in the page header in place of the On/Off pill,
555 + * because "Off" on a module nobody can switch on yet reads as a setting
556 + * left off. Null, the default, keeps the pill.
557 + */
558 + public function status_label(): ?string {
559 + return null;
560 + }
561 +
562 + /**
563 + * The reason text for a module whose "on" is "any of my flags is on".
564 + *
565 + * Names the specific settings that are on, using their schema labels, so
566 + * the user can go and look at them rather than take the pill on trust.
567 + * Shared by every flag-based module for one consistent sentence.
568 + */
569 + final protected function bool_flag_reason(): ?string {
570 + $schema = $this->settings_schema();
571 + $settings = $this->get_settings();
572 +
573 + $on = array();
574 + foreach ( $schema as $key => $spec ) {
575 + if ( 'bool' !== ( $spec['type'] ?? '' ) ) {
576 + continue;
577 + }
578 + if ( ! empty( $settings[ $key ] ) ) {
579 + $on[] = $spec['label'] ?? $key;
580 + }
581 + }
582 +
583 + // No boolean flags at all means the module has no on/off to explain
584 + // (a status panel like Health). Mirrors any_bool_flag_on() returning
585 + // null: no verdict, so no reason.
586 + if ( null === $this->any_bool_flag_on() ) {
587 + return null;
588 + }
589 +
590 + if ( empty( $on ) ) {
591 + return __( 'This module has no single on/off switch. It counts as on when any of its settings is on, and none currently is.', 'xspeed' );
592 + }
593 +
594 + return sprintf(
595 + /* translators: %s: comma-separated list of setting labels that are switched on. */
596 + __( 'This module has no single on/off switch. It counts as on because these settings are on: %s.', 'xspeed' ),
597 + implode( ', ', $on )
598 + );
599 + }
600 +
601 + /**
179 602 * WP-CLI command definitions. Each entry: [
180 603 * 'name' => 'xspeed cache purge',
181 604 * 'callback' => callable,
182 605 * 'synopsis' => array, // wp-cli synopsis spec
@@ -251,14 +674,118 @@
251 674 $opts = Settings_Manager::get( static::SLUG );
252 675 return array_key_exists( $key, $opts ) ? $opts[ $key ] : $default;
253 676 }
254 677
678 + /**
679 + * Read one boolean flag WITHOUT building the settings schema.
680 + *
681 + * `get_setting()` routes through `Settings_Manager::get()`, which calls
682 + * `settings_schema()` to know the defaults and types. That schema
683 + * declares its `label` / `description` through `__()` — correct, they are
684 + * UI copy a translator has to reach. But modules that read their own
685 + * settings from `boot()` do so on `plugins_loaded`, before `init`, where
686 + * text domains load. Building the schema there translates every label too
687 + * early: WordPress 6.7+ emits `_load_textdomain_just_in_time` on each
688 + * request, and the labels resolve against a domain that is not loaded
689 + * yet, which silently defeats the translation.
690 + *
691 + * A boot-time gate only ever asks "is this feature switched on", so it
692 + * needs the stored value, not the schema. This reads the module's option
693 + * directly and casts. `$default` is what applies when the key was never
694 + * written — pass the same value the schema declares as that field's
695 + * default, or the two disagree on a fresh install.
696 + *
697 + * Use ONLY for a boot-time on/off check. Anything that needs coercion,
698 + * schema defaults, or a non-boolean value must keep using
699 + * `get_setting()` / `get_settings()`.
700 + *
701 + * @param string $key Field name in this module's settings.
702 + * @param bool $default Value when the key has never been stored.
703 + */
704 + final protected function flag_at_boot( string $key, bool $default = false ): bool {
705 + return (bool) $this->setting_at_boot( $key, $default );
706 + }
707 +
708 + /**
709 + * Raw stored value for one setting, WITHOUT building the schema. The
710 + * general form of `flag_at_boot()` — see that method for why boot-time
711 + * reads must not touch `settings_schema()`.
712 + *
713 + * No type coercion is applied, so pass a `$default` of the type the
714 + * caller expects and cast the result at the call site. Same rule: use
715 + * ONLY from code that runs before `init`.
716 + *
717 + * @param string $key Field name in this module's settings.
718 + * @param mixed $default Value when the key has never been stored.
719 + * @return mixed
720 + */
721 + final protected function setting_at_boot( string $key, $default = null ) {
722 + $stored = get_option( Settings_Manager::OPTION_PREFIX . static::SLUG, array() );
723 + if ( ! is_array( $stored ) || ! array_key_exists( $key, $stored ) ) {
724 + return $default;
725 + }
726 + return $stored[ $key ];
727 + }
728 +
255 729 final public function get_settings(): array {
256 730 return Settings_Manager::get( static::SLUG );
257 731 }
258 732
259 733 final public function update_settings( array $input ): array {
734 + $refusal = $this->license_write_refusal( $input );
735 + if ( null !== $refusal ) {
736 + return $refusal;
737 + }
260 738 return Settings_Manager::update( static::SLUG, $input );
739 + }
740 +
741 + /**
742 + * Enforce the Pro licence gate on writes, or null to allow the write.
743 + *
744 + * The dashboard renders a Pro module as `locked` without a valid licence
745 + * and refuses to toggle it, but that flag is applied by the
746 + * `xspeed_module_descriptor` filter — a decoration on the payload the UI
747 + * reads. It never reached the write path, so `POST /xspeed/v1/<module>`
748 + * with `{"enabled": true}` returned 200 and persisted, and CLI/MCP hit the
749 + * same unguarded callbacks.
750 + *
751 + * That mattered because module availability is decided by
752 + * `Tier_Registry::is_available()`, which asks only whether the Pro plugin
753 + * is LOADED — never whether it is licensed. So a Pro module boots and runs
754 + * its hooks regardless, and a persisted `enabled: true` genuinely turns the
755 + * feature on. This was not cosmetic. (#143)
756 + *
757 + * Free never references Pro: it asks through `xspeed_pro_licensed`, the
758 + * same filter Pro already answers for the descriptor. With no Pro plugin
759 + * present nothing hooks it, the default `true` stands, and Free modules
760 + * are unaffected either way.
761 + *
762 + * Reads stay open — the dashboard must still be able to GET settings to
763 + * render the locked state at all.
764 + *
765 + * @param array $input Proposed setting values.
766 + * @return array|null Current public settings when refused, else null.
767 + */
768 + private function license_write_refusal( array $input ): ?array {
769 + if ( ! $this->is_license_locked() ) {
770 + return null;
771 + }
772 +
773 + Activity_Log::record(
774 + 'license_write_refused',
775 + sprintf(
776 + /* translators: %s: module slug. */
777 + __( 'Refused a settings write to the Pro module "%s" — no valid license.', 'xspeed' ),
778 + static::SLUG
779 + ),
780 + Activity_Log::WARN
781 + );
782 +
783 + // Return the unchanged public settings rather than throwing: callers
784 + // expect the module's settings back, and the dashboard already renders
785 + // this module as locked. REST surfaces the refusal explicitly in
786 + // rest_update_settings(), which has a WP_Error channel.
787 + return Settings_Manager::get_public( static::SLUG );
261 788 }
262 789
263 790 final public function slug(): string {
264 791 return static::SLUG;