| 1 |
<?php |
| 2 |
|
| 3 |
namespace Templately\Utils; |
| 4 |
|
| 5 |
use WPDeveloper\PageCacheSafety\Detector; |
| 6 |
|
| 7 |
/** |
| 8 |
* The caching solution Templately offers on the import dependency screen. |
| 9 |
* |
| 10 |
* Everything vendor-specific about the recommended plugin — its file, slug, display details, |
| 11 |
* option names and the settings it should come up with — is confined to the constants at the |
| 12 |
* top of this class. Nothing else in the plugin names it. Swapping the recommendation for a |
| 13 |
* different caching plugin is an edit to this block and its INITIAL_SETTINGS map; no caller |
| 14 |
* changes. |
| 15 |
* |
| 16 |
* Two jobs: decide whether to offer a caching solution at all, and — only once the user has |
| 17 |
* left the row ticked and it has actually installed — leave it configured the way the offer |
| 18 |
* implied. |
| 19 |
*/ |
| 20 |
class Caching { |
| 21 |
|
| 22 |
/** |
| 23 |
* The recommended plugin. The display fields are what the user sees on the dependency |
| 24 |
* row and are deliberately the plugin's real name and listing, so the row says what it |
| 25 |
* installs. |
| 26 |
*/ |
| 27 |
const PLUGIN_FILE = 'xspeed/xspeed.php'; |
| 28 |
const PLUGIN_SLUG = 'xspeed'; |
| 29 |
const PLUGIN_NAME = 'xSpeed Cache'; |
| 30 |
const PLUGIN_ICON = 'https://ps.w.org/xspeed/assets/icon-256x256.png'; |
| 31 |
const PLUGIN_LINK = 'https://wordpress.org/plugins/xspeed/'; |
| 32 |
|
| 33 |
/** |
| 34 |
* Where the recommended plugin keeps its settings. |
| 35 |
*/ |
| 36 |
const SETTINGS_OPTION = 'xspeed_options'; |
| 37 |
const MODULE_PREFIX = 'xspeed_module_'; |
| 38 |
|
| 39 |
/** |
| 40 |
* The option its activation sets to force a first-run setup wizard redirect. Written |
| 41 |
* unconditionally on activation and consumed on the next admin_init. |
| 42 |
*/ |
| 43 |
const WIZARD_REDIRECT_OPTION = 'xspeed_redirect_to_onboarding'; |
| 44 |
|
| 45 |
/** |
| 46 |
* When the suggestion was first put in front of this site, as a Unix timestamp. |
| 47 |
* |
| 48 |
* The offer is made once. A user who ticked it has the plugin; a user who unticked it |
| 49 |
* said no, and asking again on their next import is nagging. Delete this option to offer |
| 50 |
* it again — that is the supported reset, for support staff and for testing. |
| 51 |
* |
| 52 |
* Site-scoped rather than per-user: whether this site wants a page cache is a fact about |
| 53 |
* the site, and a second administrator should not be re-asked a question the first one |
| 54 |
* already answered. |
| 55 |
*/ |
| 56 |
const OFFER_SHOWN_OPTION = 'templately_caching_offer_shown'; |
| 57 |
|
| 58 |
/** |
| 59 |
* How long after the first showing the row keeps appearing. |
| 60 |
* |
| 61 |
* Without this, "once" would mean once per HTTP request rather than once per user. The |
| 62 |
* dependency step re-fetches whenever the wizard is reopened or the user steps back and |
| 63 |
* forward, so a flag set on first render would make the row vanish underneath someone |
| 64 |
* who was still deciding about it. Inside the window the answer is unchanged; after it, |
| 65 |
* the offer is spent. |
| 66 |
*/ |
| 67 |
const OFFER_GRACE = 1800; |
| 68 |
|
| 69 |
/** |
| 70 |
* Version floors, pinned rather than read from plugins_api(). |
| 71 |
* |
| 72 |
* The recommended plugin asks for more than Templately advertises (readme.txt: |
| 73 |
* WordPress 5.0, PHP 7.2), and Installer::check_compatibility() turns a mismatch into a |
| 74 |
* hard failure of the import's plugin step — so a pre-ticked row on an older site is a |
| 75 |
* broken import, not a declined suggestion. Fetching the real numbers per request would |
| 76 |
* mean a network call on a screen the user is already waiting on. The cost of pinning |
| 77 |
* them is that a floor change needs a matching edit here. |
| 78 |
*/ |
| 79 |
const REQUIRES_WP = '6.0'; |
| 80 |
const REQUIRES_PHP = '7.4'; |
| 81 |
|
| 82 |
/** |
| 83 |
* The settings a Templately-installed caching plugin should come up with. |
| 84 |
* |
| 85 |
* The whole desired end state, not a list of things to switch off, because pre-writing |
| 86 |
* SETTINGS_OPTION suppresses whatever first-run path the plugin otherwise uses to seed |
| 87 |
* these. Browser caching defaults to `false` in its own schema and was only ever on |
| 88 |
* because of that path — so leaving it out here silently turned it off. Anything wanted |
| 89 |
* on has to be said out loud. |
| 90 |
* |
| 91 |
* Page caching is the only thing Templately switches on. The row the user ticked offered |
| 92 |
* a page cache, so that is what they get — browser caching, minification, lazy loading, |
| 93 |
* resource hints and the rest all stay off, whatever the plugin would have enabled for |
| 94 |
* itself. |
| 95 |
* |
| 96 |
* Every module is written explicitly, including the ones that would be off anyway. |
| 97 |
* Measured against 1.2.0 with no rows written at all: lazy, resource-hints, fonts and |
| 98 |
* preloader come up ON — their schema defaults are true — so writing those is what turns |
| 99 |
* them off. gzip, minify and browser-cache come up off, but only because pre-writing |
| 100 |
* SETTINGS_OPTION happens to suppress the plugin's first-run seeding; a normal install |
| 101 |
* brings all three up ON. Leaning on that side effect is what silently re-enabled browser |
| 102 |
* caching once already, so the redundant rows stay. |
| 103 |
* |
| 104 |
* Deliberately absent, and left exactly as the plugin sets them: its GDPR consent |
| 105 |
* requirement, its Cloudflare purge behaviour, and its object-cache flag. None is an |
| 106 |
* optimisation, and for the first of them off would be the wrong answer. A blanket |
| 107 |
* "everything except browser caching" rule would have caught all three, and would |
| 108 |
* silently swallow whatever module the plugin ships next. The cost of naming them is |
| 109 |
* that a future opt-in-by-default optimisation has to be added here by hand. |
| 110 |
* |
| 111 |
* Keys are module slugs, values the fields written to MODULE_PREFIX . <slug>. Verified |
| 112 |
* against the recommended plugin at 1.2.0. |
| 113 |
*/ |
| 114 |
const INITIAL_SETTINGS = array( |
| 115 |
'browser-cache' => array( |
| 116 |
'enabled' => false, |
| 117 |
), |
| 118 |
'gzip' => array( |
| 119 |
'gzip_enabled' => false, |
| 120 |
), |
| 121 |
'minify' => array( |
| 122 |
'minify_html' => false, |
| 123 |
'minify_css' => false, |
| 124 |
), |
| 125 |
'lazy' => array( |
| 126 |
'lazy_images' => false, |
| 127 |
'lazy_iframes' => false, |
| 128 |
'lazy_videos' => false, |
| 129 |
'add_missing_dimensions' => false, |
| 130 |
), |
| 131 |
'resource-hints' => array( |
| 132 |
'enabled' => false, |
| 133 |
'lcp_preload' => false, |
| 134 |
'preconnect' => false, |
| 135 |
), |
| 136 |
'fonts' => array( |
| 137 |
'font_display_swap' => false, |
| 138 |
), |
| 139 |
'preloader' => array( |
| 140 |
'warm_on_publish' => false, |
| 141 |
), |
| 142 |
); |
| 143 |
|
| 144 |
/** |
| 145 |
* Present on disk at all, active or not. |
| 146 |
*/ |
| 147 |
public static function is_installed(): bool { |
| 148 |
return isset( Helper::get_plugins()[ self::PLUGIN_FILE ] ); |
| 149 |
} |
| 150 |
|
| 151 |
public static function is_active(): bool { |
| 152 |
return Helper::is_plugin_active( self::PLUGIN_FILE ); |
| 153 |
} |
| 154 |
|
| 155 |
/** |
| 156 |
* Whether the suggestion has already had its turn, grace window elapsed. |
| 157 |
*/ |
| 158 |
public static function has_been_offered(): bool { |
| 159 |
$shown = (int) get_option( self::OFFER_SHOWN_OPTION, 0 ); |
| 160 |
|
| 161 |
return $shown > 0 && ( time() - $shown ) > self::OFFER_GRACE; |
| 162 |
} |
| 163 |
|
| 164 |
/** |
| 165 |
* Record that the row went out. First writing wins, so the grace window is measured from |
| 166 |
* the first showing rather than being pushed forward by every re-render. |
| 167 |
*/ |
| 168 |
public static function mark_offered() { |
| 169 |
if ( ! get_option( self::OFFER_SHOWN_OPTION ) ) { |
| 170 |
update_option( self::OFFER_SHOWN_OPTION, time(), false ); |
| 171 |
} |
| 172 |
} |
| 173 |
|
| 174 |
/** |
| 175 |
* The dependency row, in the same shape as every other entry on that screen. |
| 176 |
* |
| 177 |
* `installed` is always false and not worth deriving: the offer is withheld outright |
| 178 |
* when the plugin is on disk at all, so anything reaching here is a fresh install. false |
| 179 |
* is also what keeps the checkbox enabled, which is the point of offering it. `mustHave` |
| 180 |
* is omitted — this is a suggestion, not a requirement. |
| 181 |
*/ |
| 182 |
public static function dependency_entry(): array { |
| 183 |
return array( |
| 184 |
'name' => self::PLUGIN_NAME, |
| 185 |
'icon' => self::PLUGIN_ICON, |
| 186 |
'plugin_file' => self::PLUGIN_FILE, |
| 187 |
'plugin_original_slug' => self::PLUGIN_SLUG, |
| 188 |
'is_pro' => false, |
| 189 |
'installed' => false, |
| 190 |
'link' => self::PLUGIN_LINK, |
| 191 |
); |
| 192 |
} |
| 193 |
|
| 194 |
/** |
| 195 |
* Whether to offer a caching solution alongside whatever the pack itself asked for. |
| 196 |
* |
| 197 |
* Three conditions, cheapest first. The first two are Templately's own, and the shared |
| 198 |
* detector cannot answer either: |
| 199 |
* |
| 200 |
* - Already offered once. See OFFER_SHOWN_OPTION. |
| 201 |
* - Already on disk. We only ever offer to put it there. A site that has it — running or |
| 202 |
* not — has made its own decision about that plugin, and a row for something already |
| 203 |
* sitting in wp-content is noise. |
| 204 |
* - Version floors, per the constants above. |
| 205 |
* |
| 206 |
* Only then the detector, which is the sole authority on whether anything owns the page |
| 207 |
* cache. The order is load-bearing, not incidental: the detector deliberately does not |
| 208 |
* catalogue the plugin we recommend — it answers "is anything *else* here", from a site |
| 209 |
* where that plugin may not be installed at all. An active copy with page caching |
| 210 |
* switched off leaves no drop-in, matches no catalogue entry, and classifies as |
| 211 |
* `unclaimed`. Consult the detector first and you offer users a plugin they already run. |
| 212 |
* |
| 213 |
* Read-only throughout: deciding installs, activates, and configures nothing. |
| 214 |
*/ |
| 215 |
public static function should_offer(): bool { |
| 216 |
if ( self::has_been_offered() ) { |
| 217 |
return false; |
| 218 |
} |
| 219 |
|
| 220 |
if ( self::is_installed() ) { |
| 221 |
return false; |
| 222 |
} |
| 223 |
|
| 224 |
global $wp_version; |
| 225 |
|
| 226 |
if ( version_compare( $wp_version, self::REQUIRES_WP, '<' ) |
| 227 |
|| version_compare( PHP_VERSION, self::REQUIRES_PHP, '<' ) ) { |
| 228 |
return false; |
| 229 |
} |
| 230 |
|
| 231 |
self::load_detector(); |
| 232 |
|
| 233 |
return Detector::is_field_clear(); |
| 234 |
} |
| 235 |
|
| 236 |
/** |
| 237 |
* Load the vendored page-cache detector. |
| 238 |
* |
| 239 |
* Copy-vendored from xSpeed Free: WPDevelopers/xspeed, branch |
| 240 |
* feat/portable-page-cache-detector (PR #297), page-cache-safety/, at commit |
| 241 |
* 2565b33fc90be8c3dae90aa1f0f1dd1f53339f14. |
| 242 |
* |
| 243 |
* That repo is the source of truth. Fixes go THERE and get re-copied here — never |
| 244 |
* patched in place, or the parity test that keeps every copy honest stops meaning |
| 245 |
* anything. Required at the point of use rather than at boot so it costs nothing on any |
| 246 |
* other request; its own class_exists() wrapper makes it safe for another plugin on the |
| 247 |
* same site to carry its own copy. |
| 248 |
*/ |
| 249 |
public static function load_detector() { |
| 250 |
require_once TEMPLATELY_PATH . 'includes/Vendor/page-cache-safety/class-page-cache-safety.php'; |
| 251 |
} |
| 252 |
|
| 253 |
/** |
| 254 |
* Write the settings the plugin should come up with, BEFORE it is activated. |
| 255 |
* |
| 256 |
* This is the primary mechanism, and the ordering is the whole trick. Its activation |
| 257 |
* reads what is already stored rather than stamping over it: |
| 258 |
* |
| 259 |
* - Each module seeds its option row only when one does not already exist, so a row we |
| 260 |
* wrote first survives activation untouched. |
| 261 |
* - Its cache drop-in restore runs during activation and, finding the page-cache flag |
| 262 |
* already true, installs `advanced-cache.php` and writes the `WP_CACHE` constant |
| 263 |
* itself — through the plugin's own supported path, on its own schedule. |
| 264 |
* |
| 265 |
* Verified at 1.2.0: with only this pre-write and no post-install step at all, the site |
| 266 |
* comes up with page caching live, browser caching on, and every other front-end |
| 267 |
* optimisation off. |
| 268 |
* |
| 269 |
* Doing it this way sidesteps the trap the post-install approach fell into. The plugin's |
| 270 |
* settings manager resolves modules through a registry that is empty for a plugin |
| 271 |
* activated part-way through the request — it returns without writing and without |
| 272 |
* complaining. These are plain update_option() calls that need none of its code to be |
| 273 |
* loaded, because at this point none of it is. |
| 274 |
* |
| 275 |
* Called immediately before activation so a failed download never leaves rows behind; if |
| 276 |
* activation itself fails, forget_settings() takes them back out. |
| 277 |
*/ |
| 278 |
public static function prepare_settings() { |
| 279 |
update_option( self::SETTINGS_OPTION, array( 'cache_enabled' => true ) ); |
| 280 |
|
| 281 |
foreach ( self::INITIAL_SETTINGS as $slug => $values ) { |
| 282 |
update_option( self::MODULE_PREFIX . $slug, $values ); |
| 283 |
} |
| 284 |
} |
| 285 |
|
| 286 |
/** |
| 287 |
* Undo prepare_settings() when the install did not survive to activation. |
| 288 |
* |
| 289 |
* Leaving these rows on a site that has no caching plugin is litter, and worse, a |
| 290 |
* page-cache flag sitting there would tell a LATER hand-install to bring up caching the |
| 291 |
* user never asked for. |
| 292 |
*/ |
| 293 |
public static function forget_settings() { |
| 294 |
delete_option( self::SETTINGS_OPTION ); |
| 295 |
|
| 296 |
foreach ( array_keys( self::INITIAL_SETTINGS ) as $slug ) { |
| 297 |
delete_option( self::MODULE_PREFIX . $slug ); |
| 298 |
} |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* Finish the job after a successful, Templately-driven activation. |
| 303 |
* |
| 304 |
* Only for an install Templately performed. Deliberately not hooked to `activated_plugin`: |
| 305 |
* that fires when the user activates the plugin themselves from the Plugins screen, and |
| 306 |
* reconfiguring their site off the back of an action they took elsewhere would be exactly |
| 307 |
* the overreach this feature is trying not to commit. |
| 308 |
* |
| 309 |
* Two jobs left, because pre-writing cannot cover either: |
| 310 |
* |
| 311 |
* - The setup-wizard redirect, which activation arms unconditionally. |
| 312 |
* - A safety net for page caching. The pre-write should already have caused the plugin to |
| 313 |
* install its drop-in; if it did not, enable it the long way rather than leave the user |
| 314 |
* with a cache plugin that caches nothing. |
| 315 |
* |
| 316 |
* Nothing here may break the import. A caching plugin that installed but did not |
| 317 |
* configure is a worse outcome than one that did, and a far better one than a failed |
| 318 |
* import. |
| 319 |
*/ |
| 320 |
public static function configure_after_install() { |
| 321 |
if ( ! self::is_active() ) { |
| 322 |
return; |
| 323 |
} |
| 324 |
|
| 325 |
self::suppress_setup_wizard(); |
| 326 |
|
| 327 |
if ( ! self::page_cache_is_live() ) { |
| 328 |
self::enable_page_cache(); |
| 329 |
} |
| 330 |
} |
| 331 |
|
| 332 |
/** |
| 333 |
* Drop the one-time wizard redirect the activation hook just armed. |
| 334 |
* |
| 335 |
* The one thing pre-writing cannot prevent: the flag is set unconditionally on |
| 336 |
* activation, so it has to be cleared afterwards. |
| 337 |
* |
| 338 |
* The user came here to import a template. Bouncing them into another plugin's setup |
| 339 |
* wizard on their next wp-admin page load is not what they asked for. The wizard stays in |
| 340 |
* that plugin's own menu and can be run whenever they like — this cancels only the forced |
| 341 |
* redirect, and deliberately does not mark onboarding "complete", which would be a claim |
| 342 |
* about something the user never did. |
| 343 |
*/ |
| 344 |
private static function suppress_setup_wizard() { |
| 345 |
delete_option( self::WIZARD_REDIRECT_OPTION ); |
| 346 |
} |
| 347 |
|
| 348 |
/** |
| 349 |
* Whether page caching actually took effect, rather than merely being requested. |
| 350 |
* |
| 351 |
* The stored flag on its own proves nothing — it is the drop-in and the constant that |
| 352 |
* make WordPress serve from cache, and either can be missing if the filesystem or |
| 353 |
* wp-config.php refused the write. |
| 354 |
*/ |
| 355 |
private static function page_cache_is_live(): bool { |
| 356 |
$options = get_option( self::SETTINGS_OPTION, array() ); |
| 357 |
|
| 358 |
return ! empty( $options['cache_enabled'] ) |
| 359 |
&& file_exists( WP_CONTENT_DIR . '/advanced-cache.php' ) |
| 360 |
&& defined( 'WP_CACHE' ) && WP_CACHE; |
| 361 |
} |
| 362 |
|
| 363 |
/** |
| 364 |
* Fallback: switch page caching on the long way. |
| 365 |
* |
| 366 |
* Only reached when the pre-write did not take — the drop-in restore declined, or the |
| 367 |
* drop-in / wp-config.php write failed. The happy path never comes here. |
| 368 |
* |
| 369 |
* The page-cache flag cannot simply be written: the plugin's own settings manager rejects |
| 370 |
* that key by name, because the flag is what drives the drop-in install and the |
| 371 |
* wp-config.php edit. A bare write leaves a site claiming a cache it does not have — |
| 372 |
* which the very detector that decided to offer it would then read as `unknown-occupied`. |
| 373 |
* The toggle does the drop-in and the constant; the settings write after it persists the |
| 374 |
* flag, mirroring the plugin's own REST handler. |
| 375 |
* |
| 376 |
* That REST route is its documented entry point and would be the tidier call, but it is |
| 377 |
* unreachable here: the plugin was activated part-way through THIS request, so |
| 378 |
* `rest_api_init` has already fired and its routes are not registered. These static calls |
| 379 |
* are the same code that route runs. |
| 380 |
*/ |
| 381 |
private static function enable_page_cache() { |
| 382 |
if ( ! class_exists( '\XSpeed\Cache' ) || ! class_exists( '\XSpeed\Settings' ) ) { |
| 383 |
return; |
| 384 |
} |
| 385 |
|
| 386 |
try { |
| 387 |
$state = \XSpeed\Cache::toggle( true ); |
| 388 |
\XSpeed\Settings::update( array( 'cache_enabled' => ! empty( $state['enabled'] ) ) ); |
| 389 |
} catch ( \Throwable $e ) { |
| 390 |
// A failed cache switch-on must never take the import down with it. |
| 391 |
Helper::log( 'Page cache could not be enabled: ' . $e->getMessage() ); |
| 392 |
return; |
| 393 |
} |
| 394 |
|
| 395 |
// The site's cache state just changed under the detector's feet. Anything asking |
| 396 |
// again in this request must not get the pre-install answer back from its memo. |
| 397 |
if ( class_exists( '\WPDeveloper\PageCacheSafety\Detector' ) ) { |
| 398 |
Detector::invalidate(); |
| 399 |
} |
| 400 |
} |
| 401 |
} |
| 402 |
|