PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
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 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 30 releases
xspeed / includes / class-plugin.php

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

499 lines 21.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Main plugin bootstrap.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 class Plugin {
13
14 /**
15 * Data-schema version for one-time migrations, independent of the
16 * plugin version header. Bump when adding a step to maybe_upgrade().
17 */
18 public const DATA_VERSION = '1.1.6';
19
20 private static $instance = null;
21
22 /** @var Usage_Tracker|null */
23 private $usage_tracker = null;
24
25 public static function instance() {
26 if ( null === self::$instance ) {
27 self::$instance = new self();
28 }
29 return self::$instance;
30 }
31
32 public function init() {
33 // v1 services that are NOT yet wrapped as Modules (Admin, Rest_Api,
34 // Cache, Onboarding) are instantiated directly. Minifier is now
35 // instantiated by MinifyModule::boot(); Gzip is purely static.
36 new Admin();
37 new Rest_Api();
38 new Cache();
39 new Rest_Cache();
40 new Onboarding();
41
42 // Optional deactivation feedback survey on the Plugins screen. Admin
43 // context only (its hooks are admin_enqueue_scripts / admin_footer /
44 // wp_ajax). Sends nothing unless the user clicks "Submit & Deactivate".
45 if ( is_admin() ) {
46 new Deactivation_Feedback();
47 }
48
49 // Opt-in usage analytics. Instantiating + init() only registers the
50 // cron callback; NOTHING is collected or sent until the admin opts in
51 // from the setup wizard (Onboarding wires the consent toggle to
52 // Usage_Tracker::opt_in()). See class-usage-tracker.php privacy contract.
53 $this->start_plugin_tracking();
54
55 // Auto-heal the cache drop-in + WP_CACHE constant when state
56 // drifts. Scoped to admin_init only: filesystem writes belong in
57 // an authenticated admin context, never on anonymous front-end
58 // requests. The user already opted in (cache_enabled=true) —
59 // this is a consistency check, not a new install path. First
60 // admin page load after a plugin upgrade restores the state;
61 // front-end then serves from cache on the next request.
62 add_action( 'admin_init', array( Cache::class, 'auto_heal' ) );
63 // Cheap: returns immediately unless the stored schema version is
64 // behind. Covers updates and multisite, where activate() never runs.
65 add_action( 'admin_init', array( Score_Store::class, 'maybe_install' ) );
66
67 // Secondary net: restore as soon as an update completes, for the
68 // cases where activate() does not re-run (bulk updates, auto-updates,
69 // some host updaters). Best-effort by nature — this callback is only
70 // registered when we were loaded in the request performing the
71 // update, which is not guaranteed while WE are the plugin being
72 // replaced. The restore in activate() is the primary guarantee;
73 // auto_heal() on admin_init remains the backstop.
74 add_action( 'upgrader_process_complete', array( $this, 'maybe_restore_after_update' ), 10, 2 );
75
76 // Per-post cache rules (Phase 3.4) — registers postmeta with
77 // REST + meta box on edit screens.
78 Cache_Meta_Box::boot();
79
80 // Single-URL purge entry points — row actions, the edit-screen
81 // button and the admin-post handler the admin-bar item also uses.
82 Purge_Ui::boot();
83
84 // Phase 0 architecture — managers + Free modules. v1 services
85 // (Cache/Minifier/Gzip) are NOT yet Modules; they'll be refactored
86 // in a follow-up PR with parity tests.
87 Conflict_Registry::boot();
88
89 // Read-only page-cache ownership evidence, behind the same
90 // activation/deactivation invalidation as the conflict matrix.
91 Page_Cache_Detector::boot();
92
93 // Integrations that clear OTHER plugins' caches of rendered output
94 // (Elementor's element cache + generated CSS). Registers a listener
95 // only; nothing runs until Cache::purge_render_caches() asks.
96 Render_Caches::boot();
97 // Server_Caches needs no boot(): Cache::dispatch_purge_event() calls it
98 // directly so a throwing third-party listener cannot skip it.
99
100 // Forward a full purge to a full-page cache owned by the WEB SERVER
101 // (nginx FastCGI, via the host's Nginx Helper install). Registers a
102 // listener on xspeed_after_purge_all only; it stands down unless
103 // that cache is actually configured.
104 Host_Page_Caches::boot();
105
106 // Register Free modules via the same action xspeed-pro uses, so
107 // the bootstrap path is symmetric across tiers.
108 add_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) );
109
110 // Fire the registration action + boot the registry at a LATER
111 // plugins_loaded priority so add-on plugins (xspeed-pro, in
112 // alphabetical order so it runs at default priority 10 AFTER
113 // us, but any add-on loaded at plugins_loaded(< 20)) have a
114 // chance to register their `xspeed_register_modules` callback
115 // before we fire the action.
116 //
117 // Bug history: previously this fired inline from init() at
118 // priority 10. xspeed-pro's plugins_loaded(15) hook then added
119 // its register_pro_modules callback AFTER the action had
120 // already fired — Pro modules never appeared in the registry.
121 // Caught by the ProStatus sentinel module's integration test.
122 add_action( 'plugins_loaded', array( $this, 'fire_module_lifecycle' ), 20 );
123
124 // One-time data migrations keyed on the stored version. Runs in
125 // admin only — nothing here needs to touch a front-end request.
126 if ( is_admin() ) {
127 add_action( 'plugins_loaded', array( $this, 'maybe_upgrade' ), 21 );
128 }
129 }
130
131 /**
132 * Run version-gated data migrations exactly once per upgrade.
133 *
134 * Keyed on `xspeed_data_version` rather than the plugin version header
135 * so a migration can be added without forcing a release bump.
136 */
137 public function maybe_upgrade(): void {
138 $current = (string) get_option( 'xspeed_data_version', '0' );
139 if ( version_compare( $current, self::DATA_VERSION, '>=' ) ) {
140 return;
141 }
142
143 // 1.1.2 — strip credential values recorded by earlier versions'
144 // settings change annotations (they're served by the trend endpoints).
145 Activity_Log::redact_legacy_secrets();
146
147 // 1.1.4 — earlier versions cached a failed loopback as "gzip is not
148 // active" for an hour, which showed up as a bogus server-config
149 // warning. Drop the stale answer so the fixed probe re-runs instead
150 // of the wrong verdict living on past the update (issue #18).
151 delete_transient( 'xspeed_gzip_active' );
152
153 // 1.1.6 — a `/?s=<term>` request used to write its results page into
154 // the static tree under the *searched-from* path, which for the usual
155 // query-form search is `/`. The web server then served that results
156 // page as the homepage to every visitor. The write is fixed in
157 // Cache::store_static(), but an entry poisoned before the update
158 // outlives it: nothing purges on upgrade, and the static serve path
159 // never revalidates. Clear the tree once. The flat cache is keyed
160 // correctly and is deliberately left alone. (issue #191)
161 Cache::purge_static_tree();
162
163 update_option( 'xspeed_data_version', self::DATA_VERSION, false );
164 }
165
166 /**
167 * Phase 2 of plugin init: fire the registration action (collecting
168 * Free + Pro + any third-party modules hooked into
169 * `xspeed_register_modules`) and boot the registry.
170 *
171 * Runs at plugins_loaded(20) so every add-on that hooks at any
172 * priority < 20 has time to register first.
173 */
174 public function fire_module_lifecycle(): void {
175 $this->ensure_modules_registered();
176
177 Module_Registry::boot_all();
178 }
179
180 /**
181 * Fire `xspeed_register_modules` if this request has not yet, hooking
182 * Free's own registration first when init() never got the chance.
183 *
184 * The activation request is the case that matters. activate_plugin()
185 * includes the plugin file long after `plugins_loaded` has fired, so the
186 * `plugins_loaded` callback init() would have added never runs, and
187 * neither does the add_action() inside it that puts register_free_modules
188 * on the action. Firing the action from activate() then registered
189 * nothing: Settings::conflict_safe_profile() composed from an empty
190 * registry, and a site with WP Super Cache came up with lazy-load,
191 * resource hints, font swapping and preloading switched on — only the
192 * four settings the registry-independent fallback names were held down
193 * (PR #295 review). Module_Registry::activate_all() has been a no-op on
194 * the same request for the same reason.
195 *
196 * On the activation request that means Free only: an add-on cannot have
197 * hooked yet, because xspeed-pro bails when Free's classes are absent and
198 * only hooks the action (at priority 20, from plugins_loaded(15)) once
199 * Free is active. On an ordinary request fire_module_lifecycle() reaches
200 * this at plugins_loaded(20) with every add-on already hooked. Do not
201 * call this from anything that can run in between: the action fires
202 * once, and an add-on that has not hooked yet stays unregistered for the
203 * whole request.
204 * did_action() keeps the action to one firing per request, so an
205 * activation that ran first does not make plugins_loaded(20) register
206 * every module a second time.
207 */
208 public function ensure_modules_registered(): void {
209 if ( did_action( 'xspeed_register_modules' ) ) {
210 return;
211 }
212
213 /*
214 * register_free_modules() does an unconditional `new` on every module
215 * class. On an ordinary request xspeed.php's integrity check refuses
216 * to boot before that can fatal and explains itself in an admin
217 * notice; the activation hook is registered outside that check, so
218 * an install missing a module file (truncated zip, a security
219 * plugin's quarantine, a half-applied update) would fatal here with
220 * no notice and no active plugin. Same answer as boot: do nothing.
221 */
222 if ( function_exists( 'xspeed_missing_core_classes' ) && ! empty( xspeed_missing_core_classes() ) ) {
223 return;
224 }
225
226 if ( ! has_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) ) ) {
227 add_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) );
228 }
229
230 /**
231 * Action: xspeed_register_modules
232 *
233 * Free modules register at priority 10; xspeed-pro at priority
234 * 20; site code can hook in between to inject custom modules.
235 * Fires exactly once per request.
236 */
237 do_action( 'xspeed_register_modules' );
238 }
239
240 /**
241 * Register the Free Modules shipped in this plugin. Add new module
242 * registrations here. Pro plugin hooks the same action separately.
243 */
244 public function register_free_modules(): void {
245 Module_Registry::register( new \XSpeed\Modules\Cache\CacheModule() );
246 Module_Registry::register( new \XSpeed\Modules\Health\HealthModule() );
247 // Settings — owns no settings itself; it's the CLI/MCP surface over
248 // Settings_Manager. Registering it is what makes `xspeed settings`
249 // exist, which is what keeps the curated get_settings/update_settings
250 // tools in the MCP catalog. (#149/#153)
251 Module_Registry::register( new \XSpeed\Modules\Settings\SettingsModule() );
252 // External performance scores (PSI / GTmetrix) — Free, off by
253 // default. Rendered inside the Health host page's PageSpeed tab, so
254 // it has no sidebar row of its own.
255 Module_Registry::register( new \XSpeed\Modules\Score\ScoreModule() );
256 Module_Registry::register( new \XSpeed\Modules\Preloader\PreloaderModule() );
257 Module_Registry::register( new \XSpeed\Modules\Heartbeat\HeartbeatModule() );
258 Module_Registry::register( new \XSpeed\Modules\Minify\MinifyModule() );
259 Module_Registry::register( new \XSpeed\Modules\Gzip\GzipModule() );
260 Module_Registry::register( new \XSpeed\Modules\Lazy\LazyModule() );
261 Module_Registry::register( new \XSpeed\Modules\Bloat\BloatModule() );
262 Module_Registry::register( new \XSpeed\Modules\Database\DatabaseModule() );
263 Module_Registry::register( new \XSpeed\Modules\Cdn\CdnModule() );
264 Module_Registry::register( new \XSpeed\Modules\Cloudflare\CloudflareModule() );
265 Module_Registry::register( new \XSpeed\Modules\ObjectCache\ObjectCacheModule() );
266 Module_Registry::register( new \XSpeed\Modules\BrowserCache\BrowserCacheModule() );
267 // Advanced Cache — a Free container row that gathers the Pro
268 // cache-coverage features (404 / search / feed / REST / rules /
269 // maintenance) into one sidebar sub-item (FBS-83633).
270 Module_Registry::register( new \XSpeed\Modules\CacheCoverage\CacheCoverageModule() );
271 Module_Registry::register( new \XSpeed\Modules\Fonts\FontsModule() );
272 Module_Registry::register( new \XSpeed\Modules\RenderSkip\RenderSkipModule() );
273 Module_Registry::register( new \XSpeed\Modules\ResourceHints\ResourceHintsModule() );
274 // AI Privacy (GDPR off-switch) ships in Free even though every AI
275 // *feature* is Pro — privacy is a right, not a paid tier. FEATURES.md
276 // §AI row 6 mandates it. Without this registration the module was dead
277 // code: no REST/settings surface, the promised off-switch unreachable
278 // (FBS-83633 Bug 1). It carries its own cli_commands() so it satisfies
279 // the CLI/MCP coverage guard once registered.
280 Module_Registry::register( new \XSpeed\Modules\AIPrivacy\AIPrivacyModule() );
281 // Migration moved Pro → Free: it's an acquisition/onboarding feature
282 // (detect a competing caching plugin, import its settings, switch over),
283 // so it must work without a Pro license. Agency-scale extras (profiles,
284 // bulk multisite, host presets) remain Pro.
285 Module_Registry::register( new \XSpeed\Modules\Migration\MigrationModule() );
286 // Help & Support moved Pro → Free: a ticket link + read-only system
287 // snapshot is onboarding/diagnostics, not a paid value-add, so every
288 // user gets it. The snapshot degrades gracefully without Pro (Pro
289 // version/license fields fall back to defaults via defined()/get_option).
290 Module_Registry::register( new \XSpeed\Modules\Support\SupportModule() );
291 // The dashboard control for usage-analytics consent. Consent used to
292 // be collectable only in the wizard and withdrawable nowhere, while
293 // the wizard and readme both promised a dashboard switch. (#437)
294 Module_Registry::register( new \XSpeed\Modules\Privacy\PrivacyModule() );
295 // MCP remote control (AI assistants) — Free. The plugin serves the
296 // MCP protocol at the site's own /xspeed/mcp URL; the only gate is
297 // the per-site connection token an admin mints via Connect. No
298 // license, no hosted infra. See IMPLEMENTATION.md §17.
299 Module_Registry::register( new \XSpeed\Modules\Mcp\McpModule() );
300 }
301
302 /**
303 * Boot the opt-in usage tracker. Registers the cron sender only; the send
304 * itself is consent-gated inside Usage_Tracker. The instance is held so the
305 * onboarding REST handler can flip consent via usage_tracker()->opt_in().
306 */
307 public function start_plugin_tracking(): void {
308 $this->usage_tracker = Usage_Tracker::get_instance(
309 XSPEED_FILE,
310 array(
311 'opt_in' => true,
312 'item_id' => defined( 'XSPEED_INSIGHTS_ITEM_ID' ) ? XSPEED_INSIGHTS_ITEM_ID : false,
313 )
314 );
315 $this->usage_tracker->init();
316 }
317
318 /**
319 * The shared Usage_Tracker singleton (or null if tracking wasn't booted,
320 * e.g. on the activation hook before init() runs).
321 */
322 public function usage_tracker(): ?Usage_Tracker {
323 return $this->usage_tracker;
324 }
325
326 public static function activate() {
327 // Nothing below can see a module the registry does not hold — the
328 // conflict-safe profile Settings::set_defaults() may pick is composed
329 // from it, and activate_all() walks it. See ensure_modules_registered()
330 // for why the registry is empty on this request without this call.
331 self::instance()->ensure_modules_registered();
332
333 $profile = Settings::set_defaults();
334
335 // Score history table. Also called on admin_init (see init()) because
336 // activation does not fire for a site added to a multisite network
337 // later, nor after an update that ships a new schema version.
338 Score_Store::maybe_install();
339
340 if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
341 wp_mkdir_p( XSPEED_CACHE_DIR );
342 }
343 Cache::write_silence( XSPEED_CACHE_DIR );
344
345 /*
346 * One exception to "caching is only ever enabled from the admin UI":
347 * a fresh install another plugin performed on the user's behalf.
348 *
349 * That plugin asked the user for site performance and installed us to
350 * provide it; making them go and find a second switch afterwards is
351 * a step nobody wants. It is also what the copy-vendored Setup::finish()
352 * did, so hosts migrating off it keep the behaviour they have.
353 *
354 * PROFILE_HOST_PAGE_CACHE is the whole condition, and it means three
355 * things at once: the install was genuinely fresh, nothing else owns
356 * the page cache, and another plugin claimed the install. Every other
357 * fresh install — including a user's own on a clear site — waits for
358 * the wizard. Every OTHER feature is off in this profile; the cache is
359 * the one thing a host may assume, because it is what it installed us
360 * for. And toggle() runs its own ownership transaction, so a
361 * competitor appearing between the two checks loses the race rather
362 * than being overwritten.
363 */
364 if ( Settings::PROFILE_HOST_PAGE_CACHE === $profile ) {
365 $state = Cache::toggle( true );
366 if ( ! empty( $state['blocked'] ) ) {
367 /*
368 * Refused after all — a competitor that appeared between the
369 * profile decision and the write, or a drop-in we could not
370 * install. The settings are identical either way (everything
371 * off), so the honest record is the one that does not claim a
372 * cache: conflict-safe is what the site actually got.
373 */
374 $profile = Settings::PROFILE_CONFLICT_SAFE;
375 update_option( 'xspeed_install_profile', $profile, false );
376 }
377 }
378
379 /*
380 * Read the claim BEFORE spending it. consume_installed_by() records
381 * the installer only for a FRESH install — on a re-activation over
382 * settings that are already there it deletes the arming option and
383 * keeps nothing — so asking again afterwards answered "the user did
384 * it" about an install a host had just claimed, and the wizard opened
385 * over the host's own flow. The claim decides who to tell and whether
386 * to open the wizard; whether it is worth RECORDING is a separate
387 * question, and only the recording depends on the install being fresh.
388 */
389 $installed_by = Settings::installed_by();
390
391 // Spend the arming option now the profile is settled. It changes what
392 // activation does, so it may not survive into the next one.
393 Settings::consume_installed_by( $profile );
394
395 // Caching is otherwise only ENABLED from the admin UI — see
396 // Cache::toggle() and Rest_Api::toggle_cache(). A fresh install
397 // therefore gets no drop-in and no wp-config.php edit here:
398 // cache_enabled is unset, so the call below is a no-op.
399 //
400 // It is NOT a no-op during an upgrade. WordPress runs an update as
401 // deactivate → wipe files → install → activate, which deletes
402 // advanced-cache.php while cache_enabled stays true. Restoring it
403 // here closes the window in which the site silently serves uncached
404 // (auto_heal() alone only fires on the next wp-admin page load).
405 Cache::restore_dropin_if_enabled();
406
407 /*
408 * First-run wizard: flag a one-time redirect for the activating user.
409 * Suppressed for bulk activations / already-completed sites in
410 * Onboarding::maybe_redirect() — and here for an install another plugin
411 * performed, which has an onboarding flow of its own. The install runs
412 * over AJAX, so our redirect would fire on that admin's NEXT page load
413 * and pull them out of the middle of the host's wizard; finishing ours
414 * would then overwrite the deliberately all-off profile they never
415 * asked to change.
416 */
417 if ( '' === $installed_by ) {
418 Onboarding::flag_redirect();
419 }
420
421 // Propagate activation to every registered Module — registered by
422 // ensure_modules_registered() at the top, not by plugins_loaded,
423 // which fired before this file was even included.
424 Module_Registry::activate_all();
425
426 /**
427 * Fires at the end of activation, once the settings profile is decided.
428 *
429 * The other half of the host-plugin contract: a plugin that installed
430 * xSpeed for the user writes `xspeed_installed_by` before activating
431 * and listens here to find out how it came up. It carries no return
432 * value and nothing branches on it — a host that ignores it changes
433 * nothing about the install.
434 *
435 * @param string $installed_by Host slug, or '' when the user did it.
436 * @param string $profile Settings::PROFILE_* — which profile a fresh
437 * install came up with, '' if not fresh.
438 */
439 do_action( 'xspeed_activated', $installed_by, $profile );
440 }
441
442 /**
443 * Restore the cache drop-in right after THIS plugin is updated.
444 *
445 * Bound to upgrader_process_complete. Bulk updates, auto-updates and
446 * host-level updaters finish without re-running activate(), so this is
447 * the only hook that repairs the drop-in before the next wp-admin page
448 * load. Narrow by design: bails unless the completed action was a
449 * plugin update whose payload actually includes xspeed.
450 *
451 * @param \WP_Upgrader $upgrader Upgrader instance (unused).
452 * @param array $hook_extra Contextual data about the update.
453 * @return void
454 */
455 public function maybe_restore_after_update( $upgrader, $hook_extra ) {
456 unset( $upgrader );
457
458 if ( ! is_array( $hook_extra ) ) {
459 return;
460 }
461 if ( ! isset( $hook_extra['type'], $hook_extra['action'] ) ) {
462 return;
463 }
464 if ( 'plugin' !== $hook_extra['type'] || 'update' !== $hook_extra['action'] ) {
465 return;
466 }
467
468 // Single update uses 'plugin'; bulk uses 'plugins'.
469 $updated = array();
470 if ( isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
471 $updated = $hook_extra['plugins'];
472 } elseif ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) {
473 $updated = array( $hook_extra['plugin'] );
474 }
475
476 $ours = plugin_basename( XSPEED_FILE );
477 if ( ! in_array( $ours, $updated, true ) ) {
478 return;
479 }
480
481 Cache::restore_dropin_if_enabled();
482 }
483
484 public static function deactivate() {
485 // Drop-in + WP_CACHE constant are NOT touched here. WordPress
486 // upgrades run as deactivate → wipe files → install → activate,
487 // so removing those artifacts on every deactivate would silently
488 // disable caching after each plugin update. uninstall.php
489 // handles full teardown when the user actually removes the
490 // plugin; auto_heal() restores state on the next admin_init if
491 // the drop-in or WP_CACHE went missing for any other reason.
492 Cache::purge_all();
493 Minifier::purge_minified();
494 Gzip::apply( false );
495
496 Module_Registry::deactivate_all();
497 }
498 }
499