PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.0 1.2.1 1.2.2 1.2.3
xspeed / includes / class-module.php

class-module.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, 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