PluginProbe ʕ •ᴥ•ʔ
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents / 4.6.5
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents v4.6.5
4.6.5 4.6.4 4.6.3 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 trunk 1.0.0 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.5.0 1.6.0 1.6.1 1.6.2 1.6.3 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 2.0.0 2.0.1 2.0.2 2.0.3 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.2.0 2.2.1 2.2.2 2.3.0 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.1.0 3.1.1 3.1.2 3.1.3 3.2.0 3.2.1 3.3.0 3.3.1 3.3.2 3.3.3 3.3.4 3.3.5 3.3.6 3.3.7 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.6.8 3.7.0 3.7.1 3.7.2 3.7.3 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.9.0 3.9.1 3.9.10 3.9.11 3.9.12 3.9.13 3.9.14 3.9.15 3.9.16 3.9.17 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9 4.0.0 4.0.1 4.0.10 4.0.11 4.0.12 4.0.13 4.0.14 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.10 4.1.2 4.1.3 4.1.4 4.1.5 4.1.6 4.1.7 4.1.8 4.1.9 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.2.5 4.2.6 4.2.7 4.2.8 4.2.9 4.3.0 4.3.1 4.4.0 4.4.1 4.4.10 4.4.11 4.4.2 4.4.3 4.4.4 4.4.5 4.4.6 4.4.7 4.4.8 4.4.9 4.5.0 4.5.1
embedpress / includes / Vendor / page-cache-safety / class-page-cache-safety.php
embedpress / includes / Vendor / page-cache-safety Last commit date
class-page-cache-safety.php 5 days ago class-page-cache-setup.php 5 days ago
class-page-cache-safety.php
1099 lines
1 <?php
2
3 /**
4 * Page Cache Safety — a portable, read-only answer to "does this WordPress
5 * site already have a page cache, and whose is it?"
6 *
7 * Copy this ONE file into any WPDeveloper plugin that needs to decide whether
8 * to offer, install, or activate a caching plugin. It depends on WordPress and
9 * nothing else — no composer package, no xSpeed, no autoloader. That is the
10 * point: the decision has to be answerable on a site where xSpeed is not
11 * installed yet.
12 *
13 * Usage:
14 *
15 * require_once __DIR__ . '/page-cache-safety/class-page-cache-safety.php';
16 *
17 * if ( \WPDeveloper\PageCacheSafety\Detector::is_field_clear() ) {
18 * // Nothing owns the page cache. Safe to promote / install / activate.
19 * } else {
20 * $verdict = \WPDeveloper\PageCacheSafety\Detector::classify();
21 * // $verdict['state'] — one of the STATE_* constants
22 * // $verdict['blockers'] — [ ['code' => …, 'plugin' => …, 'label' => …], … ]
23 * }
24 *
25 * Blockers carry message CODES, not sentences: the host plugin owns its own
26 * textdomain, so it renders the wording. See BLOCKER_* below.
27 *
28 * What this file will never do: write anything, include or execute another
29 * plugin's PHP, or treat a loose substring like "cache" as proof of ownership.
30 * An unreadable or unattributable artifact is reported as a blocker, never as
31 * a pass — "we could not tell" and "the field is clear" are different answers
32 * and only one of them licenses an install.
33 *
34 * Source of truth: the xSpeed Free repo, page-cache-safety/. It is deliberately
35 * NOT loaded by xSpeed and NOT shipped in its zip — it exists to be copied.
36 * Improve it there and re-copy; do not fork it per plugin.
37 *
38 * @package WPDeveloper\PageCacheSafety
39 * @version 1.4.1
40 */
41
42 namespace WPDeveloper\PageCacheSafety;
43
44 defined('ABSPATH') || exit;
45
46 /*
47 * Two plugins on the same site can each carry a copy of this file. First one
48 * loaded wins, which is fine — the classes are pure functions over site state,
49 * so any version answers the same question. VERSION is here so a host that
50 * cares can log which copy it got.
51 */
52 if (! class_exists(__NAMESPACE__ . '\\Detector')) {
53
54 final class Detector
55 {
56
57 public const VERSION = '1.4.1';
58
59 /* Ownership states. */
60
61 /** Nothing owns the page cache. */
62 public const STATE_UNCLAIMED = 'unclaimed';
63 /** A page cache we can name is installed and serving. */
64 public const STATE_FOREIGN_LIVE = 'foreign-live';
65 /** Cache files remain but nothing is wired up to serve them. */
66 public const STATE_FOREIGN_RESIDUAL = 'foreign-residual';
67 /** A page-cache-capable plugin is active with no drop-in evidence. It may cache at server level (LiteSpeed), or have caching switched off — unprovable either way from here. */
68 public const STATE_POSSIBLE_LIVE = 'possible-live';
69 /** More than one page cache is in play. */
70 public const STATE_CONTESTED = 'contested';
71 /** A drop-in, or a live WP_CACHE, that we cannot attribute to anyone. */
72 public const STATE_UNKNOWN_OCCUPIED = 'unknown-occupied';
73 /** We could not read what we needed to decide. */
74 public const STATE_UNAVAILABLE = 'unavailable';
75
76 /* Blocker codes. The host plugin supplies the wording. */
77
78 /** A named plugin owns wp-content/advanced-cache.php. `plugin` + `label` are set. */
79 public const BLOCKER_FOREIGN_DROPIN = 'foreign_dropin';
80 /** A drop-in is installed that we cannot attribute. */
81 public const BLOCKER_UNKNOWN_DROPIN = 'unknown_dropin';
82 /** A drop-in is installed and could not be read. */
83 public const BLOCKER_UNREADABLE_DROPIN = 'unreadable_dropin';
84 /** A page-cache-capable plugin is active; its cache may or may not be on. `plugin` + `label` are set. */
85 public const BLOCKER_ACTIVE_PAGE_CACHE = 'active_page_cache';
86 /** Two or more page caches. */
87 public const BLOCKER_MULTIPLE_PAGE_CACHES = 'multiple_page_caches';
88 /** WP_CACHE is true but no drop-in owner was found. */
89 public const BLOCKER_WP_CACHE_ORPHANED = 'wp_cache_orphaned';
90 /** wp-config.php defines WP_CACHE more than once. */
91 public const BLOCKER_WP_CACHE_DUPLICATE = 'wp_cache_duplicate';
92 /** WP_CACHE is set from an expression, so it cannot be rewritten safely. */
93 public const BLOCKER_WP_CACHE_DYNAMIC = 'wp_cache_dynamic';
94 /** wp-config.php could not be read. */
95 public const BLOCKER_WP_CONFIG_UNREADABLE = 'wp_config_unreadable';
96
97 /* Drop-in owner classifications. */
98
99 public const OWNER_NONE = 'none';
100 public const OWNER_KNOWN = 'known';
101 public const OWNER_UNKNOWN = 'unknown';
102
103 /** Capability keys. */
104 public const CAP_PAGE_CACHE = 'page-cache';
105 public const CAP_OBJECT_CACHE = 'object-cache';
106 public const CAP_MINIFY = 'minify';
107
108 /**
109 * @var array|null Memoized report for this request.
110 */
111 private static $report = null;
112
113 /**
114 * Is it safe to promote, install, or activate a page-caching plugin?
115 *
116 * The one call most hosts need. True only when nothing owns the page
117 * cache and nothing about the site's state is unreadable or ambiguous.
118 */
119 public static function is_field_clear(): bool
120 {
121 $verdict = self::classify();
122
123 if (! empty($verdict['blockers'])) {
124 return false;
125 }
126
127 return in_array(
128 $verdict['state'],
129 array(self::STATE_UNCLAIMED, self::STATE_FOREIGN_RESIDUAL),
130 true
131 );
132 }
133
134 /**
135 * Labels of every ACTIVE plugin that can write a page cache. Handy for
136 * a promo screen that wants to say what it found.
137 *
138 * @return string[]
139 */
140 public static function active_page_caches(): array
141 {
142 $out = array();
143 foreach (self::inspect()['plugins'] as $plugin) {
144 if ($plugin['page_cache'] && $plugin['active']) {
145 $out[] = $plugin['label'];
146 }
147 }
148 return $out;
149 }
150
151 /**
152 * Who owns wp-content/advanced-cache.php, as a plugin label, or null
153 * when nobody does (or we cannot tell).
154 */
155 public static function dropin_owner_label(): ?string
156 {
157 $dropin = self::inspect()['dropin'];
158 return is_string($dropin['label']) ? $dropin['label'] : null;
159 }
160
161 /**
162 * The verdict: one state plus the reasons behind it.
163 *
164 * @return array{state:string,blockers:array<int,array>,notes:array<int,array>,revision:string}
165 */
166 public static function classify(): array
167 {
168 $report = self::inspect();
169 $dropin = $report['dropin'];
170 $wp_cache = $report['wp_cache'];
171 $blockers = array();
172 $notes = array();
173
174 if ($report['object_dropin']['exists']) {
175 // Informational only. A persistent object cache sits BESIDE a
176 // page cache; it competes for nothing and must never block.
177 $notes[] = array(
178 'code' => 'object_cache_present',
179 'plugin' => $report['object_dropin']['plugin'],
180 'label' => $report['object_dropin']['label'],
181 );
182 }
183
184 $active_page_caches = array();
185 foreach ($report['plugins'] as $plugin) {
186 if ($plugin['page_cache'] && $plugin['active']) {
187 $active_page_caches[] = $plugin;
188 }
189 }
190 foreach (self::residual_plugins($report['plugins']) as $plugin) {
191 $notes[] = array(
192 'code' => 'residual_cache_files',
193 'plugin' => $plugin['plugin'],
194 'label' => $plugin['label'],
195 );
196 }
197
198 if (self::OWNER_KNOWN === $dropin['owner']) {
199 $blockers[] = array(
200 'code' => self::BLOCKER_FOREIGN_DROPIN,
201 'plugin' => $dropin['plugin'],
202 'label' => $dropin['label'],
203 );
204 } elseif (self::OWNER_UNKNOWN === $dropin['owner']) {
205 $blockers[] = array(
206 'code' => $dropin['readable'] ? self::BLOCKER_UNKNOWN_DROPIN : self::BLOCKER_UNREADABLE_DROPIN,
207 'plugin' => null,
208 'label' => null,
209 );
210 }
211
212 if ('unreadable' === $wp_cache['state']) {
213 $blockers[] = array(
214 'code' => self::BLOCKER_WP_CONFIG_UNREADABLE,
215 'plugin' => null,
216 'label' => null,
217 );
218 } elseif ('duplicate' === $wp_cache['state']) {
219 $blockers[] = array(
220 'code' => self::BLOCKER_WP_CACHE_DUPLICATE,
221 'plugin' => null,
222 'label' => null,
223 );
224 } elseif ('dynamic' === $wp_cache['state']) {
225 $blockers[] = array(
226 'code' => self::BLOCKER_WP_CACHE_DYNAMIC,
227 'plugin' => null,
228 'label' => null,
229 );
230 }
231
232 // Most specific unsafe state wins.
233 if ('unreadable' === $wp_cache['state'] || ! $dropin['readable']) {
234 return self::verdict(self::STATE_UNAVAILABLE, $blockers, $notes, $report);
235 }
236
237 /*
238 * Keyed by plugin file so one plugin counts once. A live
239 * competitor is normally both active and the drop-in's owner;
240 * counting those separately put the ordinary single-competitor
241 * site in `contested` with a "more than one page cache" blocker.
242 */
243 $owners = array();
244 foreach ($active_page_caches as $active) {
245 $owners[(string) $active['plugin']] = true;
246 }
247 if (self::OWNER_KNOWN === $dropin['owner']) {
248 $owners[(string) ($dropin['plugin'] ?? $dropin['label'])] = true;
249 }
250 if (count($owners) > 1) {
251 $blockers[] = array(
252 'code' => self::BLOCKER_MULTIPLE_PAGE_CACHES,
253 'plugin' => null,
254 'label' => null,
255 );
256 return self::verdict(self::STATE_CONTESTED, $blockers, $notes, $report);
257 }
258
259 if (self::OWNER_UNKNOWN === $dropin['owner']) {
260 return self::verdict(self::STATE_UNKNOWN_OCCUPIED, $blockers, $notes, $report);
261 }
262 if (self::OWNER_KNOWN === $dropin['owner']) {
263 return self::verdict(self::STATE_FOREIGN_LIVE, $blockers, $notes, $report);
264 }
265
266 if (! empty($active_page_caches)) {
267 $blockers[] = array(
268 'code' => self::BLOCKER_ACTIVE_PAGE_CACHE,
269 'plugin' => $active_page_caches[0]['plugin'],
270 'label' => $active_page_caches[0]['label'],
271 );
272 return self::verdict(self::STATE_POSSIBLE_LIVE, $blockers, $notes, $report);
273 }
274
275 /*
276 * No drop-in, nothing active, but WP_CACHE is on. Something
277 * enabled page caching and we cannot say what. Review, not clear.
278 */
279 /*
280 * A WP_CACHE we cannot rewrite is not a clear field. `duplicate` and
281 * `dynamic` already add a blocker above, but the ladder used to fall
282 * past them to `unclaimed` — a state the README documents as "field
283 * clear: yes". is_field_clear() was safe (it checks blockers first),
284 * but any host branching on the STATE, as the README invites, read a
285 * doubly-defined or expression-valued config as a clean site.
286 */
287 if (in_array($wp_cache['state'], array('duplicate', 'dynamic'), true)) {
288 return self::verdict(self::STATE_UNKNOWN_OCCUPIED, $blockers, $notes, $report);
289 }
290
291 if ('true' === $wp_cache['state']) {
292 $blockers[] = array(
293 'code' => self::BLOCKER_WP_CACHE_ORPHANED,
294 'plugin' => null,
295 'label' => null,
296 );
297 return self::verdict(self::STATE_UNKNOWN_OCCUPIED, $blockers, $notes, $report);
298 }
299
300 foreach ($notes as $note) {
301 if ('residual_cache_files' === $note['code']) {
302 return self::verdict(self::STATE_FOREIGN_RESIDUAL, $blockers, $notes, $report);
303 }
304 }
305
306 return self::verdict(self::STATE_UNCLAIMED, $blockers, $notes, $report);
307 }
308
309 /**
310 * Inactive page-cache plugins whose artifacts are their OWN.
311 *
312 * Builds that share a signal set (Swift Performance Lite and the
313 * commercial build share their constants, options row and cache dir)
314 * would otherwise each be reported as having left cache files behind,
315 * including the one that was never on this site. A signal is credited
316 * to a plugin only when no plugin of stronger standing also carries
317 * it (active over installed-but-inactive over not on disk), and only a
318 * page-cache plugin can explain a page-cache artifact. Two absent
319 * twins cannot be told apart and are both reported.
320 *
321 * Mirrors the same helper in xSpeed's own detector.
322 *
323 * @param array<int,array> $plugins Rows from inspect_plugins().
324 * @return array<int,array>
325 */
326 private static function residual_plugins(array $plugins): array
327 {
328 $standing = static function (array $plugin): int {
329 if ($plugin['active']) {
330 return 2;
331 }
332 return ! empty($plugin['installed']) ? 1 : 0;
333 };
334
335 $out = array();
336 foreach ($plugins as $plugin) {
337 if (! $plugin['page_cache'] || $plugin['active'] || empty($plugin['signals'])) {
338 continue;
339 }
340
341 $explained = array();
342 foreach ($plugins as $other) {
343 if (! $other['page_cache'] || $other['plugin'] === $plugin['plugin'] || $standing($other) <= $standing($plugin)) {
344 continue;
345 }
346 $explained = array_merge($explained, (array) $other['signals']);
347 }
348
349 if (array_diff((array) $plugin['signals'], $explained)) {
350 $out[] = $plugin;
351 }
352 }
353
354 return $out;
355 }
356
357 /**
358 * The raw evidence, for a host that wants to render its own summary.
359 *
360 * @return array{scope:string,multisite:bool,plugins:array<int,array>,dropin:array,object_dropin:array,wp_cache:array,revision:string}
361 */
362 public static function inspect(bool $fresh = true): array
363 {
364 if (! $fresh && null !== self::$report) {
365 return self::$report;
366 }
367
368 $multisite = function_exists('is_multisite') && is_multisite();
369 $report = array(
370 'scope' => $multisite ? 'site-and-network' : 'site',
371 'multisite' => $multisite,
372 'plugins' => self::inspect_plugins(),
373 'dropin' => self::inspect_dropin(),
374 'object_dropin' => self::inspect_object_dropin(),
375 'wp_cache' => self::inspect_wp_cache(),
376 );
377
378 // Fingerprint of every fact the verdict rests on. A host that acts
379 // on a verdict can re-inspect immediately before acting and bail
380 // if this changed.
381 $report['revision'] = hash('sha256', (string) wp_json_encode($report));
382
383 self::$report = $report;
384 return $report;
385 }
386
387 /** Mandatory fresh evidence path for callers that may write afterwards. */
388 public static function inspect_fresh(): array
389 {
390 return self::inspect(true);
391 }
392
393 /** Drop the memoized report — call after activating or deactivating a plugin. */
394 public static function invalidate(): void
395 {
396 self::$report = null;
397 }
398
399 /**
400 * Every catalogued plugin, keyed by its main file path as it appears
401 * in `active_plugins`. Folder-only checks would false-positive on a
402 * plugin merely present on disk.
403 *
404 * Filterable so a host can describe a plugin this copy predates.
405 *
406 * @return array<string,array>
407 */
408 public static function catalog(): array
409 {
410 $catalog = array(
411 /*
412 * xSpeed itself. The question this file answers is "does this
413 * site already have a page cache, and whose is it?" — and xSpeed
414 * is one. Leaving it out did not change any host's decision, but
415 * a site running xSpeed reported an unattributable drop-in, so a
416 * host rendering the reason described the plugin it installed
417 * itself as an unidentified page cache.
418 */
419 'xspeed/xspeed.php' => array(
420 'label' => 'xSpeed Cache',
421 'capabilities' => array(self::CAP_PAGE_CACHE),
422 // Drop-in token only, deliberately: no constants, options or
423 // paths. Those are residual-evidence signals, and xSpeed's are
424 // present on any site running it — including, awkwardly, this
425 // file's own test suite — which would report a site as holding
426 // leftovers from the very plugin asking the question. Naming
427 // the drop-in owner is all that was missing; an ACTIVE xSpeed
428 // is already caught by is_plugin_active() on the key above.
429 'dropin' => array('XSPEED_DROPIN'),
430 ),
431 'wp-rocket/wp-rocket.php' => array(
432 'label' => 'WP Rocket',
433 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY),
434 'constants' => array('WP_ROCKET_VERSION'),
435 'paths' => array('cache/wp-rocket'),
436 'dropin' => array('WP Rocket', 'WP_ROCKET'),
437 ),
438 'litespeed-cache/litespeed-cache.php' => array(
439 'label' => 'LiteSpeed Cache',
440 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_OBJECT_CACHE, self::CAP_MINIFY),
441 'constants' => array('LSCWP_V', 'LSCWP_DIR'),
442 'options' => array('litespeed.conf.cache'),
443 'paths' => array('litespeed', 'cache/litespeed'),
444 'dropin' => array('LiteSpeed_Cache', 'LSCWP'),
445 ),
446 'w3-total-cache/w3-total-cache.php' => array(
447 'label' => 'W3 Total Cache',
448 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_OBJECT_CACHE, self::CAP_MINIFY),
449 'constants' => array('W3TC_DIR', 'W3TC_VERSION'),
450 'paths' => array('w3tc-config/master.php', 'cache/page_enhanced'),
451 'dropin' => array('W3TC', 'w3-total-cache'),
452 ),
453 'wp-super-cache/wp-cache.php' => array(
454 'label' => 'WP Super Cache',
455 'capabilities' => array(self::CAP_PAGE_CACHE),
456 'constants' => array('WPCACHEHOME'),
457 'paths' => array('wp-cache-config.php', 'cache/supercache'),
458 'dropin' => array('WP SUPER CACHE', 'wp-cache-phase1'),
459 ),
460 'wp-fastest-cache/wpFastestCache.php' => array(
461 'label' => 'WP Fastest Cache',
462 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY),
463 'classes' => array('WpFastestCache'),
464 'options' => array('WpFastestCache'),
465 'paths' => array('cache/all', 'cache/wpfc-minified'),
466 'dropin' => array('WpFastestCache', 'wpFastestCache'),
467 ),
468 'swift-performance-lite/performance.php' => array(
469 // Both builds share drop-in markers, constants and options
470 // row, and identify() returns the first match — so a
471 // commercial install was reported as "Lite". Neither entry
472 // claims an edition it cannot tell apart.
473 'label' => 'Swift Performance',
474 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY),
475 'constants' => array('SWIFT_PERFORMANCE_VER', 'SWIFT_PERFORMANCE_DIR'),
476 'options' => array('swift_performance_options'),
477 'paths' => array('cache/swift-performance'),
478 'dropin' => array('Swift Performance', 'swift_performance'),
479 ),
480 'swift-performance/performance.php' => array(
481 'label' => 'Swift Performance',
482 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY),
483 'constants' => array('SWIFT_PERFORMANCE_VER', 'SWIFT_PERFORMANCE_DIR'),
484 'options' => array('swift_performance_options'),
485 'paths' => array('cache/swift-performance'),
486 'dropin' => array('Swift Performance', 'swift_performance'),
487 ),
488 'cache-enabler/cache-enabler.php' => array(
489 'label' => 'Cache Enabler',
490 'capabilities' => array(self::CAP_PAGE_CACHE),
491 'constants' => array('CACHE_ENABLER_DIR', 'CACHE_ENABLER_VERSION'),
492 'classes' => array('Cache_Enabler_Engine'),
493 'paths' => array('cache/cache-enabler'),
494 'dropin' => array('Cache_Enabler', 'cache-enabler'),
495 ),
496 'comet-cache/comet-cache.php' => array(
497 'label' => 'Comet Cache',
498 'capabilities' => array(self::CAP_PAGE_CACHE),
499 'paths' => array('cache/comet-cache'),
500 'dropin' => array('comet-cache', 'ZenCache', 'Quick Cache'),
501 ),
502 'hummingbird-performance/wp-hummingbird.php' => array(
503 'label' => 'Hummingbird',
504 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY),
505 'constants' => array('WPHB_ADVANCED_CACHE', 'WPHB_VERSION'),
506 'paths' => array('wphb-cache'),
507 'dropin' => array('Hummingbird', 'WPHB'),
508 ),
509 'sg-cachepress/sg-cachepress.php' => array(
510 'label' => 'SG Optimizer',
511 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY),
512 'constants' => array('SiteGround_Optimizer\\VERSION'),
513 'dropin' => array('SG CachePress', 'SiteGround'),
514 ),
515 'breeze/breeze.php' => array(
516 'label' => 'Breeze',
517 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY),
518 'constants' => array('BREEZE_VERSION', 'BREEZE_CACHE_DIR'),
519 'paths' => array('cache/breeze-minification'),
520 'dropin' => array('Breeze', 'breeze-cache'),
521 ),
522 'autoptimize/autoptimize.php' => array(
523 // Minification only. Present so a host can say "we saw it
524 // and it is not a page cache" instead of guessing.
525 'label' => 'Autoptimize',
526 'capabilities' => array(self::CAP_MINIFY),
527 'constants' => array('AUTOPTIMIZE_PLUGIN_VERSION'),
528 'paths' => array('cache/autoptimize'),
529 ),
530 'flying-press/flying-press.php' => array(
531 'label' => 'FlyingPress',
532 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY),
533 'constants' => array('FLYING_PRESS_VERSION', 'FLYING_PRESS_CACHE_DIR'),
534 'paths' => array('cache/flying-press'),
535 'dropin' => array('FlyingPress', 'flying-press'),
536 ),
537 'nitropack/main.php' => array(
538 'label' => 'NitroPack',
539 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY),
540 'constants' => array('NITROPACK_VERSION'),
541 'dropin' => array('NitroPack', 'nitropack'),
542 ),
543 'wp-optimize/wp-optimize.php' => array(
544 'label' => 'WP-Optimize',
545 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY),
546 'constants' => array('WPO_VERSION', 'WPO_CACHE_DIR'),
547 'paths' => array('cache/wpo-cache'),
548 'dropin' => array('WP-Optimize', 'WPO_CACHE'),
549 ),
550 'jetpack-boost/jetpack-boost.php' => array(
551 'label' => 'Jetpack Boost',
552 'capabilities' => array(self::CAP_PAGE_CACHE, self::CAP_MINIFY),
553 'paths' => array('boost-cache'),
554 'dropin' => array('Jetpack Boost', 'jetpack-boost'),
555 ),
556 'redis-cache/redis-cache.php' => array(
557 // Object cache only — owns object-cache.php, never
558 // advanced-cache.php. Catalogued so the object-cache
559 // drop-in can be NAMED rather than reported as unknown.
560 'label' => 'Redis Object Cache',
561 'capabilities' => array(self::CAP_OBJECT_CACHE),
562 'constants' => array('WP_REDIS_VERSION'),
563 'object_dropin' => array('Redis Object Cache', 'WP_Redis'),
564 ),
565 'memcached/object-cache.php' => array(
566 'label' => 'Memcached Object Cache',
567 'capabilities' => array(self::CAP_OBJECT_CACHE),
568 'object_dropin' => array('Memcached', 'memcache'),
569 ),
570 );
571
572 if (function_exists('apply_filters')) {
573 $filtered = apply_filters('page_cache_safety_catalog', $catalog);
574 if (is_array($filtered)) {
575 $catalog = $filtered;
576 }
577 }
578
579 return $catalog;
580 }
581
582 /**
583 * One row per catalogued plugin that is active or has left evidence.
584 *
585 * @return array<int,array>
586 */
587 private static function inspect_plugins(): array
588 {
589 if (! function_exists('is_plugin_active') && defined('ABSPATH') && file_exists(ABSPATH . 'wp-admin/includes/plugin.php')) {
590 require_once ABSPATH . 'wp-admin/includes/plugin.php';
591 }
592
593 $plugin_dir = defined('WP_PLUGIN_DIR') ? WP_PLUGIN_DIR : (defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR . '/plugins' : '');
594
595 $out = array();
596 foreach (self::catalog() as $file => $entry) {
597 $signals = self::signals_for($entry);
598 $installed = '' !== $plugin_dir && is_file($plugin_dir . '/' . $file);
599 $network_active = function_exists('is_plugin_active_for_network') && is_plugin_active_for_network($file);
600 $active = function_exists('is_plugin_active') ? (bool) is_plugin_active($file) : false;
601 $site_active = function_exists('get_option') && in_array($file, (array) get_option('active_plugins', array()), true);
602 if ($active && ! $network_active) {
603 $site_active = true;
604 }
605 $active = $active || $site_active || $network_active;
606 if (! $active && empty($signals)) {
607 continue;
608 }
609
610 $capabilities = (array) ($entry['capabilities'] ?? array());
611 $out[] = array(
612 'plugin' => $file,
613 'label' => (string) ($entry['label'] ?? $file),
614 'active' => $active,
615 'installed' => $installed,
616 'site_active' => $site_active,
617 'network_active' => $network_active,
618 'activation_scope' => $site_active && $network_active ? 'site-and-network' : ($network_active ? 'network' : ($site_active ? 'site' : 'inactive')),
619 'capabilities' => $capabilities,
620 'page_cache' => in_array(self::CAP_PAGE_CACHE, $capabilities, true),
621 'signals' => $signals,
622 );
623 }
624 return $out;
625 }
626
627 /**
628 * Which of an entry's signals are present. Constants and classes are
629 * checked WITHOUT autoloading, options through get_option, paths by
630 * stat under wp-content. No foreign file is opened or executed.
631 *
632 * @return string[]
633 */
634 private static function signals_for(array $entry): array
635 {
636 $found = array();
637
638 foreach ((array) ($entry['constants'] ?? array()) as $constant) {
639 if (defined($constant)) {
640 $found[] = 'constant:' . $constant;
641 }
642 }
643 foreach ((array) ($entry['classes'] ?? array()) as $class) {
644 if (class_exists($class, false)) {
645 $found[] = 'class:' . $class;
646 }
647 }
648 foreach ((array) ($entry['options'] ?? array()) as $option) {
649 if (function_exists('get_option')) {
650 $value = get_option($option, null);
651 if (null !== $value && false !== $value) {
652 $found[] = 'option:' . $option;
653 }
654 }
655 }
656 foreach ((array) ($entry['paths'] ?? array()) as $path) {
657 if (defined('WP_CONTENT_DIR') && file_exists(WP_CONTENT_DIR . '/' . ltrim((string) $path, '/'))) {
658 $found[] = 'path:' . $path;
659 }
660 }
661
662 return $found;
663 }
664
665 /**
666 * advanced-cache.php: present, whose, and what it hashes to.
667 */
668 private static function inspect_dropin(): array
669 {
670 $target = defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR . '/advanced-cache.php' : '';
671 $state = array(
672 'path' => $target,
673 'exists' => false,
674 'owner' => self::OWNER_NONE,
675 'plugin' => null,
676 'label' => null,
677 'hash' => null,
678 'readable' => true,
679 );
680
681 if ('' === $target || ! file_exists($target)) {
682 return $state;
683 }
684
685 $state['exists'] = true;
686 $contents = self::read($target);
687 if (null === $contents) {
688 $state['readable'] = false;
689 $state['owner'] = self::OWNER_UNKNOWN;
690 return $state;
691 }
692
693 $state['hash'] = hash('sha256', $contents);
694 $owner = self::identify($contents, 'dropin');
695
696 if (null !== $owner) {
697 $catalog = self::catalog();
698 $state['owner'] = self::OWNER_KNOWN;
699 $state['plugin'] = $owner;
700 $state['label'] = (string) ($catalog[$owner]['label'] ?? $owner);
701 return $state;
702 }
703
704 $state['owner'] = self::OWNER_UNKNOWN;
705 return $state;
706 }
707
708 /**
709 * object-cache.php. Reported, never a blocker: a persistent object
710 * cache is a different layer from a page cache.
711 */
712 private static function inspect_object_dropin(): array
713 {
714 $target = defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR . '/object-cache.php' : '';
715 $state = array(
716 'path' => $target,
717 'exists' => false,
718 'readable' => true,
719 'plugin' => null,
720 'label' => null,
721 'hash' => null,
722 );
723
724 if ('' === $target || ! file_exists($target)) {
725 return $state;
726 }
727
728 $state['exists'] = true;
729 $contents = self::read($target);
730 if (null === $contents) {
731 $state['readable'] = false;
732 return $state;
733 }
734
735 $state['hash'] = hash('sha256', $contents);
736
737 // Its own token list first; several page-cache plugins ship both
738 // drop-ins and mark them with the same string, hence the fallback.
739 $owner = self::identify($contents, 'object_dropin');
740 if (null === $owner) {
741 $owner = self::identify($contents, 'dropin');
742 }
743 if (null !== $owner) {
744 $catalog = self::catalog();
745 $state['plugin'] = $owner;
746 $state['label'] = (string) ($catalog[$owner]['label'] ?? $owner);
747 }
748
749 return $state;
750 }
751
752 /**
753 * Match catalog candidates only in structured, anchored locations. The
754 * file is tokenized but never included or executed. Null means unknown.
755 */
756 private static function identify(string $contents, string $key): ?string
757 {
758 if ('' === $contents) {
759 return null;
760 }
761 $evidence = self::signature_evidence($contents);
762 $owners = array();
763 foreach (self::catalog() as $file => $entry) {
764 foreach ((array) ($entry[$key] ?? array()) as $token) {
765 if (self::has_anchored_signature((string) $token, $evidence)) {
766 $owners[$file] = true;
767 break;
768 }
769 }
770 }
771 if (1 === count($owners)) {
772 return (string) array_key_first($owners);
773 }
774 if (count($owners) > 1) {
775 $labels = array();
776 $catalog = self::catalog();
777 foreach (array_keys($owners) as $owner) {
778 $labels[] = (string) ($catalog[$owner]['label'] ?? $owner);
779 }
780 if (1 === count(array_unique($labels))) {
781 return (string) array_key_first($owners);
782 }
783 }
784 return null;
785 }
786
787 /** @return array{comment_lines:string[],identifiers:string[]} */
788 private static function signature_evidence(string $contents): array
789 {
790 $comments = array();
791 $identifiers = array();
792 foreach (token_get_all($contents) as $token) {
793 if (! is_array($token)) {
794 continue;
795 }
796 if (in_array($token[0], array(T_COMMENT, T_DOC_COMMENT), true)) {
797 foreach (preg_split('/\\R/', $token[1]) ?: array() as $line) {
798 $line = preg_replace('/^\\s*(?:\/\\*+|\\*|\/\/|#)\\s*/', '', $line);
799 $line = preg_replace('/\\s*\\*\\/\\s*$/', '', (string) $line);
800 if ('' !== trim((string) $line)) {
801 $comments[] = trim((string) $line);
802 }
803 }
804 continue;
805 }
806 if (T_STRING === $token[0] || (defined('T_NAME_QUALIFIED') && T_NAME_QUALIFIED === $token[0]) || (defined('T_NAME_FULLY_QUALIFIED') && T_NAME_FULLY_QUALIFIED === $token[0])) {
807 foreach (explode('\\', trim($token[1], '\\')) as $identifier) {
808 if ('' !== $identifier) {
809 $identifiers[] = strtolower($identifier);
810 }
811 }
812 }
813 }
814 return array(
815 'comment_lines' => $comments,
816 'identifiers' => array_values(array_unique($identifiers)),
817 );
818 }
819
820 private static function has_anchored_signature(string $candidate, array $evidence): bool
821 {
822 $candidate = trim($candidate);
823 if ('' === $candidate) {
824 return false;
825 }
826 if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $candidate) && in_array(strtolower($candidate), $evidence['identifiers'], true)) {
827 return true;
828 }
829 $pattern = '/^' . preg_quote($candidate, '/') . '(?:\\s+(?:v(?:ersion)?\\s*)?\\d[A-Za-z0-9._-]*)?\\s*$/i';
830 foreach ($evidence['comment_lines'] as $line) {
831 if (preg_match($pattern, $line)) {
832 return true;
833 }
834 }
835 return false;
836 }
837
838 /**
839 * WP_CACHE as WRITTEN in wp-config.php, plus the runtime value.
840 *
841 * The literal is what matters: a value behind an expression, or two
842 * competing defines, cannot be rewritten safely, and a host that acts
843 * on "WP_CACHE is false" without noticing it is `getenv(...)` will get
844 * it wrong.
845 *
846 * @return array{path:string,readable:bool,state:string,runtime:?bool,defines:int,hash:?string}
847 */
848 private static function inspect_wp_cache(): array
849 {
850 $runtime = defined('WP_CACHE') ? (bool) constant('WP_CACHE') : null;
851 $path = self::wp_config_path();
852
853 if ('' === $path) {
854 return array(
855 'path' => $path,
856 'readable' => false,
857 'state' => 'unreadable',
858 'runtime' => $runtime,
859 'defines' => 0,
860 'hash' => null,
861 );
862 }
863
864 $config = self::read($path);
865 if (null === $config) {
866 return array(
867 'path' => $path,
868 'readable' => false,
869 'state' => 'unreadable',
870 'runtime' => $runtime,
871 'defines' => 0,
872 'hash' => null,
873 );
874 }
875
876 $defines = self::wp_cache_defines($config);
877 $count = count($defines);
878
879 if (! $count) {
880 return array(
881 'path' => $path,
882 'readable' => true,
883 'state' => 'undefined',
884 'runtime' => $runtime,
885 'defines' => 0,
886 'hash' => hash('sha256', $config),
887 );
888 }
889 if ($count > 1) {
890 return array(
891 'path' => $path,
892 'readable' => true,
893 'state' => 'duplicate',
894 'runtime' => $runtime,
895 'defines' => $count,
896 'hash' => hash('sha256', $config),
897 );
898 }
899
900 /*
901 * Hosts and older tutorials write the value several ways — 1, '1',
902 * TRUE — and all are literals. Only something we cannot evaluate by
903 * reading it (a variable, a call, a ternary) is dynamic.
904 */
905 $literal = self::literal_value($defines[0]);
906 if (in_array($literal, array('true', '1'), true)) {
907 $state = 'true';
908 } elseif (in_array($literal, array('false', '0', '', 'null'), true)) {
909 $state = 'false';
910 } else {
911 $state = 'dynamic';
912 }
913
914 return array(
915 'path' => $path,
916 'readable' => true,
917 'state' => $state,
918 'runtime' => $runtime,
919 'defines' => 1,
920 'hash' => hash('sha256', $config),
921 );
922 }
923
924 /** wp-config.php, in the site root or one level up (WP supports both). */
925 private static function wp_config_path(): string
926 {
927 if (! defined('ABSPATH')) {
928 return '';
929 }
930 foreach (array(ABSPATH . 'wp-config.php', dirname(ABSPATH) . '/wp-config.php') as $path) {
931 if (file_exists($path)) {
932 return $path;
933 }
934 }
935 return '';
936 }
937
938 /** @return array<int,array<int,mixed>> */
939 private static function wp_cache_defines(string $config): array
940 {
941 $tokens = token_get_all($config);
942 $defines = array();
943 $count = count($tokens);
944 for ($i = 0; $i < $count; $i++) {
945 $token = $tokens[$i];
946 if (! self::is_global_define_call($tokens, $i)) {
947 continue;
948 }
949 $open = self::next_code_index($tokens, $i + 1);
950 if (null === $open || '(' !== $tokens[$open]) {
951 continue;
952 }
953 $name = self::next_code_index($tokens, $open + 1);
954 if (null === $name || ! is_array($tokens[$name]) || T_CONSTANT_ENCAPSED_STRING !== $tokens[$name][0] || 'WP_CACHE' !== self::unquote($tokens[$name][1])) {
955 continue;
956 }
957 $comma = self::next_code_index($tokens, $name + 1);
958 if (null === $comma || ',' !== $tokens[$comma]) {
959 continue;
960 }
961
962 $value = array();
963 $depth = 1;
964 for ($k = $comma + 1; $k < $count; $k++) {
965 $current = $tokens[$k];
966 if ('(' === $current) {
967 $depth++;
968 } elseif (')' === $current) {
969 $depth--;
970 if (0 === $depth) {
971 $defines[] = $value;
972 $i = $k;
973 break;
974 }
975 }
976 $value[] = $current;
977 }
978 }
979 return $defines;
980 }
981
982 private static function literal_value(array $tokens): string
983 {
984 $code = array_values(
985 array_filter(
986 $tokens,
987 static function ($token) {
988 return ! is_array($token) || ! in_array($token[0], array(T_WHITESPACE, T_COMMENT, T_DOC_COMMENT), true);
989 }
990 )
991 );
992 if (1 !== count($code)) {
993 return '__dynamic__';
994 }
995 $token = $code[0];
996 if (is_array($token) && T_CONSTANT_ENCAPSED_STRING === $token[0]) {
997 return strtolower(self::unquote($token[1]));
998 }
999 if (is_array($token) && in_array($token[0], array(T_STRING, T_LNUMBER), true)) {
1000 return strtolower($token[1]);
1001 }
1002 return '__dynamic__';
1003 }
1004
1005 private static function unquote(string $value): string
1006 {
1007 if (strlen($value) < 2) {
1008 return $value;
1009 }
1010 $body = substr($value, 1, -1);
1011 return "'" === $value[0] ? str_replace(array("\\\\", "\\'"), array("\\", "'"), $body) : stripcslashes($body);
1012 }
1013
1014 private static function next_code_index(array $tokens, int $start): ?int
1015 {
1016 for ($i = $start, $count = count($tokens); $i < $count; $i++) {
1017 if (! is_array($tokens[$i]) || ! in_array($tokens[$i][0], array(T_WHITESPACE, T_COMMENT, T_DOC_COMMENT), true)) {
1018 return $i;
1019 }
1020 }
1021 return null;
1022 }
1023
1024 /**
1025 * Is the token at $i a call to the GLOBAL define()?
1026 *
1027 * `\define( 'WP_CACHE', true )` is valid and some hardened configs write it.
1028 * PHP 8 tokenizes it as one T_NAME_FULLY_QUALIFIED; PHP 7 as T_NS_SEPARATOR
1029 * then T_STRING. A namespaced `Foo\define(...)` (T_NAME_QUALIFIED, or on
1030 * PHP 7 a T_STRING preceded by `Foo\`) is some other function, as is a
1031 * method call `$x->define(...)` / `X::define(...)`.
1032 */
1033 private static function is_global_define_call(array $tokens, int $i): bool
1034 {
1035 $token = $tokens[$i];
1036 if (! is_array($token)) {
1037 return false;
1038 }
1039 if (defined('T_NAME_FULLY_QUALIFIED') && T_NAME_FULLY_QUALIFIED === $token[0]) {
1040 return 0 === strcasecmp($token[1], '\\define');
1041 }
1042 if (T_STRING !== $token[0] || 0 !== strcasecmp($token[1], 'define')) {
1043 return false;
1044 }
1045 $previous = self::previous_code_index($tokens, $i);
1046 if (null === $previous || ! is_array($tokens[$previous])) {
1047 return true;
1048 }
1049 if (in_array($tokens[$previous][0], array(T_OBJECT_OPERATOR, T_DOUBLE_COLON), true) || (defined('T_NULLSAFE_OBJECT_OPERATOR') && T_NULLSAFE_OBJECT_OPERATOR === $tokens[$previous][0])) {
1050 return false;
1051 }
1052 if (T_NS_SEPARATOR === $tokens[$previous][0]) {
1053 $before = self::previous_code_index($tokens, $previous);
1054 // `Foo\define` on PHP 7 (T_STRING before the separator), or `namespace\define`
1055 // (T_NAMESPACE before it; PHP 8 gives T_NAME_RELATIVE and never gets here).
1056 return null === $before || ! is_array($tokens[$before]) || ! in_array($tokens[$before][0], array(T_STRING, T_NAMESPACE), true);
1057 }
1058 return true;
1059 }
1060
1061 /** Index of the nearest code token before $before, skipping whitespace and comments. */
1062 private static function previous_code_index(array $tokens, int $before): ?int
1063 {
1064 for ($i = $before - 1; $i >= 0; $i--) {
1065 if (! is_array($tokens[$i]) || ! in_array($tokens[$i][0], array(T_WHITESPACE, T_COMMENT, T_DOC_COMMENT), true)) {
1066 return $i;
1067 }
1068 }
1069 return null;
1070 }
1071
1072
1073 /**
1074 * Read a file for inspection. Null on any failure — callers treat null
1075 * as "unknown", never as "empty". An empty string would read as "no
1076 * marker found", which is exactly the wrong conclusion.
1077 */
1078 private static function read(string $path): ?string
1079 {
1080 if (! is_readable($path)) {
1081 return null;
1082 }
1083 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Read-only inspection of a local file; WP_Filesystem would need credentials we must not prompt for on a read path.
1084 $contents = @file_get_contents($path); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failed read is a valid answer here ("unknown"), not an error to surface.
1085 return is_string($contents) ? $contents : null;
1086 }
1087
1088 private static function verdict(string $state, array $blockers, array $notes, array $report): array
1089 {
1090 return array(
1091 'state' => $state,
1092 'blockers' => array_values($blockers),
1093 'notes' => array_values($notes),
1094 'revision' => (string) ($report['revision'] ?? ''),
1095 );
1096 }
1097 }
1098 }
1099