PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
1.3.6 1.3.5 1.3.4 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 All 32 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.6, at includes/class-host-page-caches.php

233 lines 10.5 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 * **This class is the mechanism, not the policy.** It knows how to detect the
52 * server cache and how to ask Nginx Helper to clear it. WHEN to ask is decided
53 * one level up, by `Server_Caches::forward()`, from the `intent` and `scope`
54 * on the purge-event contract — which is also where the reasoning for standing
55 * down on a content purge is written. Nothing here listens to a hook.
56 *
57 * Purge-ALL only. Nginx Helper's per-URL entry point is
58 * `$GLOBALS['nginx_purger']->purge_url()`, a method on its purger object
59 * rather than an action, and it calls `is_page()`/`is_single()` internally —
60 * which emits `_doing_it_wrong` outside a main query, so WP-CLI, cron and REST
61 * purges would warn. The contract already carries the exact `urls`, so
62 * per-URL forwarding is a follow-up rather than a redesign.
63 *
64 * **Multisite:** nginx keys one cache zone per *install*, not per subsite, so
65 * a purge here clears every site on the network at the nginx layer. That is
66 * accepted rather than worked around — a cold cache costs one slow request
67 * per page, stale HTML costs a wrong page for the whole TTL.
68 */
69 final class Host_Page_Caches {
70
71 /**
72 * The site option Nginx Helper stores its configuration in. Network-wide
73 * on multisite, which is why it is read with `get_site_option()`.
74 */
75 private const NH_OPTION = 'rt_wp_nginx_helper_options';
76
77 /**
78 * The `cache_method` value that means "nginx FastCGI full-page cache".
79 * The other one Nginx Helper supports is `enable_redis`, which is a
80 * page cache in Redis fronted by nginx's `srcache` module — a different
81 * layer, not present on the hosts this integration targets, and not
82 * something a purge from here should reach for.
83 */
84 private const NH_FASTCGI = 'enable_fastcgi';
85
86 /**
87 * Forward a full purge to the server-level cache, when there is one.
88 *
89 * No gate on the purge's reason here — `Server_Caches::forward()` has
90 * already decided this purge should reach the server layer. Detection is
91 * still checked, because it is a fact about the environment rather than
92 * about the purge, and it is only stable at purge time: Nginx Helper
93 * builds `$GLOBALS['nginx_purger']` from its own `plugins_loaded`
94 * callback, so anything asked earlier races its load order.
95 *
96 * Re-entrancy is handled upstream. A third-party listener on
97 * `rt_nginx_helper_purge_all` that calls back into `Cache::purge_all()`
98 * used to recurse here until PHP ran out of stack, which is what the
99 * latch this method once carried was for. The inner purge now re-enters
100 * `Cache::dispatch_purge_event()`, whose `$purge_events_in_flight` guard
101 * is still held by the outer one and returns before any adapter runs.
102 * Pinned by a test that recurses through `Cache::purge_all()` itself
103 * rather than through this method.
104 *
105 * @return bool Whether the purge was forwarded.
106 */
107 public static function purge_nginx_helper(): bool {
108 if ( ! self::nginx_helper_is_fastcgi() ) {
109 return false;
110 }
111
112 try {
113 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Nginx Helper's own public integration hook; an xspeed_-prefixed name would reach nothing.
114 do_action( 'rt_nginx_helper_purge_all' );
115 } catch ( \Throwable $e ) {
116 /*
117 * The same third-party listeners the latch above exists for can
118 * also throw, and everything on this action is code we do not
119 * own. Letting it out would take down `Cache::purge_all()` — the
120 * admin presses Purge All, another plugin's mu-plugin fatals, and
121 * the failure reads as xSpeed's. The local sweep has already
122 * happened by the time we run, so swallowing this costs the
123 * server layer and nothing else. `Server_Caches::forward()`
124 * catches around this call for the same reason and logs it under
125 * WP_DEBUG; this inner catch is what turns a throw into an honest
126 * "not forwarded" return rather than a reported purge.
127 */
128 return false;
129 }
130
131 return true;
132 }
133
134 /**
135 * Whether Nginx Helper is present AND configured against an nginx FastCGI
136 * cache.
137 *
138 * Three separate facts, because each one alone is a false positive:
139 *
140 * 1. `RT_WP_NGINX_HELPER_CACHE_PATH` — there is a directory to purge, and
141 * we know which one, because Health names it.
142 *
143 * Do NOT read this as evidence the host configured anything. Nginx
144 * Helper defines the constant itself whenever it is not already set,
145 * defaulting to `/var/run/nginx-cache` through its own
146 * `rt_wp_nginx_helper_cache_path` filter (its
147 * `includes/class-nginx-helper.php`, in the constructor). So it is
148 * defined on every install and cannot be missing while the plugin is
149 * loaded — the only way past this check is a WordPress older than the
150 * plugin's minimum, where it returns before the `define`.
151 *
152 * On a site where the host never set a path, that default is what
153 * Nginx Helper would unlink, so it is also what we forward a purge
154 * against and what Health names. That directory usually does not
155 * exist, making its `purge_all()` a no-op we would report as a purge.
156 * Left alone deliberately: it is exactly what the admin gets from
157 * Nginx Helper's own Purge All button, and second-guessing another
158 * plugin's configured path is not ours to do.
159 * 2. `$GLOBALS['nginx_purger']` — the plugin finished booting and built a
160 * purger. The constant can be defined in `wp-config.php` by a host
161 * whose site owner then deactivated the plugin, in which case the
162 * action has no listener and firing it is a silent no-op we would
163 * still report as a purge.
164 * 3. `cache_method === 'enable_fastcgi'` — it is the page cache we mean.
165 * On `enable_redis` the same action clears a Redis key space that
166 * xSpeed's own object-cache flush may already own.
167 *
168 * Note what is deliberately NOT in the gate: `enable_purge`. That option
169 * governs Nginx Helper's own AUTOMATIC purging — its post-save, comment
170 * and term hooks each check it — and does not reach `purge_all()`, which
171 * runs whatever it is set to. An admin who switched automatic purging off
172 * has said "don't purge behind my back"; they have not said "ignore me
173 * when I press Purge All". Reading it as the latter would leave an
174 * explicit, operator-initiated purge silently short of the layer actually
175 * serving the page, which is the exact failure this integration exists to
176 * fix. `nginx_helper_purges_changes()` does read it, only to decide
177 * whether a content purge can be left to Nginx Helper.
178 */
179 public static function nginx_helper_is_fastcgi(): bool {
180 return null !== self::nginx_helper_cache_path();
181 }
182
183 /**
184 * The cache directory Nginx Helper is pointed at, or null when the
185 * integration does not apply. Read-only — we never define the constant
186 * and never write the option.
187 *
188 * Health reports the path; the gate only cares whether there is one.
189 */
190 public static function nginx_helper_cache_path(): ?string {
191 if ( ! defined( 'RT_WP_NGINX_HELPER_CACHE_PATH' ) ) {
192 return null;
193 }
194 $path = (string) constant( 'RT_WP_NGINX_HELPER_CACHE_PATH' );
195 if ( '' === $path ) {
196 return null;
197 }
198 if ( ! isset( $GLOBALS['nginx_purger'] ) || ! is_object( $GLOBALS['nginx_purger'] ) ) {
199 return null;
200 }
201 $options = get_site_option( self::NH_OPTION );
202 if ( ! is_array( $options ) || self::NH_FASTCGI !== ( $options['cache_method'] ?? '' ) ) {
203 return null;
204 }
205 return $path;
206 }
207
208 /**
209 * Whether Nginx Helper purges changed pages by itself.
210 *
211 * Its `enable_purge` option is the one switch in front of all of its
212 * automatic purging: the post, comment and term hooks each return early
213 * without it. It defaults to off, so an install where the host never
214 * turned it on purges nothing when a post is published. Not part of the
215 * detection gate above; `Server_Caches` asks it only to decide whether a
216 * content purge can be left to Nginx Helper.
217 */
218 public static function nginx_helper_purges_changes(): bool {
219 $options = get_site_option( self::NH_OPTION );
220 return is_array( $options ) && ! empty( $options['enable_purge'] );
221 }
222
223 /**
224 * The configured purge method (`unlink_files`, `get_request`, …), or ''
225 * when unset. Health uses it to decide whether the permissions caveat
226 * applies; nothing gates on it.
227 */
228 public static function nginx_helper_purge_method(): string {
229 $options = get_site_option( self::NH_OPTION );
230 return is_array( $options ) ? (string) ( $options['purge_method'] ?? '' ) : '';
231 }
232 }
233