PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 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 All 35 releases
xspeed / includes / class-managed-edge-purge.php

class-managed-edge-purge.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.0, at includes/class-managed-edge-purge.php

279 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Managed_Edge_Purge — hand xSpeed's purges to the purge plugin xCloud
4 * installs on sites whose Cloudflare Enterprise it provides (the "xCloud
5 * purge plugin" in CONTEXT.md). Named for the role, an edge someone else
6 * manages, so Pro can ask it without naming the service.
7 *
8 * @package XSpeed
9 */
10
11 namespace XSpeed;
12
13 defined( 'ABSPATH' ) || exit;
14
15 /**
16 * xCloud installs a must-use plugin on every site it puts on its Cloudflare
17 * Enterprise edge. From 1.3.0 that plugin takes purges from other plugins:
18 *
19 * do_action( 'xcloud_cfe_purge_urls', $urls );
20 * do_action( 'xcloud_cfe_purge_everything' );
21 *
22 * It batches what it is given with its own purges and sends one request per
23 * page load, with its own credential. xSpeed never sees a token and makes no
24 * request of its own here. A queue raised after that request has gone is
25 * still sent.
26 *
27 * Without this, xSpeed cleared its own copy of a page and the edge kept
28 * serving the old one: xCloud's plugin purges only the edited post's own
29 * address, so a new post never reached the cached home page or archives, and
30 * xSpeed's "Purge all" never reached Cloudflare at all.
31 *
32 * What the plugin does NOT do yet, and what follows from it:
33 *
34 * - It reports no result. A purge it fails to send is not visible here, so
35 * the Purge_Runner row says the purge was handed over, not that it landed.
36 * - Over 50 URLs in one request it purges the whole domain instead, assets
37 * included. That is broader than asked but never stale, so the list is sent
38 * as is rather than trimmed to fit.
39 * - No prefix purges, so archive pagination goes as one URL per page.
40 * - No path-scoped purge, so a full purge of a shared-domain subsite
41 * (`example.com/shop`) purges the whole domain. Broader than its own pages,
42 * but the alternative is leaving them stale.
43 *
44 * Its own automatic purging (`xcloud_cfe_auto_purge_enabled`) is left on. It
45 * covers the edited post, which is already in our list, so nothing is sent
46 * twice, and it still covers any edit xSpeed does not purge for.
47 *
48 * Engine infrastructure, like Server_Caches: no settings, no routes, no UI.
49 * Called directly from Cache::dispatch_purge_event() rather than as a
50 * listener, for the same reason that one is: a throwing third-party listener
51 * on the public action must not be able to skip it. Free tier, because an
52 * edge left serving pages xSpeed has purged is a correctness bug, not a paid
53 * feature.
54 */
55 final class Managed_Edge_Purge {
56
57 /**
58 * First release that accepts purges from other plugins. Used only to
59 * word the message when the actions are missing: see status().
60 */
61 public const MIN_VERSION = '1.3.0';
62
63 /**
64 * Set once a whole-domain purge is queued, so later calls in the same
65 * page load add nothing. Never set in WP-CLI or cron: see latches().
66 */
67 private static $everything_queued = false;
68
69 /**
70 * Whether xCloud's purge plugin is on this site at all, at any version.
71 *
72 * The Purge_Runner row is registered on this rather than on status(), so
73 * a site with an old release is told to update instead of seeing nothing.
74 */
75 public static function installed(): bool {
76 return defined( 'XCLOUD_CFE_PURGE_VERSION' );
77 }
78
79 /**
80 * Whether purges can be handed over, or why not.
81 *
82 * @return true|string
83 */
84 public static function status() {
85 if ( ! self::installed() ) {
86 return __( 'xCloud\'s purge plugin is not installed', 'xspeed' );
87 }
88 // The listeners decide, not the version. xCloud's template defines
89 // XCLOUD_CFE_PURGE_VERSION as 1.0.0 when its deploy passes no
90 // version, so a copy that takes purges can still report an old
91 // number. A copy that failed to boot defines the constant and
92 // nothing else, so both actions have to be there.
93 if ( function_exists( 'has_action' ) && has_action( 'xcloud_cfe_purge_urls' ) && has_action( 'xcloud_cfe_purge_everything' ) ) {
94 return true;
95 }
96 // The version only picks the message.
97 $version = (string) constant( 'XCLOUD_CFE_PURGE_VERSION' );
98 if ( ! version_compare( $version, self::MIN_VERSION, '>=' ) ) {
99 return sprintf(
100 /* translators: 1: installed version, 2: minimum version. */
101 __( 'xCloud\'s purge plugin %1$s cannot take purges from xSpeed. Update it to %2$s or later from xCloud.', 'xspeed' ),
102 $version,
103 self::MIN_VERSION
104 );
105 }
106 return __( 'xCloud\'s purge plugin is installed but not accepting purges', 'xspeed' );
107 }
108
109 /**
110 * Mirror one purge-event context at the edge.
111 *
112 * @param mixed $context Purge-event context. See the
113 * `xspeed_after_purge` docblock in Cache.
114 */
115 public static function forward( $context ): void {
116 if ( ! is_array( $context ) || true !== self::status() ) {
117 return;
118 }
119 // A purge run that includes the `xcloud` target (`wp xspeed purge`,
120 // Purge All) hands the whole domain over itself and reports it on its
121 // own line. Sending the page-cache event's purge as well made one
122 // Purge All two whole-domain purges in WP-CLI and cron, where nothing
123 // latches. Scoped to the run, not the request, so a later purge in
124 // the same long run is still handed over.
125 if ( Purge_Runner::covers( 'xcloud' ) ) {
126 return;
127 }
128 $scope = isset( $context['scope'] ) && is_string( $context['scope'] ) ? $context['scope'] : '';
129 if ( 'none' === $scope ) {
130 return;
131 }
132
133 if ( 'urls' === $scope ) {
134 $urls = array();
135 foreach ( (array) ( $context['urls'] ?? array() ) as $url ) {
136 // The endpoint refuses a URL on a host the domain does not
137 // serve, and the refusal costs the whole request, xCloud's own
138 // purges included. So only this domain's URLs go in.
139 if ( is_string( $url ) && self::on_this_domain( $url ) ) {
140 $urls[] = $url;
141 }
142 }
143 self::queue_urls( $urls );
144 return;
145 }
146
147 if ( 'network' === $scope ) {
148 self::queue_everything();
149 return;
150 }
151
152 // `site`, and anything unknown: a purge we cannot read is likelier to
153 // need the edge than not. Only for this domain, though. A full purge
154 // of another multisite site on a host of its own is not ours to send.
155 $host = isset( $context['host'] ) && is_string( $context['host'] ) ? $context['host'] : '';
156 if ( '' === $host || '*' === $host || self::host_is_this_domain( $host ) ) {
157 self::queue_everything();
158 }
159 }
160
161 /**
162 * Purge the whole domain at the edge. The Purge_Runner `xcloud` target.
163 *
164 * @param string $cause Who asked. Unused: xCloud's request carries no cause.
165 * @return array{ok?:bool,reason:string}
166 */
167 public static function purge_everything( string $cause = 'manual' ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Purge_Runner callback signature.
168 $status = self::status();
169 if ( true !== $status ) {
170 return array(
171 'ok' => false,
172 'reason' => (string) $status,
173 );
174 }
175 self::queue_everything();
176 return array(
177 'reason' => __( 'handed to xCloud\'s purge plugin, which sends it when this request ends', 'xspeed' ),
178 );
179 }
180
181 /**
182 * Queue URLs with xCloud's plugin.
183 *
184 * @param array<int,string> $urls Absolute URLs on this domain.
185 */
186 private static function queue_urls( array $urls ): void {
187 $urls = array_values( array_unique( $urls ) );
188 if ( array() === $urls || self::$everything_queued ) {
189 return;
190 }
191 do_action( 'xcloud_cfe_purge_urls', $urls ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- xCloud's own action; xSpeed calls it, it does not define it.
192 }
193
194 /** Queue a whole-domain purge, once per page load. */
195 private static function queue_everything(): void {
196 if ( self::$everything_queued ) {
197 return;
198 }
199 self::$everything_queued = self::latches();
200 do_action( 'xcloud_cfe_purge_everything' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- xCloud's own action; xSpeed calls it, it does not define it.
201 }
202
203 /**
204 * Whether a queued whole-domain purge may stand for the rest of this
205 * process.
206 *
207 * In a page load, yes: the plugin sends once, at the end, so everything
208 * after the first whole-domain purge is covered by it. WP-CLI and cron
209 * can run for minutes and purge many times. When the plugin sends is
210 * not visible from here, and a purge it has already sent cannot cover a
211 * change made after it, so there every purge is handed over. The plugin
212 * batches what it is given, so a repeat costs nothing if it has not sent
213 * yet.
214 */
215 private static function latches(): bool {
216 if ( defined( 'WP_CLI' ) && WP_CLI ) {
217 return false;
218 }
219 return ! ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() );
220 }
221
222 /**
223 * Whether a URL is on the domain xCloud's plugin purges for.
224 *
225 * @param string $url Absolute URL.
226 */
227 private static function on_this_domain( string $url ): bool {
228 $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- fallback for a bare test harness.
229 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
230 return false;
231 }
232 $scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
233 if ( 'http' !== $scheme && 'https' !== $scheme ) {
234 return false;
235 }
236 // A non-default port is another origin, and not one the edge serves.
237 if ( isset( $parts['port'] ) ) {
238 $port = (int) $parts['port'];
239 if ( ! ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) ) {
240 return false;
241 }
242 }
243 return self::host_is_this_domain( (string) $parts['host'] );
244 }
245
246 /**
247 * Whether a host is this site's, allowing the apex and `www` forms of it.
248 *
249 * xCloud registers an apex with its `www` form, so a URL on either is one
250 * the edge serves. Any other host, including another multisite site's own
251 * domain, is not.
252 *
253 * @param string $host Host, optionally with a port.
254 */
255 private static function host_is_this_domain( string $host ): bool {
256 if ( ! function_exists( 'home_url' ) ) {
257 return false;
258 }
259 $home = function_exists( 'wp_parse_url' ) ? wp_parse_url( (string) home_url( '/' ), PHP_URL_HOST ) : parse_url( (string) home_url( '/' ), PHP_URL_HOST ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- see above.
260 $ours = self::without_www( strtolower( (string) $home ) );
261 $given = self::without_www( strtolower( (string) preg_replace( '/:\d+$/', '', $host ) ) );
262 return '' !== $ours && $ours === $given;
263 }
264
265 /**
266 * Strip one leading `www.`.
267 *
268 * @param string $host Lowercase host.
269 */
270 private static function without_www( string $host ): string {
271 return 0 === strpos( $host, 'www.' ) ? substr( $host, 4 ) : $host;
272 }
273
274 /** Test seam: forget the per-request state. */
275 public static function reset(): void {
276 self::$everything_queued = false;
277 }
278 }
279