| 1 |
<?php |
| 2 |
/** |
| 3 |
* Host_Page_Caches — forward a purge to a full-page cache that lives in the |
| 4 |
* WEB SERVER rather than in WordPress. |
| 5 |
* |
| 6 |
* The full rationale is on the class below. Kept short here on purpose: |
| 7 |
* Plugin Check reads only the first 50 lines of a file when looking for the |
| 8 |
* direct-access guard, so a long header docblock pushes the guard out of its |
| 9 |
* window and the file reports `missing_direct_file_access_protection` while |
| 10 |
* being perfectly well guarded. |
| 11 |
* |
| 12 |
* @package XSpeed |
| 13 |
*/ |
| 14 |
|
| 15 |
declare(strict_types=1); |
| 16 |
|
| 17 |
namespace XSpeed; |
| 18 |
|
| 19 |
defined( 'ABSPATH' ) || exit; |
| 20 |
|
| 21 |
/** |
| 22 |
* Forward a purge to a full-page cache that lives in the WEB SERVER rather |
| 23 |
* than in WordPress. |
| 24 |
* |
| 25 |
* Why this exists: `Cache::purge_all()` sweeps the trees xSpeed owns, under |
| 26 |
* `wp-content/cache/`. On a host that runs its own nginx FastCGI full-page |
| 27 |
* cache in front of PHP, that sweep reaches none of it — nginx keeps |
| 28 |
* answering from `/etc/nginx/cache/<site>` until its own `fastcgi_cache_valid` |
| 29 |
* window expires (an hour on a stock xCloud site). The observable bug is the |
| 30 |
* one every "purge did nothing" report describes: the admin edits a page, |
| 31 |
* clicks Purge All, xSpeed reports the files cleared, and the anonymous |
| 32 |
* visitor still gets yesterday's HTML. |
| 33 |
* |
| 34 |
* We do not talk to nginx ourselves, and we do not touch its cache directory. |
| 35 |
* The purge goes through **Nginx Helper** (rtCamp), which is the plugin the |
| 36 |
* host installs and configures alongside that cache — xCloud, for one, runs |
| 37 |
* `wp plugin install nginx-helper --activate`, writes its options and sets |
| 38 |
* `RT_WP_NGINX_HELPER_CACHE_PATH` when a site owner turns full-page caching |
| 39 |
* on. Nginx Helper owns the cache path, the key derivation and the options; |
| 40 |
* all of that is read-only to us, always. |
| 41 |
* |
| 42 |
* `rt_nginx_helper_purge_all` is an action Nginx Helper exposes for exactly |
| 43 |
* this — its own source comments it "expose action to allow other plugins to |
| 44 |
* purge the cache" (includes/class-nginx-helper.php) — so this is a supported |
| 45 |
* seam, not a reach into another plugin's internals. |
| 46 |
* |
| 47 |
* Detection is by constant and global only, never `is_plugin_active()` on a |
| 48 |
* path string: a renamed plugin folder must not silently turn the integration |
| 49 |
* off. Same rule as Render_Caches. |
| 50 |
* |
| 51 |
* Deliberately purge-ALL only. Nginx Helper's per-URL entry point is a method |
| 52 |
* call on its purger object rather than an action, and `Cache::purge_url()` |
| 53 |
* has no action to hook yet; forwarding single URLs is a separate change once |
| 54 |
* that seam lands. |
| 55 |
* |
| 56 |
* **Converges with `Server_Caches` later.** PR #348 introduces that class for |
| 57 |
* the same idea — forwarding a purge to a cache in front of PHP — with |
| 58 |
* LiteSpeed as its first adapter and `xspeed_purge_server_caches` as its |
| 59 |
* public seam. Nothing this class does overlaps with it today (different |
| 60 |
* server cache, different plugin, and the purge sets are disjoint), so the two |
| 61 |
* can land independently. Once #348 is merged, the right shape for this is an |
| 62 |
* adapter registered on that filter rather than its own listener; keeping it |
| 63 |
* separate now is what avoids editing a branch that is out for re-test. |
| 64 |
* |
| 65 |
* **Multisite:** nginx keys one cache zone per *install*, not per subsite, so |
| 66 |
* a purge here clears every site on the network at the nginx layer. That is |
| 67 |
* accepted rather than worked around — a cold cache costs one slow request |
| 68 |
* per page, stale HTML costs a wrong page for the whole TTL. |
| 69 |
*/ |
| 70 |
final class Host_Page_Caches { |
| 71 |
|
| 72 |
/** |
| 73 |
* The site option Nginx Helper stores its configuration in. Network-wide |
| 74 |
* on multisite, which is why it is read with `get_site_option()`. |
| 75 |
*/ |
| 76 |
private const NH_OPTION = 'rt_wp_nginx_helper_options'; |
| 77 |
|
| 78 |
/** |
| 79 |
* The `cache_method` value that means "nginx FastCGI full-page cache". |
| 80 |
* The other one Nginx Helper supports is `enable_redis`, which is a |
| 81 |
* page cache in Redis fronted by nginx's `srcache` module — a different |
| 82 |
* layer, not present on the hosts this integration targets, and not |
| 83 |
* something a purge from here should reach for. |
| 84 |
*/ |
| 85 |
private const NH_FASTCGI = 'enable_fastcgi'; |
| 86 |
|
| 87 |
/** |
| 88 |
* Re-entrancy latch. See purge_nginx_helper(). |
| 89 |
* |
| 90 |
* @var bool |
| 91 |
*/ |
| 92 |
private static bool $purging = false; |
| 93 |
|
| 94 |
/** |
| 95 |
* Register the listener. |
| 96 |
* |
| 97 |
* Registration is unconditional and the gate lives in the callback: this |
| 98 |
* runs from `Plugin::init()` on `plugins_loaded`, and Nginx Helper builds |
| 99 |
* `$GLOBALS['nginx_purger']` from its own `plugins_loaded` callback, so |
| 100 |
* load order decides whether a check made here would see it. The callback |
| 101 |
* runs during a purge, long after both plugins are up, where the answer |
| 102 |
* is stable. |
| 103 |
*/ |
| 104 |
public static function boot(): void { |
| 105 |
add_action( 'xspeed_after_purge_all', array( __CLASS__, 'purge_nginx_helper' ), 10, 1 ); |
| 106 |
} |
| 107 |
|
| 108 |
/** |
| 109 |
* Forward a full purge to the server-level cache, when there is one. |
| 110 |
* |
| 111 |
* @param string $cause Who asked. Threaded through for symmetry with the |
| 112 |
* other `xspeed_after_purge_all` listeners; Nginx |
| 113 |
* Helper's action takes no arguments. |
| 114 |
* @return bool Whether the purge was forwarded. |
| 115 |
*/ |
| 116 |
public static function purge_nginx_helper( $cause = 'manual' ): bool { |
| 117 |
unset( $cause ); |
| 118 |
|
| 119 |
/* |
| 120 |
* Nginx Helper's purge is a directory sweep, but it is not OUR code: |
| 121 |
* it runs third-party listeners on `rt_nginx_helper_purge_all`, and a |
| 122 |
* site can easily have one that calls back into a WordPress purge — |
| 123 |
* a "keep every cache in sync" mu-plugin is the common shape. Without |
| 124 |
* this latch that lands back in Cache::purge_all(), which fires |
| 125 |
* `xspeed_after_purge_all` again, and the two purges recurse until PHP |
| 126 |
* runs out of stack. One forward per request is all this integration |
| 127 |
* can usefully do anyway, since the second sweep would find an empty |
| 128 |
* directory. |
| 129 |
*/ |
| 130 |
if ( self::$purging ) { |
| 131 |
return false; |
| 132 |
} |
| 133 |
|
| 134 |
if ( ! self::nginx_helper_is_fastcgi() ) { |
| 135 |
return false; |
| 136 |
} |
| 137 |
|
| 138 |
self::$purging = true; |
| 139 |
try { |
| 140 |
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Nginx Helper's own public integration hook; an xspeed_-prefixed name would reach nothing. |
| 141 |
do_action( 'rt_nginx_helper_purge_all' ); |
| 142 |
} catch ( \Throwable $e ) { |
| 143 |
/* |
| 144 |
* The same third-party listeners the latch above exists for can |
| 145 |
* also throw, and everything on this action is code we do not |
| 146 |
* own. Letting it out would take down `Cache::purge_all()` — the |
| 147 |
* admin presses Purge All, another plugin's mu-plugin fatals, and |
| 148 |
* the failure reads as xSpeed's. The local sweep has already |
| 149 |
* happened by the time we run, so swallowing this costs the |
| 150 |
* server layer and nothing else. `Server_Caches::forward()` on |
| 151 |
* #348 catches at the same boundary for the same reason. |
| 152 |
*/ |
| 153 |
return false; |
| 154 |
} finally { |
| 155 |
self::$purging = false; |
| 156 |
} |
| 157 |
|
| 158 |
return true; |
| 159 |
} |
| 160 |
|
| 161 |
/** |
| 162 |
* Whether Nginx Helper is present AND configured against an nginx FastCGI |
| 163 |
* cache. |
| 164 |
* |
| 165 |
* Three separate facts, because each one alone is a false positive: |
| 166 |
* |
| 167 |
* 1. `RT_WP_NGINX_HELPER_CACHE_PATH` — there is a directory to purge, and |
| 168 |
* we know which one, because Health names it. |
| 169 |
* |
| 170 |
* Do NOT read this as evidence the host configured anything. Nginx |
| 171 |
* Helper defines the constant itself whenever it is not already set, |
| 172 |
* defaulting to `/var/run/nginx-cache` through its own |
| 173 |
* `rt_wp_nginx_helper_cache_path` filter (its |
| 174 |
* `includes/class-nginx-helper.php`, in the constructor). So it is |
| 175 |
* defined on every install and cannot be missing while the plugin is |
| 176 |
* loaded — the only way past this check is a WordPress older than the |
| 177 |
* plugin's minimum, where it returns before the `define`. |
| 178 |
* |
| 179 |
* On a site where the host never set a path, that default is what |
| 180 |
* Nginx Helper would unlink, so it is also what we forward a purge |
| 181 |
* against and what Health names. That directory usually does not |
| 182 |
* exist, making its `purge_all()` a no-op we would report as a purge. |
| 183 |
* Left alone deliberately: it is exactly what the admin gets from |
| 184 |
* Nginx Helper's own Purge All button, and second-guessing another |
| 185 |
* plugin's configured path is not ours to do. |
| 186 |
* 2. `$GLOBALS['nginx_purger']` — the plugin finished booting and built a |
| 187 |
* purger. The constant can be defined in `wp-config.php` by a host |
| 188 |
* whose site owner then deactivated the plugin, in which case the |
| 189 |
* action has no listener and firing it is a silent no-op we would |
| 190 |
* still report as a purge. |
| 191 |
* 3. `cache_method === 'enable_fastcgi'` — it is the page cache we mean. |
| 192 |
* On `enable_redis` the same action clears a Redis key space that |
| 193 |
* xSpeed's own object-cache flush may already own. |
| 194 |
* |
| 195 |
* Note what is deliberately NOT in the gate: `enable_purge`. That option |
| 196 |
* governs Nginx Helper's own AUTOMATIC purging — its post-save, comment |
| 197 |
* and term hooks each check it — and does not reach `purge_all()`, which |
| 198 |
* runs whatever it is set to. An admin who switched automatic purging off |
| 199 |
* has said "don't purge behind my back"; they have not said "ignore me |
| 200 |
* when I press Purge All". Reading it as the latter would leave an |
| 201 |
* explicit, operator-initiated purge silently short of the layer actually |
| 202 |
* serving the page, which is the exact failure this integration exists to |
| 203 |
* fix. |
| 204 |
*/ |
| 205 |
public static function nginx_helper_is_fastcgi(): bool { |
| 206 |
return null !== self::nginx_helper_cache_path(); |
| 207 |
} |
| 208 |
|
| 209 |
/** |
| 210 |
* The cache directory Nginx Helper is pointed at, or null when the |
| 211 |
* integration does not apply. Read-only — we never define the constant |
| 212 |
* and never write the option. |
| 213 |
* |
| 214 |
* Health reports the path; the gate only cares whether there is one. |
| 215 |
*/ |
| 216 |
public static function nginx_helper_cache_path(): ?string { |
| 217 |
if ( ! defined( 'RT_WP_NGINX_HELPER_CACHE_PATH' ) ) { |
| 218 |
return null; |
| 219 |
} |
| 220 |
$path = (string) constant( 'RT_WP_NGINX_HELPER_CACHE_PATH' ); |
| 221 |
if ( '' === $path ) { |
| 222 |
return null; |
| 223 |
} |
| 224 |
if ( ! isset( $GLOBALS['nginx_purger'] ) || ! is_object( $GLOBALS['nginx_purger'] ) ) { |
| 225 |
return null; |
| 226 |
} |
| 227 |
$options = get_site_option( self::NH_OPTION ); |
| 228 |
if ( ! is_array( $options ) || self::NH_FASTCGI !== ( $options['cache_method'] ?? '' ) ) { |
| 229 |
return null; |
| 230 |
} |
| 231 |
return $path; |
| 232 |
} |
| 233 |
|
| 234 |
/** |
| 235 |
* The configured purge method (`unlink_files`, `get_request`, …), or '' |
| 236 |
* when unset. Health uses it to decide whether the permissions caveat |
| 237 |
* applies; nothing gates on it. |
| 238 |
*/ |
| 239 |
public static function nginx_helper_purge_method(): string { |
| 240 |
$options = get_site_option( self::NH_OPTION ); |
| 241 |
return is_array( $options ) ? (string) ( $options['purge_method'] ?? '' ) : ''; |
| 242 |
} |
| 243 |
} |
| 244 |
|