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 / class-module.php

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

535 lines 17.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Module abstract base class.
4 *
5 * Every feature in xSpeed (Free or Pro) extends this class. The contract is
6 * documented in IMPLEMENTATION.md §1.1. A Module is a self-contained unit
7 * that declares its tier, settings schema, REST routes, UI panels, CLI
8 * commands, conflicts, and lifecycle hooks in one place — so moving a
9 * feature between Free and Pro is a `git mv` + flipping the TIER constant,
10 * with no call-site changes.
11 *
12 * Concrete modules MUST:
13 * - Set the SLUG class constant.
14 * - Set the TIER class constant (TIER_FREE or TIER_PRO).
15 * - Set the VERSION class constant.
16 *
17 * @package XSpeed
18 */
19
20 namespace XSpeed;
21
22 defined( 'ABSPATH' ) || exit;
23
24 abstract class Module {
25
26 public const TIER_FREE = 'free';
27 public const TIER_PRO = 'pro';
28
29 /**
30 * Concrete modules override these three constants.
31 */
32 public const SLUG = '';
33 public const TIER = self::TIER_FREE;
34 public const VERSION = '1.0.0';
35
36 /**
37 * Other module slugs this module needs at boot. Resolved by
38 * Module_Registry via topological sort; missing deps fail loudly.
39 *
40 * @return string[]
41 */
42 public function dependencies(): array {
43 return array();
44 }
45
46 /**
47 * Typed settings schema. See Settings_Manager::validate() for the
48 * supported `type` values (bool, int, string, enum, list, url). Each
49 * field declares `default` and optional `min` / `max` / `options` /
50 * `item_type`. Storage key is `xspeed_module_<slug>`.
51 *
52 * @return array<string,array>
53 */
54 public function settings_schema(): array {
55 return array();
56 }
57
58 /**
59 * Settings this module keeps at their own defaults when xSpeed cannot own
60 * the page cache, even though they default to ON.
61 *
62 * Settings::conflict_safe_profile() switches off every bool so a
63 * site that already has a caching plugin gets an xSpeed that does nothing
64 * until asked. Two kinds of setting do not belong in that sweep: one where
65 * OFF is the wrong answer (a consent requirement), and one that cannot act
66 * at all while the feature above it is off, where writing false would
67 * suggest a decision nobody made.
68 *
69 * Naming a field here is a decision, not a default: the sweep covers every
70 * bool, so a field is only left alone because someone said so.
71 *
72 * @return string[] Field names from this module's settings_schema().
73 */
74 public function conflict_safe_exempt(): array {
75 return array();
76 }
77
78 /**
79 * Option keys a module stores OUTSIDE its settings_schema that must
80 * survive a schema-driven save. Settings_Manager rebuilds the option
81 * from the schema on get()/update(), which would otherwise drop these.
82 * Example: the REST-cache module keeps its route `rules` array here so a
83 * plain enabled/ttl save doesn't wipe the rules table. (FBS-82408)
84 *
85 * @return string[]
86 */
87 public function preserved_keys(): array {
88 return array();
89 }
90
91 /**
92 * Schema migrations keyed by target version. Each value is a callable
93 * that receives the stored options array and returns the migrated
94 * array. Migrations run in version order on first load after upgrade.
95 *
96 * @return array<string,callable>
97 */
98 public function migrations(): array {
99 return array();
100 }
101
102 /**
103 * REST routes the module owns. Paths are prefixed with
104 * `/xspeed/v1/<slug>/` by Rest_Manager; declare without the prefix.
105 * `permission_callback` is wrapped automatically with a final cap
106 * check + tier gate, so modules don't need to repeat that boilerplate
107 * — but they MUST still declare a sensible callback.
108 *
109 * Default impl returns the standard GET + POST pair for modules that
110 * declare a settings_schema. Modules that need extra endpoints can
111 * extend the array. Modules with truly custom REST should override
112 * entirely and skip parent::rest_routes().
113 *
114 * Per SETTINGS.md §5.1 every module's settings live at:
115 * GET /xspeed/v1/<slug>/ → current settings
116 * POST /xspeed/v1/<slug>/ → partial patch, returns updated settings
117 *
118 * @return array[]
119 */
120 public function rest_routes(): array {
121 if ( empty( $this->settings_schema() ) ) {
122 return array();
123 }
124 return array(
125 array(
126 'path' => '/',
127 'methods' => 'GET',
128 'callback' => array( $this, 'rest_get_settings' ),
129 ),
130 array(
131 'path' => '/',
132 'methods' => 'POST',
133 'callback' => array( $this, 'rest_update_settings' ),
134 'feature' => static::SLUG,
135 ),
136 );
137 }
138
139 /**
140 * Default GET handler — returns all settings (defaults + stored)
141 * coerced against the schema, with secret fields masked. Uses the public
142 * view (not get_settings()) so a credential never leaves in a REST payload;
143 * the engine reads real values through get_settings()/get_setting(). (#115)
144 * Modules can override but rarely need to.
145 */
146 public function rest_get_settings( \WP_REST_Request $request ) {
147 return rest_ensure_response( Settings_Manager::get_public( static::SLUG ) );
148 }
149
150 /**
151 * Default POST handler — validates the JSON body against the
152 * schema, persists, returns the post-update settings. Unknown keys
153 * are stripped by Settings_Manager.
154 */
155 public function rest_update_settings( \WP_REST_Request $request ) {
156 $params = $request->get_json_params();
157 if ( ! is_array( $params ) ) {
158 $params = $request->get_params();
159 }
160
161 // Say so, rather than returning 200 over a write that didn't happen.
162 // update_settings() enforces the gate on every surface; REST is the
163 // one with an error channel, so it reports the reason. (#143)
164 if ( $this->is_license_locked() ) {
165 return new \WP_Error(
166 'xspeed_license_required',
167 sprintf(
168 /* translators: %s: module slug. */
169 __( '"%s" is a Pro module and this site has no valid license, so its settings cannot be changed.', 'xspeed' ),
170 static::SLUG
171 ),
172 array( 'status' => 403 )
173 );
174 }
175
176 return rest_ensure_response( $this->update_settings( $params ) );
177 }
178
179 /**
180 * Is this a Pro module whose settings are locked for want of a licence?
181 *
182 * Shared by the REST handler and the write guard so the two can never
183 * disagree about what is locked.
184 */
185 final public function is_license_locked(): bool {
186 // The licence module itself must stay writable — otherwise an expired
187 // licence locks the user out of the very screen where a new key is
188 // entered.
189 if ( self::TIER_PRO !== $this->tier() || 'license' === static::SLUG ) {
190 return false;
191 }
192
193 /**
194 * Filter: xspeed_pro_licensed
195 *
196 * Answered by xspeed-pro — the same filter it already answers when
197 * decorating module descriptors with the `locked` flag, so the write
198 * gate and the UI lock can't disagree.
199 *
200 * Defaults to true so a Free-only install (where nothing hooks this)
201 * is never gated by a question no one is present to answer.
202 *
203 * @param bool $licensed Whether Pro is licensed right now.
204 */
205 return ! (bool) apply_filters( 'xspeed_pro_licensed', true );
206 }
207
208 /**
209 * UI panel declarations consumed by the React dashboard via the
210 * bootstrap payload. Each entry: [
211 * 'section' => 'cache' | 'performance' | 'images' | ...,
212 * 'position' => int,
213 * 'component' => 'HealthCard' | 'TogglesList' | 'StatGrid' | 'Custom',
214 * 'props' => array,
215 * ]
216 *
217 * @return array[]
218 */
219 public function ui_panels(): array {
220 return array();
221 }
222
223 /**
224 * Sidebar / dashboard metadata. The React app uses these to render the
225 * module's nav entry. Override per module to set a friendly label and
226 * a lucide-react icon name (must be in the renderer's icon whitelist —
227 * see src/components/IconResolver.tsx).
228 *
229 * @return array{label:string,icon:string,description?:string,hidden?:bool}
230 */
231 public function ui_metadata(): array {
232 return array(
233 'label' => ucfirst( str_replace( '_', ' ', static::SLUG ) ),
234 'icon' => 'Square',
235 );
236 }
237
238 /**
239 * Dynamic in-panel notices (callouts) rendered above the schema form.
240 * Computed fresh on every dashboard load. Examples: nginx GZIP
241 * snippet when the server can't be auto-configured, "drop-in
242 * missing" warning when cache_enabled but no advanced-cache.php.
243 *
244 * Each entry: [
245 * 'tone' => 'info' | 'warn' | 'danger' | 'success',
246 * 'title' => 'Short heading.',
247 * 'body' => 'One- or two-sentence explanation.',
248 * 'snippet' => 'Optional verbatim code snippet rendered in a
249 * <pre> with a Copy button.',
250 * ]
251 *
252 * @return array[]
253 */
254 public function ui_notices(): array {
255 return array();
256 }
257
258 /**
259 * Is this module actually doing something right now?
260 *
261 * "On" is not one shape across the plugin. Most modules carry an
262 * `enabled` setting, but page caching lives in the GLOBAL option
263 * (`xspeed_options.cache_enabled`), Minify and Lazy are on when any of
264 * their individual flags is set, and MCP is on when it is connected.
265 * The sidebar's "N on" badge counted only the literal `enabled` key, so
266 * it under-reported: on a site with page caching, minification, lazy
267 * loading and MCP all running it read "Cache 2 / Optimization 1" and
268 * left the plugin's headline feature out of its own count. (#363)
269 *
270 * The default below keeps the historic behaviour for the modules that
271 * genuinely do store `enabled`. A module whose "on" means something
272 * else overrides this and answers for itself, which is what stops the
273 * count drifting again the next time a module changes shape.
274 *
275 * Three-state on purpose:
276 * true — on and doing work
277 * false — off
278 * null — no meaningful on/off (a status panel like Health). Callers
279 * must exclude these rather than counting them as off.
280 */
281 public function is_active(): ?bool {
282 $settings = $this->get_settings();
283 return array_key_exists( 'enabled', $settings )
284 ? (bool) $settings['enabled']
285 : null;
286 }
287
288 /**
289 * "On if any of my boolean flags is on" — the shape used by modules
290 * that have no master switch, only a set of independent toggles
291 * (Minify, Lazy, Bloat, Gzip).
292 *
293 * Derived from the module's OWN schema rather than a hardcoded key
294 * list, so adding a flag to a module cannot silently fall out of its
295 * active state the way a literal list would. Only `bool` fields count:
296 * an int like `eager_first_n` or a list like `excluded_images` is
297 * configuration for a feature, not evidence the feature is on.
298 *
299 * Returns null when the module declares no boolean flags at all, so a
300 * caller can exclude it rather than record a misleading false.
301 */
302 final protected function any_bool_flag_on(): ?bool {
303 $schema = $this->settings_schema();
304 $settings = $this->get_settings();
305
306 $found = false;
307 foreach ( $schema as $key => $spec ) {
308 if ( 'bool' !== ( $spec['type'] ?? '' ) ) {
309 continue;
310 }
311 $found = true;
312 if ( ! empty( $settings[ $key ] ) ) {
313 return true;
314 }
315 }
316
317 return $found ? false : null;
318 }
319
320 /**
321 * Why is this module reported on or off? One short sentence for the (i)
322 * beside the status pill.
323 *
324 * "On" is not one shape (see is_active()), so without this the pill is a
325 * bare assertion the user cannot check. It is most opaque exactly where
326 * the rule is least obvious: Media Optimization reads "On" while its two
327 * most prominent switches, Lazy-load Images and Iframes, are both off --
328 * because three other flags are on. The reason names them.
329 *
330 * Computed server-side alongside is_active() so the explanation cannot
331 * drift from the verdict it explains. Returning null means "no reason to
332 * add" and the (i) is not rendered.
333 */
334 public function active_reason(): ?string {
335 // A module with its own `enabled` switch needs no explaining: the
336 // pill and the switch say the same thing, and an (i) that only
337 // restates the pill is noise on every one of those pages. Silence
338 // here is what keeps the (i) meaningful where it does appear.
339 if ( array_key_exists( 'enabled', $this->get_settings() ) ) {
340 return null;
341 }
342
343 return $this->bool_flag_reason();
344 }
345
346 /**
347 * The reason text for a module whose "on" is "any of my flags is on".
348 *
349 * Names the specific settings that are on, using their schema labels, so
350 * the user can go and look at them rather than take the pill on trust.
351 * Shared by every flag-based module for one consistent sentence.
352 */
353 final protected function bool_flag_reason(): ?string {
354 $schema = $this->settings_schema();
355 $settings = $this->get_settings();
356
357 $on = array();
358 foreach ( $schema as $key => $spec ) {
359 if ( 'bool' !== ( $spec['type'] ?? '' ) ) {
360 continue;
361 }
362 if ( ! empty( $settings[ $key ] ) ) {
363 $on[] = $spec['label'] ?? $key;
364 }
365 }
366
367 // No boolean flags at all means the module has no on/off to explain
368 // (a status panel like Health). Mirrors any_bool_flag_on() returning
369 // null: no verdict, so no reason.
370 if ( null === $this->any_bool_flag_on() ) {
371 return null;
372 }
373
374 if ( empty( $on ) ) {
375 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' );
376 }
377
378 return sprintf(
379 /* translators: %s: comma-separated list of setting labels that are switched on. */
380 __( 'This module has no single on/off switch. It counts as on because these settings are on: %s.', 'xspeed' ),
381 implode( ', ', $on )
382 );
383 }
384
385 /**
386 * WP-CLI command definitions. Each entry: [
387 * 'name' => 'xspeed cache purge',
388 * 'callback' => callable,
389 * 'synopsis' => array, // wp-cli synopsis spec
390 * ]
391 *
392 * @return array[]
393 */
394 public function cli_commands(): array {
395 return array();
396 }
397
398 /**
399 * Nginx directives this module contributes to the unified server-block
400 * snippet rendered by Cache::full_nginx_server_block(). Returning a
401 * non-null string opts the module into the consolidated "paste this
402 * once into your nginx vhost" UX on the Cache panel.
403 *
404 * The returned string should be the bare directives only — no `server
405 * { }` wrapper, no comment header (the aggregator adds one). Empty
406 * string and null are both treated as "no contribution this render".
407 *
408 * Return null (default) when the module is disabled, its current
409 * settings make the directives a no-op, or the module doesn't have
410 * nginx-side directives at all.
411 */
412 public function nginx_directives(): ?string {
413 return null;
414 }
415
416 /**
417 * Conflict declarations for this module — which other plugins clash
418 * with which sub-feature. Each entry: [
419 * 'plugin' => 'wp-rocket/wp-rocket.php',
420 * 'feature' => 'page_cache',
421 * 'strategy' => 'refuse' | 'warn' | 'allow',
422 * 'reason' => 'human-readable why',
423 * ]
424 *
425 * @return array[]
426 */
427 public function conflicts(): array {
428 return array();
429 }
430
431 /**
432 * Register WP hooks. Called by Module_Registry::boot_all() after
433 * dependencies are resolved. Modules should NOT register hooks in
434 * their constructors — only in boot() — so the registry can control
435 * load order.
436 */
437 public function boot(): void {}
438
439 /**
440 * One-time setup at plugin activation. Idempotent. Examples: create
441 * a custom table, write a silence guard, register a cron schedule.
442 */
443 public function activate(): void {}
444
445 /**
446 * Tear down at plugin deactivation. Reversible counterpart to
447 * activate(). MUST leave the site in a clean state — no orphaned
448 * cron jobs, no leftover drop-ins.
449 */
450 public function deactivate(): void {}
451
452 /**
453 * Convenience accessors. Modules read/write their own settings
454 * through these so the storage detail (one option per module under
455 * `xspeed_module_<slug>`) stays encapsulated.
456 */
457 final public function get_setting( string $key, $default = null ) {
458 $opts = Settings_Manager::get( static::SLUG );
459 return array_key_exists( $key, $opts ) ? $opts[ $key ] : $default;
460 }
461
462 final public function get_settings(): array {
463 return Settings_Manager::get( static::SLUG );
464 }
465
466 final public function update_settings( array $input ): array {
467 $refusal = $this->license_write_refusal( $input );
468 if ( null !== $refusal ) {
469 return $refusal;
470 }
471 return Settings_Manager::update( static::SLUG, $input );
472 }
473
474 /**
475 * Enforce the Pro licence gate on writes, or null to allow the write.
476 *
477 * The dashboard renders a Pro module as `locked` without a valid licence
478 * and refuses to toggle it, but that flag is applied by the
479 * `xspeed_module_descriptor` filter — a decoration on the payload the UI
480 * reads. It never reached the write path, so `POST /xspeed/v1/<module>`
481 * with `{"enabled": true}` returned 200 and persisted, and CLI/MCP hit the
482 * same unguarded callbacks.
483 *
484 * That mattered because module availability is decided by
485 * `Tier_Registry::is_available()`, which asks only whether the Pro plugin
486 * is LOADED — never whether it is licensed. So a Pro module boots and runs
487 * its hooks regardless, and a persisted `enabled: true` genuinely turns the
488 * feature on. This was not cosmetic. (#143)
489 *
490 * Free never references Pro: it asks through `xspeed_pro_licensed`, the
491 * same filter Pro already answers for the descriptor. With no Pro plugin
492 * present nothing hooks it, the default `true` stands, and Free modules
493 * are unaffected either way.
494 *
495 * Reads stay open — the dashboard must still be able to GET settings to
496 * render the locked state at all.
497 *
498 * @param array $input Proposed setting values.
499 * @return array|null Current public settings when refused, else null.
500 */
501 private function license_write_refusal( array $input ): ?array {
502 if ( ! $this->is_license_locked() ) {
503 return null;
504 }
505
506 Activity_Log::record(
507 'license_write_refused',
508 sprintf(
509 /* translators: %s: module slug. */
510 __( 'Refused a settings write to the Pro module "%s" — no valid license.', 'xspeed' ),
511 static::SLUG
512 ),
513 Activity_Log::WARN
514 );
515
516 // Return the unchanged public settings rather than throwing: callers
517 // expect the module's settings back, and the dashboard already renders
518 // this module as locked. REST surfaces the refusal explicitly in
519 // rest_update_settings(), which has a WP_Error channel.
520 return Settings_Manager::get_public( static::SLUG );
521 }
522
523 final public function slug(): string {
524 return static::SLUG;
525 }
526
527 final public function tier(): string {
528 return static::TIER;
529 }
530
531 final public function version(): string {
532 return static::VERSION;
533 }
534 }
535