PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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 1.2.0 All 28 releases
xspeed / includes / class-module.php

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

388 lines 12.3 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 * Option keys a module stores OUTSIDE its settings_schema that must
60 * survive a schema-driven save. Settings_Manager rebuilds the option
61 * from the schema on get()/update(), which would otherwise drop these.
62 * Example: the REST-cache module keeps its route `rules` array here so a
63 * plain enabled/ttl save doesn't wipe the rules table. (FBS-82408)
64 *
65 * @return string[]
66 */
67 public function preserved_keys(): array {
68 return array();
69 }
70
71 /**
72 * Schema migrations keyed by target version. Each value is a callable
73 * that receives the stored options array and returns the migrated
74 * array. Migrations run in version order on first load after upgrade.
75 *
76 * @return array<string,callable>
77 */
78 public function migrations(): array {
79 return array();
80 }
81
82 /**
83 * REST routes the module owns. Paths are prefixed with
84 * `/xspeed/v1/<slug>/` by Rest_Manager; declare without the prefix.
85 * `permission_callback` is wrapped automatically with a final cap
86 * check + tier gate, so modules don't need to repeat that boilerplate
87 * — but they MUST still declare a sensible callback.
88 *
89 * Default impl returns the standard GET + POST pair for modules that
90 * declare a settings_schema. Modules that need extra endpoints can
91 * extend the array. Modules with truly custom REST should override
92 * entirely and skip parent::rest_routes().
93 *
94 * Per SETTINGS.md §5.1 every module's settings live at:
95 * GET /xspeed/v1/<slug>/ → current settings
96 * POST /xspeed/v1/<slug>/ → partial patch, returns updated settings
97 *
98 * @return array[]
99 */
100 public function rest_routes(): array {
101 if ( empty( $this->settings_schema() ) ) {
102 return array();
103 }
104 return array(
105 array(
106 'path' => '/',
107 'methods' => 'GET',
108 'callback' => array( $this, 'rest_get_settings' ),
109 ),
110 array(
111 'path' => '/',
112 'methods' => 'POST',
113 'callback' => array( $this, 'rest_update_settings' ),
114 'feature' => static::SLUG,
115 ),
116 );
117 }
118
119 /**
120 * Default GET handler — returns all settings (defaults + stored)
121 * coerced against the schema, with secret fields masked. Uses the public
122 * view (not get_settings()) so a credential never leaves in a REST payload;
123 * the engine reads real values through get_settings()/get_setting(). (#115)
124 * Modules can override but rarely need to.
125 */
126 public function rest_get_settings( \WP_REST_Request $request ) {
127 return rest_ensure_response( Settings_Manager::get_public( static::SLUG ) );
128 }
129
130 /**
131 * Default POST handler — validates the JSON body against the
132 * schema, persists, returns the post-update settings. Unknown keys
133 * are stripped by Settings_Manager.
134 */
135 public function rest_update_settings( \WP_REST_Request $request ) {
136 $params = $request->get_json_params();
137 if ( ! is_array( $params ) ) {
138 $params = $request->get_params();
139 }
140
141 // Say so, rather than returning 200 over a write that didn't happen.
142 // update_settings() enforces the gate on every surface; REST is the
143 // one with an error channel, so it reports the reason. (#143)
144 if ( $this->is_license_locked() ) {
145 return new \WP_Error(
146 'xspeed_license_required',
147 sprintf(
148 /* translators: %s: module slug. */
149 __( '"%s" is a Pro module and this site has no valid license, so its settings cannot be changed.', 'xspeed' ),
150 static::SLUG
151 ),
152 array( 'status' => 403 )
153 );
154 }
155
156 return rest_ensure_response( $this->update_settings( $params ) );
157 }
158
159 /**
160 * Is this a Pro module whose settings are locked for want of a licence?
161 *
162 * Shared by the REST handler and the write guard so the two can never
163 * disagree about what is locked.
164 */
165 final public function is_license_locked(): bool {
166 // The licence module itself must stay writable — otherwise an expired
167 // licence locks the user out of the very screen where a new key is
168 // entered.
169 if ( self::TIER_PRO !== $this->tier() || 'license' === static::SLUG ) {
170 return false;
171 }
172
173 /**
174 * Filter: xspeed_pro_licensed
175 *
176 * Answered by xspeed-pro — the same filter it already answers when
177 * decorating module descriptors with the `locked` flag, so the write
178 * gate and the UI lock can't disagree.
179 *
180 * Defaults to true so a Free-only install (where nothing hooks this)
181 * is never gated by a question no one is present to answer.
182 *
183 * @param bool $licensed Whether Pro is licensed right now.
184 */
185 return ! (bool) apply_filters( 'xspeed_pro_licensed', true );
186 }
187
188 /**
189 * UI panel declarations consumed by the React dashboard via the
190 * bootstrap payload. Each entry: [
191 * 'section' => 'cache' | 'performance' | 'images' | ...,
192 * 'position' => int,
193 * 'component' => 'HealthCard' | 'TogglesList' | 'StatGrid' | 'Custom',
194 * 'props' => array,
195 * ]
196 *
197 * @return array[]
198 */
199 public function ui_panels(): array {
200 return array();
201 }
202
203 /**
204 * Sidebar / dashboard metadata. The React app uses these to render the
205 * module's nav entry. Override per module to set a friendly label and
206 * a lucide-react icon name (must be in the renderer's icon whitelist —
207 * see src/components/IconResolver.tsx).
208 *
209 * @return array{label:string,icon:string,description?:string,hidden?:bool}
210 */
211 public function ui_metadata(): array {
212 return array(
213 'label' => ucfirst( str_replace( '_', ' ', static::SLUG ) ),
214 'icon' => 'Square',
215 );
216 }
217
218 /**
219 * Dynamic in-panel notices (callouts) rendered above the schema form.
220 * Computed fresh on every dashboard load. Examples: nginx GZIP
221 * snippet when the server can't be auto-configured, "drop-in
222 * missing" warning when cache_enabled but no advanced-cache.php.
223 *
224 * Each entry: [
225 * 'tone' => 'info' | 'warn' | 'danger' | 'success',
226 * 'title' => 'Short heading.',
227 * 'body' => 'One- or two-sentence explanation.',
228 * 'snippet' => 'Optional verbatim code snippet rendered in a
229 * <pre> with a Copy button.',
230 * ]
231 *
232 * @return array[]
233 */
234 public function ui_notices(): array {
235 return array();
236 }
237
238 /**
239 * WP-CLI command definitions. Each entry: [
240 * 'name' => 'xspeed cache purge',
241 * 'callback' => callable,
242 * 'synopsis' => array, // wp-cli synopsis spec
243 * ]
244 *
245 * @return array[]
246 */
247 public function cli_commands(): array {
248 return array();
249 }
250
251 /**
252 * Nginx directives this module contributes to the unified server-block
253 * snippet rendered by Cache::full_nginx_server_block(). Returning a
254 * non-null string opts the module into the consolidated "paste this
255 * once into your nginx vhost" UX on the Cache panel.
256 *
257 * The returned string should be the bare directives only — no `server
258 * { }` wrapper, no comment header (the aggregator adds one). Empty
259 * string and null are both treated as "no contribution this render".
260 *
261 * Return null (default) when the module is disabled, its current
262 * settings make the directives a no-op, or the module doesn't have
263 * nginx-side directives at all.
264 */
265 public function nginx_directives(): ?string {
266 return null;
267 }
268
269 /**
270 * Conflict declarations for this module — which other plugins clash
271 * with which sub-feature. Each entry: [
272 * 'plugin' => 'wp-rocket/wp-rocket.php',
273 * 'feature' => 'page_cache',
274 * 'strategy' => 'refuse' | 'warn' | 'allow',
275 * 'reason' => 'human-readable why',
276 * ]
277 *
278 * @return array[]
279 */
280 public function conflicts(): array {
281 return array();
282 }
283
284 /**
285 * Register WP hooks. Called by Module_Registry::boot_all() after
286 * dependencies are resolved. Modules should NOT register hooks in
287 * their constructors — only in boot() — so the registry can control
288 * load order.
289 */
290 public function boot(): void {}
291
292 /**
293 * One-time setup at plugin activation. Idempotent. Examples: create
294 * a custom table, write a silence guard, register a cron schedule.
295 */
296 public function activate(): void {}
297
298 /**
299 * Tear down at plugin deactivation. Reversible counterpart to
300 * activate(). MUST leave the site in a clean state — no orphaned
301 * cron jobs, no leftover drop-ins.
302 */
303 public function deactivate(): void {}
304
305 /**
306 * Convenience accessors. Modules read/write their own settings
307 * through these so the storage detail (one option per module under
308 * `xspeed_module_<slug>`) stays encapsulated.
309 */
310 final public function get_setting( string $key, $default = null ) {
311 $opts = Settings_Manager::get( static::SLUG );
312 return array_key_exists( $key, $opts ) ? $opts[ $key ] : $default;
313 }
314
315 final public function get_settings(): array {
316 return Settings_Manager::get( static::SLUG );
317 }
318
319 final public function update_settings( array $input ): array {
320 $refusal = $this->license_write_refusal( $input );
321 if ( null !== $refusal ) {
322 return $refusal;
323 }
324 return Settings_Manager::update( static::SLUG, $input );
325 }
326
327 /**
328 * Enforce the Pro licence gate on writes, or null to allow the write.
329 *
330 * The dashboard renders a Pro module as `locked` without a valid licence
331 * and refuses to toggle it, but that flag is applied by the
332 * `xspeed_module_descriptor` filter — a decoration on the payload the UI
333 * reads. It never reached the write path, so `POST /xspeed/v1/<module>`
334 * with `{"enabled": true}` returned 200 and persisted, and CLI/MCP hit the
335 * same unguarded callbacks.
336 *
337 * That mattered because module availability is decided by
338 * `Tier_Registry::is_available()`, which asks only whether the Pro plugin
339 * is LOADED — never whether it is licensed. So a Pro module boots and runs
340 * its hooks regardless, and a persisted `enabled: true` genuinely turns the
341 * feature on. This was not cosmetic. (#143)
342 *
343 * Free never references Pro: it asks through `xspeed_pro_licensed`, the
344 * same filter Pro already answers for the descriptor. With no Pro plugin
345 * present nothing hooks it, the default `true` stands, and Free modules
346 * are unaffected either way.
347 *
348 * Reads stay open — the dashboard must still be able to GET settings to
349 * render the locked state at all.
350 *
351 * @param array $input Proposed setting values.
352 * @return array|null Current public settings when refused, else null.
353 */
354 private function license_write_refusal( array $input ): ?array {
355 if ( ! $this->is_license_locked() ) {
356 return null;
357 }
358
359 Activity_Log::record(
360 'license_write_refused',
361 sprintf(
362 /* translators: %s: module slug. */
363 __( 'Refused a settings write to the Pro module "%s" — no valid license.', 'xspeed' ),
364 static::SLUG
365 ),
366 Activity_Log::WARN
367 );
368
369 // Return the unchanged public settings rather than throwing: callers
370 // expect the module's settings back, and the dashboard already renders
371 // this module as locked. REST surfaces the refusal explicitly in
372 // rest_update_settings(), which has a WP_Error channel.
373 return Settings_Manager::get_public( static::SLUG );
374 }
375
376 final public function slug(): string {
377 return static::SLUG;
378 }
379
380 final public function tier(): string {
381 return static::TIER;
382 }
383
384 final public function version(): string {
385 return static::VERSION;
386 }
387 }
388