class-page-cache-setup.php
524 lines
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * Page Cache Setup — how to bring xSpeed up already configured, for a plugin |
| 5 | * that installs it on a user's behalf. |
| 6 | * |
| 7 | * The companion to class-page-cache-safety.php. That file DECIDES (read-only: |
| 8 | * "does anything already own this page cache?"); this one CONFIGURES. Keep the |
| 9 | * split — Detector promises never to write, and hosts reason about it on that |
| 10 | * basis. |
| 11 | * |
| 12 | * Call order matters and is enforced in prepare(). See page-cache-safety/README.md. |
| 13 | * |
| 14 | * Source of truth: the xSpeed Free repo, page-cache-safety/. Not loaded by |
| 15 | * xSpeed, not shipped in its zip — it exists to be copied. Improve it here and |
| 16 | * re-copy; do not fork it per plugin. |
| 17 | * |
| 18 | * @package WPDeveloper\PageCacheSafety |
| 19 | * @version 1.4.1 |
| 20 | */ |
| 21 | |
| 22 | namespace WPDeveloper\PageCacheSafety; |
| 23 | |
| 24 | defined('ABSPATH') || exit; |
| 25 | |
| 26 | /* |
| 27 | * Two plugins on the same site can each carry a copy. First one loaded wins. |
| 28 | * Unlike Detector these methods write — but everything they write is xSpeed's |
| 29 | * own option rows, to the same values, so whichever copy runs is immaterial. |
| 30 | */ |
| 31 | if (! class_exists(__NAMESPACE__ . '\\Setup')) { |
| 32 | |
| 33 | final class Setup |
| 34 | { |
| 35 | |
| 36 | public const VERSION = '1.4.1'; |
| 37 | |
| 38 | /** |
| 39 | * A host offering this a row on a screen should show the real name and |
| 40 | * link: the user is agreeing to install this specific plugin. |
| 41 | */ |
| 42 | public const PLUGIN_FILE = 'xspeed/xspeed.php'; |
| 43 | public const PLUGIN_SLUG = 'xspeed'; |
| 44 | public const PLUGIN_NAME = 'xSpeed Cache'; |
| 45 | public const PLUGIN_ICON = 'https://ps.w.org/xspeed/assets/icon-256x256.png'; |
| 46 | public const PLUGIN_LINK = 'https://wordpress.org/plugins/xspeed/'; |
| 47 | |
| 48 | /** |
| 49 | * A host whose own floor is lower must check these before offering: a |
| 50 | * promised cache that then fails to install is worse than none offered. |
| 51 | */ |
| 52 | public const REQUIRES_WP = '6.0'; |
| 53 | public const REQUIRES_PHP = '7.4'; |
| 54 | |
| 55 | /** Pre-module blob, and one row per module. */ |
| 56 | public const SETTINGS_OPTION = 'xspeed_options'; |
| 57 | public const MODULE_PREFIX = 'xspeed_module_'; |
| 58 | |
| 59 | /** |
| 60 | * Turns page caching on. Deliberately NOT a module setting — it drives the |
| 61 | * drop-in install and the wp-config.php edit, and Settings_Manager rejects |
| 62 | * it by name for that reason. |
| 63 | */ |
| 64 | public const PAGE_CACHE_KEY = 'cache_enabled'; |
| 65 | |
| 66 | /** |
| 67 | * What prepare() overwrote, so rollback() can put it back rather than |
| 68 | * deleting rows it never created. Option rows outlive plugin deletion, so |
| 69 | * a site with no xSpeed can still have them. |
| 70 | */ |
| 71 | public const SNAPSHOT_OPTION = 'xspeed_setup_snapshot'; |
| 72 | |
| 73 | /** |
| 74 | * Set unconditionally by xSpeed's activation to force a one-time redirect |
| 75 | * to its setup wizard. Nothing a host does before activation can prevent |
| 76 | * it, so finish() clears it afterwards. |
| 77 | */ |
| 78 | public const WIZARD_REDIRECT_OPTION = 'xspeed_redirect_to_onboarding'; |
| 79 | |
| 80 | /** |
| 81 | * The module state a host-installed xSpeed should come up with. |
| 82 | * |
| 83 | * This is a STATIC COPY of what XSpeed\Settings::conflict_safe_profile() |
| 84 | * builds from the live module registry. The copy has to exist: prepare() |
| 85 | * runs before activation, when none of xSpeed's code is loaded and there |
| 86 | * is no registry to ask. PortableSetupParityTest compares the two and |
| 87 | * fails when they drift, and the Pro repo's own test covers its half. |
| 88 | * |
| 89 | * Two sources feed it, because scanning one misses half the switches: |
| 90 | * settings whose schema default is `true` (an ABSENT option row reads |
| 91 | * back ON, so these need explicit `false` rows), and the four that |
| 92 | * activation seeds through Settings::recommended_module_settings(). |
| 93 | * |
| 94 | * Page caching only. Someone who accepted "install a cache" agreed to a page |
| 95 | * cache, not to having their markup rewritten by minify, lazy loading or |
| 96 | * resource hints. |
| 97 | * |
| 98 | * Every module is listed, including ones already off by default, because "off |
| 99 | * by default" is not "off": writing SETTINGS_OPTION suppresses the first-run |
| 100 | * path that seeds these rows, so a few land off as a side effect of an |
| 101 | * unrelated write. Relying on that silently re-enabled browser caching for one |
| 102 | * host already. |
| 103 | * |
| 104 | * ai-privacy, cloudflare, object-cache and cache keep xSpeed's own defaults — |
| 105 | * none is a rendering optimisation, and for the first, off would be wrong. |
| 106 | */ |
| 107 | public const INITIAL_SETTINGS = array( |
| 108 | 'bloat' => array( |
| 109 | 'disable_dashicons_frontend' => false, |
| 110 | 'disable_oembed' => false, |
| 111 | 'disable_rss_feeds' => false, |
| 112 | 'disable_xmlrpc' => false, |
| 113 | 'restrict_rest_to_authed' => false, |
| 114 | 'strip_jquery_migrate' => false, |
| 115 | ), |
| 116 | 'browser-cache' => array( |
| 117 | 'enabled' => false, |
| 118 | ), |
| 119 | 'cache' => array( |
| 120 | 'mobile_separate' => false, |
| 121 | ), |
| 122 | 'cdn' => array( |
| 123 | 'enabled' => false, |
| 124 | ), |
| 125 | 'cloudflare' => array( |
| 126 | 'enabled' => false, |
| 127 | ), |
| 128 | 'fonts' => array( |
| 129 | 'font_display_swap' => false, |
| 130 | ), |
| 131 | 'gzip' => array( |
| 132 | 'gzip_enabled' => false, |
| 133 | ), |
| 134 | 'lazy' => array( |
| 135 | 'add_missing_dimensions' => false, |
| 136 | 'lazy_iframes' => false, |
| 137 | 'lazy_images' => false, |
| 138 | 'lazy_videos' => false, |
| 139 | 'video_facade' => false, |
| 140 | ), |
| 141 | 'minify' => array( |
| 142 | 'async_css' => false, |
| 143 | 'combine_css' => false, |
| 144 | 'combine_js' => false, |
| 145 | 'defer_js' => false, |
| 146 | 'delay_js' => false, |
| 147 | 'minify_css' => false, |
| 148 | 'minify_html' => false, |
| 149 | 'minify_js' => false, |
| 150 | 'remove_query_strings' => false, |
| 151 | ), |
| 152 | 'preloader' => array( |
| 153 | 'enabled' => false, |
| 154 | 'warm_on_comment' => false, |
| 155 | 'warm_on_publish' => false, |
| 156 | ), |
| 157 | 'resource-hints' => array( |
| 158 | 'enabled' => false, |
| 159 | 'lcp_preload' => false, |
| 160 | 'preconnect' => false, |
| 161 | ), |
| 162 | 'score' => array( |
| 163 | 'enabled' => false, |
| 164 | ), |
| 165 | ); |
| 166 | |
| 167 | /** |
| 168 | * On disk at all, active or not — what an OFFER should gate on. A site that |
| 169 | * already has xSpeed has decided about it; re-offering one the user |
| 170 | * deactivated is nagging. |
| 171 | */ |
| 172 | public static function is_installed(): bool |
| 173 | { |
| 174 | return isset(self::installed_plugins()[self::PLUGIN_FILE]); |
| 175 | } |
| 176 | |
| 177 | public static function is_active(): bool |
| 178 | { |
| 179 | if (! function_exists('is_plugin_active')) { |
| 180 | self::load_plugin_api(); |
| 181 | } |
| 182 | |
| 183 | return function_exists('is_plugin_active') && is_plugin_active(self::PLUGIN_FILE); |
| 184 | } |
| 185 | |
| 186 | /** |
| 187 | * Has xSpeed ever run on this site? |
| 188 | * |
| 189 | * The same signal xSpeed's own Settings::set_defaults() gates on: the option |
| 190 | * is created on first activation and survives deactivation, so its presence |
| 191 | * means these settings are the user's. Its absence means a genuinely fresh |
| 192 | * install, whose recommended profile has not been seeded yet. |
| 193 | * |
| 194 | * This is the question `prepare()` needs, and unlike "are the files on disk" |
| 195 | * it is immune to install ordering — activation can only run on an unpacked |
| 196 | * plugin, so by the time a host calls prepare() the files are always there. |
| 197 | */ |
| 198 | public static function has_settings(): bool |
| 199 | { |
| 200 | return false !== get_option(self::SETTINGS_OPTION, false); |
| 201 | } |
| 202 | |
| 203 | /** |
| 204 | * Did THIS class put xSpeed's settings here? |
| 205 | * |
| 206 | * The snapshot is prepare()'s receipt: written when it succeeds, cleared by |
| 207 | * finish(). Its presence is the only evidence that the page cache is ours to |
| 208 | * switch on — without it, prepare() refused because the site already had |
| 209 | * settings, and touching them would overwrite the choice the guard protects. |
| 210 | */ |
| 211 | public static function is_ours(): bool |
| 212 | { |
| 213 | return false !== get_option(self::SNAPSHOT_OPTION, false); |
| 214 | } |
| 215 | |
| 216 | public static function is_supported(): bool |
| 217 | { |
| 218 | global $wp_version; |
| 219 | |
| 220 | return version_compare((string) $wp_version, self::REQUIRES_WP, '>=') |
| 221 | && version_compare(PHP_VERSION, self::REQUIRES_PHP, '>='); |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Write the state xSpeed should come up with. Call BEFORE activation. |
| 226 | * |
| 227 | * Not a style choice. xSpeed's activation reads what is already stored rather |
| 228 | * than stamping over it: module seeders skip rows that exist, and |
| 229 | * Cache::restore_dropin_if_enabled() sees the page-cache flag already true and |
| 230 | * installs advanced-cache.php and WP_CACHE itself. Configuring AFTER activation |
| 231 | * instead does not work and does not complain — Settings_Manager::update() |
| 232 | * resolves modules through Module_Registry, which is empty for a plugin |
| 233 | * activated part-way through the request, so it returns without writing. |
| 234 | * |
| 235 | * Refuses when xSpeed has settings here already — see has_settings(). Not when |
| 236 | * its files are on disk: activation can only run on an unpacked plugin, so the |
| 237 | * files are always present by the time a host calls this, and guarding on them |
| 238 | * meant nothing was ever written. |
| 239 | * |
| 240 | * Even so, only the keys in INITIAL_SETTINGS are written; anything else in a row |
| 241 | * is preserved. Option rows survive plugin deletion, so a site with no xSpeed |
| 242 | * can still carry lazy-load exclusions, a preloader sitemap or browser-cache |
| 243 | * lifetimes from a previous install, and there is no reason to destroy them to |
| 244 | * turn four switches off. |
| 245 | * |
| 246 | * $enable_cache false installs and configures xSpeed WITHOUT taking the |
| 247 | * page cache — for a site where the Detector found an incumbent. Do not |
| 248 | * mistake it for "skip this call": skipping means xSpeed's own |
| 249 | * set_defaults() sees a fresh install and seeds its recommended profile, |
| 250 | * switching on gzip, browser caching and minification. On the very site |
| 251 | * where you promised to touch nothing, the user would get markup |
| 252 | * rewriting they never agreed to, and no page cache either. Writing the |
| 253 | * settings option is what suppresses that first-run path, so this still |
| 254 | * has to run — just with the page cache left off. |
| 255 | * |
| 256 | * @param bool $enable_cache Whether xSpeed should come up owning the page |
| 257 | * cache. Pass Detector::is_field_clear(). |
| 258 | * |
| 259 | * @return bool False means xSpeed already has settings here, so they are the |
| 260 | * user's and not ours to replace. Not something to retry. |
| 261 | */ |
| 262 | public static function prepare(bool $enable_cache = true): bool |
| 263 | { |
| 264 | if (self::has_settings()) { |
| 265 | /* |
| 266 | * A leftover snapshot says these settings are an earlier |
| 267 | * prepare() of ours that never reached finish() or rollback() — |
| 268 | * the request died between them, which on shared hosting is an |
| 269 | * activation that timed out. Left alone it is worse than |
| 270 | * untidy: a stranded `cache_enabled => true` makes xSpeed's own |
| 271 | * restore_dropin_if_enabled() install the drop-in the next time |
| 272 | * the user activates by hand, which is page caching nobody asked |
| 273 | * for. Undo it and start again. |
| 274 | */ |
| 275 | if (! self::is_ours()) { |
| 276 | return false; |
| 277 | } |
| 278 | |
| 279 | self::rollback(); |
| 280 | |
| 281 | if (self::has_settings()) { |
| 282 | return false; |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | $snapshot = array(); |
| 287 | $settings = get_option(self::SETTINGS_OPTION, null); |
| 288 | |
| 289 | $snapshot[self::SETTINGS_OPTION] = $settings; |
| 290 | update_option( |
| 291 | self::SETTINGS_OPTION, |
| 292 | array_merge( |
| 293 | is_array($settings) ? $settings : array(), |
| 294 | array(self::PAGE_CACHE_KEY => $enable_cache) |
| 295 | ), |
| 296 | false |
| 297 | ); |
| 298 | |
| 299 | foreach (self::INITIAL_SETTINGS as $slug => $values) { |
| 300 | $option = self::MODULE_PREFIX . $slug; |
| 301 | $stored = get_option($option, null); |
| 302 | |
| 303 | $snapshot[$option] = $stored; |
| 304 | update_option( |
| 305 | $option, |
| 306 | array_merge(is_array($stored) ? $stored : array(), $values), |
| 307 | false |
| 308 | ); |
| 309 | } |
| 310 | |
| 311 | update_option(self::SNAPSHOT_OPTION, $snapshot, false); |
| 312 | |
| 313 | return true; |
| 314 | } |
| 315 | |
| 316 | /** |
| 317 | * Undo prepare() when the install did not survive to activation. |
| 318 | * |
| 319 | * Restores what was there rather than deleting: a row prepare() merged into |
| 320 | * belonged to the site before we touched it. A stranded `cache_enabled => true` |
| 321 | * would otherwise tell a LATER hand-install to bring up page caching nobody |
| 322 | * asked for. |
| 323 | */ |
| 324 | public static function rollback(): void |
| 325 | { |
| 326 | $snapshot = get_option(self::SNAPSHOT_OPTION, null); |
| 327 | |
| 328 | if (! is_array($snapshot)) { |
| 329 | return; |
| 330 | } |
| 331 | |
| 332 | foreach ($snapshot as $option => $previous) { |
| 333 | if (null === $previous) { |
| 334 | delete_option($option); |
| 335 | continue; |
| 336 | } |
| 337 | |
| 338 | update_option($option, $previous, false); |
| 339 | } |
| 340 | |
| 341 | delete_option(self::SNAPSHOT_OPTION); |
| 342 | } |
| 343 | |
| 344 | /** |
| 345 | * Finish up. Call AFTER a successful activation. |
| 346 | * |
| 347 | * Activation arms the setup-wizard redirect unconditionally, so nothing |
| 348 | * prepare() does can prevent it — it has to be cleared here. Clearing the |
| 349 | * redirect deliberately does not mark onboarding complete; the user did not |
| 350 | * complete it, and the wizard stays in xSpeed's own menu. |
| 351 | * |
| 352 | * The page-cache fallback is for when the drop-in or wp-config.php write was |
| 353 | * refused: better the long way round than a cache plugin that caches nothing. |
| 354 | * |
| 355 | * @return bool True when xSpeed is active and page caching is live. |
| 356 | */ |
| 357 | public static function finish(): bool |
| 358 | { |
| 359 | if (! self::is_active()) { |
| 360 | return false; |
| 361 | } |
| 362 | |
| 363 | // Without prepare()'s receipt these settings are not ours, and enabling |
| 364 | // here would overwrite the very choice the guard protects — two lines |
| 365 | // later in the same documented flow. |
| 366 | $ours = self::is_ours(); |
| 367 | |
| 368 | // Cancelling the wizard redirect is safe either way: the host caused |
| 369 | // the activation, so nobody asked to be sent to onboarding. |
| 370 | delete_option(self::WIZARD_REDIRECT_OPTION); |
| 371 | |
| 372 | if (! $ours) { |
| 373 | return self::page_cache_is_live(); |
| 374 | } |
| 375 | |
| 376 | /* |
| 377 | * prepare( false ) asked for an install that does NOT take the page |
| 378 | * cache, and that choice is recorded in the settings row. Enabling |
| 379 | * here would install advanced-cache.php over the incumbent's — the |
| 380 | * exact conflict the Detector exists to prevent — so the flag is |
| 381 | * read back rather than assumed. |
| 382 | * |
| 383 | * The snapshot still goes: activation succeeded, so there is nothing |
| 384 | * to roll back to, and a stranded receipt makes the next prepare() |
| 385 | * undo settings that are now legitimately the site's. |
| 386 | */ |
| 387 | if (! self::page_cache_requested()) { |
| 388 | delete_option(self::SNAPSHOT_OPTION); |
| 389 | |
| 390 | return false; |
| 391 | } |
| 392 | |
| 393 | if (! self::page_cache_is_live()) { |
| 394 | self::enable_page_cache(); |
| 395 | } |
| 396 | |
| 397 | // Activation succeeded, so there is nothing left to roll back to. |
| 398 | delete_option(self::SNAPSHOT_OPTION); |
| 399 | |
| 400 | return self::page_cache_is_live(); |
| 401 | } |
| 402 | |
| 403 | /** |
| 404 | * Whether page caching took effect, not merely that it was requested: the |
| 405 | * flag proves nothing without the drop-in and the WP_CACHE constant. |
| 406 | * |
| 407 | * The constant is read from wp-config.php, not from `defined()`. Enabling the |
| 408 | * cache WRITES it there, and nothing defines it for the request already in |
| 409 | * flight — so on a clean site, exactly the site the detector green-lights, the |
| 410 | * runtime check is false the whole way through a successful install. Trusting |
| 411 | * it made finish() re-run the entire enable and then report failure on a |
| 412 | * perfectly good install. |
| 413 | */ |
| 414 | /** |
| 415 | * Whether the stored settings ASK for page caching — what prepare() was |
| 416 | * told, not whether it took effect. |
| 417 | * |
| 418 | * The distinction matters to finish(): "not live yet" is a job to do, |
| 419 | * "not requested" is a decision to respect, and page_cache_is_live() |
| 420 | * cannot tell them apart. |
| 421 | */ |
| 422 | public static function page_cache_requested(): bool |
| 423 | { |
| 424 | $options = get_option(self::SETTINGS_OPTION, array()); |
| 425 | |
| 426 | return is_array($options) && ! empty($options[self::PAGE_CACHE_KEY]); |
| 427 | } |
| 428 | |
| 429 | public static function page_cache_is_live(): bool |
| 430 | { |
| 431 | $options = get_option(self::SETTINGS_OPTION, array()); |
| 432 | |
| 433 | return is_array($options) |
| 434 | && ! empty($options[self::PAGE_CACHE_KEY]) |
| 435 | && file_exists(WP_CONTENT_DIR . '/advanced-cache.php') |
| 436 | && self::wp_cache_constant_written(); |
| 437 | } |
| 438 | |
| 439 | /** |
| 440 | * Is WP_CACHE set to true in wp-config.php? |
| 441 | * |
| 442 | * Delegated to Detector, which already reads that file and understands the |
| 443 | * spellings hosts use (`true`, `1`, `'1'`, `TRUE`) and which of them are |
| 444 | * literals. A second parser here disagreed with it on both the accepted |
| 445 | * values and where wp-config.php lives; two copies of one lookup drift. |
| 446 | * |
| 447 | * Detector memoises, and the constant may have been written moments ago by |
| 448 | * the very call we are checking, so drop the memo first. |
| 449 | */ |
| 450 | private static function wp_cache_constant_written(): bool |
| 451 | { |
| 452 | if (defined('WP_CACHE') && WP_CACHE) { |
| 453 | return true; |
| 454 | } |
| 455 | |
| 456 | if (! class_exists(__NAMESPACE__ . '\\Detector')) { |
| 457 | // Setup is meant to be copied alongside Detector. Without it the |
| 458 | // file cannot be read, and "we could not tell" must not read as |
| 459 | // "it is live". |
| 460 | return false; |
| 461 | } |
| 462 | |
| 463 | Detector::invalidate(); |
| 464 | $report = Detector::inspect(); |
| 465 | |
| 466 | return isset($report['wp_cache']['state']) && 'true' === $report['wp_cache']['state']; |
| 467 | } |
| 468 | |
| 469 | /** |
| 470 | * Fallback: switch page caching on the long way. |
| 471 | * |
| 472 | * The flag cannot simply be written — Settings_Manager rejects that key by |
| 473 | * name, because it is what drives the drop-in install and the wp-config.php |
| 474 | * edit, so a bare write leaves a site claiming a cache it does not have (which |
| 475 | * Detector then reads as unknown-occupied). Mirrors Rest_Api::toggle_cache(). |
| 476 | * |
| 477 | * That REST route would be tidier, but a host that just activated xSpeed |
| 478 | * part-way through a request cannot reach it: rest_api_init has already fired. |
| 479 | */ |
| 480 | private static function enable_page_cache(): void |
| 481 | { |
| 482 | if (! class_exists('\\XSpeed\\Cache') || ! class_exists('\\XSpeed\\Settings')) { |
| 483 | return; |
| 484 | } |
| 485 | |
| 486 | try { |
| 487 | $state = \XSpeed\Cache::toggle(true); |
| 488 | \XSpeed\Settings::update( |
| 489 | array(self::PAGE_CACHE_KEY => ! empty($state['enabled'])) |
| 490 | ); |
| 491 | } catch (\Throwable $e) { |
| 492 | // A cache that would not switch on must never take the host's |
| 493 | // own operation down with it. |
| 494 | return; |
| 495 | } |
| 496 | |
| 497 | // Site state just changed under Detector's feet; drop its memo so a |
| 498 | // later question in this request does not get the pre-install answer. |
| 499 | if (class_exists(__NAMESPACE__ . '\\Detector')) { |
| 500 | Detector::invalidate(); |
| 501 | } |
| 502 | } |
| 503 | |
| 504 | /** |
| 505 | * @return array<string,array> |
| 506 | */ |
| 507 | private static function installed_plugins(): array |
| 508 | { |
| 509 | if (! function_exists('get_plugins')) { |
| 510 | self::load_plugin_api(); |
| 511 | } |
| 512 | |
| 513 | return function_exists('get_plugins') ? get_plugins() : array(); |
| 514 | } |
| 515 | |
| 516 | private static function load_plugin_api(): void |
| 517 | { |
| 518 | if (defined('ABSPATH') && file_exists(ABSPATH . 'wp-admin/includes/plugin.php')) { |
| 519 | require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 520 | } |
| 521 | } |
| 522 | } |
| 523 | } |
| 524 |