| 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 |
|