PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-host-page-caches.php

class-host-page-caches.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.3, at includes/class-host-page-caches.php

244 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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