| 1 |
<?php |
| 2 |
/** |
| 3 |
* Server_Caches — forward xSpeed's purges to a cache in front of PHP. |
| 4 |
* |
| 5 |
* xSpeed owns one cache. A LiteSpeed stack has two: ours, and LSCache holding |
| 6 |
* its own copy of the same URL at the server. Purging ours and stopping there |
| 7 |
* left the server still serving the page we had just invalidated — measured on |
| 8 |
* OpenLiteSpeed before this existed. |
| 9 |
* |
| 10 |
* This is the counterpart to Render_Caches. That one clears caches of RENDERED |
| 11 |
* OUTPUT owned by page builders; this one clears caches of whole RESPONSES |
| 12 |
* owned by the web server. Both are integrations with software we do not ship, |
| 13 |
* and both hang off a public seam so a site can add its own. |
| 14 |
* |
| 15 |
* Nothing here touches another plugin's files or runs a shell command. Each |
| 16 |
* integration calls the documented public API of the plugin it integrates |
| 17 |
* with, and detects that plugin by class or constant rather than by path — a |
| 18 |
* renamed plugin folder must not silently disable the integration. |
| 19 |
* |
| 20 |
* Tier: Free. xSpeed's tiering rule (FEATURES.md) is that anything LiteSpeed |
| 21 |
* Cache ships free, xSpeed ships free — and their purge API is free. Gating |
| 22 |
* this would mean an unlicensed site keeps serving stale HTML from LSCache, |
| 23 |
* which is a correctness bug, not a paid feature. |
| 24 |
* |
| 25 |
* @package XSpeed |
| 26 |
*/ |
| 27 |
|
| 28 |
declare(strict_types=1); |
| 29 |
|
| 30 |
namespace XSpeed; |
| 31 |
|
| 32 |
defined( 'ABSPATH' ) || exit; |
| 33 |
|
| 34 |
final class Server_Caches { |
| 35 |
|
| 36 |
/* |
| 37 |
* There is deliberately no boot()/add_action here. `Cache` calls forward() |
| 38 |
* directly, before it fires the public purge actions. |
| 39 |
* |
| 40 |
* As a listener this would be one callback among many, and WordPress stops |
| 41 |
* dispatching an action's remaining callbacks when an earlier one throws — |
| 42 |
* so an unrelated third-party listener's bug could silently skip our |
| 43 |
* LiteSpeed forwarding, leaving the server serving stale HTML while xSpeed |
| 44 |
* reported a successful purge. Shipped behaviour should not be hostage to |
| 45 |
* that. Third parties still extend through `xspeed_purge_server_caches` |
| 46 |
* below, which runs after we have done our own work. |
| 47 |
*/ |
| 48 |
|
| 49 |
/** |
| 50 |
* Forward one purge to every server cache we recognise. |
| 51 |
* |
| 52 |
* The public context carries the action an adapter should take: |
| 53 |
* `urls` purges only the listed response URLs, `site` purges this site's |
| 54 |
* response cache, and `network` represents a deliberate whole-tree sweep. |
| 55 |
* Older callers that omit `scope` retain the original url/null behaviour. |
| 56 |
* |
| 57 |
* @param array<string,mixed> $context See `xspeed_after_purge_url`. |
| 58 |
*/ |
| 59 |
public static function forward( $context ): void { |
| 60 |
if ( ! is_array( $context ) ) { |
| 61 |
return; |
| 62 |
} |
| 63 |
|
| 64 |
// A broken built-in adapter must not suppress the public seam. The local |
| 65 |
// purge already succeeded, and another adapter may still clear the edge. |
| 66 |
try { |
| 67 |
self::forward_litespeed( $context ); |
| 68 |
} catch ( \Throwable $e ) { |
| 69 |
if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) { |
| 70 |
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- built-in integration failure after local invalidation. |
| 71 |
error_log( '[xspeed] LiteSpeed response purge failed: ' . $e->getMessage() ); |
| 72 |
} |
| 73 |
} |
| 74 |
|
| 75 |
/** |
| 76 |
* Fires so a site can invalidate a server cache xSpeed does not know. |
| 77 |
* |
| 78 |
* Same context as the event that triggered it. Use this rather than |
| 79 |
* subscribing to `xspeed_after_purge_url` directly when you want to |
| 80 |
* run only after the built-in integrations have had their turn. |
| 81 |
* |
| 82 |
* @since 1.2.3 |
| 83 |
* |
| 84 |
* @param array $context Bounded purge context. |
| 85 |
*/ |
| 86 |
// Use WordPress' dispatcher so current_action(), did_action(), the `all` |
| 87 |
// hook and observability tools retain native semantics. Cache wraps each |
| 88 |
// callback one level down so a throwing adapter cannot cancel the ones |
| 89 |
// queued behind it. |
| 90 |
Cache::do_action_isolated( 'xspeed_purge_server_caches', $context ); |
| 91 |
} |
| 92 |
|
| 93 |
/** |
| 94 |
* LiteSpeed Cache: URL purges plus its response-cache-only full seam. |
| 95 |
* |
| 96 |
* URL purges use LiteSpeed's documented `litespeed_purge_url` action. A |
| 97 |
* site-wide response invalidation calls the public |
| 98 |
* `LiteSpeed\Purge::purge_all_lscache()` seam added in 7.7. Older releases |
| 99 |
* expose only the broad purge-all API, so full forwarding deliberately |
| 100 |
* stands down there. Do not use `litespeed_purge_all`: in 7.9 that also |
| 101 |
* deletes LiteSpeed |
| 102 |
* CSS/JS, local-resource, object and opcode caches and may purge its |
| 103 |
* Cloudflare integration. xSpeed only owns the response invalidation. |
| 104 |
* |
| 105 |
* Detected by constant, not plugin path. `LSCWP_V` is defined by the |
| 106 |
* plugin bootstrap and survives a renamed folder. |
| 107 |
* |
| 108 |
* @param array<string,mixed> $context Public purge context. |
| 109 |
*/ |
| 110 |
private static function forward_litespeed( array $context ): void { |
| 111 |
if ( ! defined( 'LSCWP_V' ) ) { |
| 112 |
return; |
| 113 |
} |
| 114 |
|
| 115 |
$url = isset( $context['url'] ) && is_string( $context['url'] ) ? $context['url'] : ''; |
| 116 |
$host = isset( $context['host'] ) && is_string( $context['host'] ) ? $context['host'] : ''; |
| 117 |
$scope = isset( $context['scope'] ) && is_string( $context['scope'] ) |
| 118 |
? $context['scope'] |
| 119 |
: ( '' !== $url ? 'urls' : 'site' ); |
| 120 |
|
| 121 |
if ( 'none' === $scope ) { |
| 122 |
return; |
| 123 |
} |
| 124 |
|
| 125 |
if ( 'site' === $scope || 'network' === $scope ) { |
| 126 |
// A full purge scoped to ANOTHER site — Multisite::purge_site() |
| 127 |
// runs inside switch_to_blog(), so the request's LSCache is not |
| 128 |
// that site's — must not flush ours. `'*'` is the deliberate |
| 129 |
// whole-tree sweep and does mean everything. An empty host is the |
| 130 |
// single-site case, where the purge is ours by definition. |
| 131 |
if ( '' !== $host && '*' !== $host && ! self::host_is_this_site( $host ) ) { |
| 132 |
return; |
| 133 |
} |
| 134 |
if ( is_callable( array( '\\LiteSpeed\\Purge', 'purge_all_lscache' ) ) ) { |
| 135 |
// LiteSpeed normally prefixes `*` with the current blog ID. A |
| 136 |
// network response contract needs the raw `*` tag. This is the same |
| 137 |
// official switch used by its Empty Entire Cache path and does not |
| 138 |
// invoke its CSS/JS, object or opcode purgers. |
| 139 |
if ( 'network' === $scope && ! defined( 'LSWCP_EMPTYCACHE' ) ) { |
| 140 |
define( 'LSWCP_EMPTYCACHE', true ); |
| 141 |
} |
| 142 |
\LiteSpeed\Purge::purge_all_lscache( 'xSpeed response invalidation' ); |
| 143 |
} |
| 144 |
return; |
| 145 |
} |
| 146 |
|
| 147 |
$urls = array(); |
| 148 |
if ( isset( $context['urls'] ) && is_array( $context['urls'] ) ) { |
| 149 |
$urls = $context['urls']; |
| 150 |
} elseif ( '' !== $url ) { |
| 151 |
$urls = array( $url ); |
| 152 |
} |
| 153 |
$targets = array(); |
| 154 |
foreach ( array_unique( array_filter( $urls, 'is_string' ) ) as $target_url ) { |
| 155 |
$targets = array_merge( $targets, self::litespeed_targets_for_url( $target_url ) ); |
| 156 |
} |
| 157 |
foreach ( array_values( array_unique( $targets ) ) as $target ) { |
| 158 |
do_action( 'litespeed_purge_url', $target ); |
| 159 |
} |
| 160 |
} |
| 161 |
|
| 162 |
/** |
| 163 |
* Build LiteSpeed targets for one same-site URL. |
| 164 |
* |
| 165 |
* @return string[] |
| 166 |
*/ |
| 167 |
private static function litespeed_targets_for_url( string $url ): array { |
| 168 |
// Only this site's own URLs. `purge_url()` supports cross-site purges |
| 169 |
// (multisite, WP-CLI, cron), and LSCache is per-site: reducing another |
| 170 |
// site's URL to a path would have this site's LiteSpeed purge its OWN |
| 171 |
// /page/ — the wrong entry gone, the intended one still stale, and a |
| 172 |
// success reported for both. The other site's server cache is not |
| 173 |
// addressable from here, so we stand down and leave it to a |
| 174 |
// network-aware listener on `xspeed_purge_server_caches`. (QA review) |
| 175 |
if ( ! self::is_this_site( $url ) ) { |
| 176 |
return array(); |
| 177 |
} |
| 178 |
// Both trailing-slash forms. Our own sweep purges `/about` and |
| 179 |
// `/about/` because the cache key preserves whichever the request |
| 180 |
// used, and LiteSpeed tags them separately for the same reason — so |
| 181 |
// forwarding only the canonical form can leave the other a HIT. Root |
| 182 |
// stays a single '/'. (QA review; plausible rather than reproduced — |
| 183 |
// LSCache dedupes identical tags, so the cost of being wrong is one |
| 184 |
// redundant purge.) |
| 185 |
return self::slash_forms( self::site_relative( $url ) ); |
| 186 |
} |
| 187 |
|
| 188 |
/** |
| 189 |
* A relative target in both trailing-slash forms, deduplicated. |
| 190 |
* |
| 191 |
* @return string[] |
| 192 |
*/ |
| 193 |
private static function slash_forms( string $relative ): array { |
| 194 |
$query = ''; |
| 195 |
$path = $relative; |
| 196 |
$split = strpos( $relative, '?' ); |
| 197 |
if ( false !== $split ) { |
| 198 |
$path = substr( $relative, 0, $split ); |
| 199 |
$query = substr( $relative, $split ); |
| 200 |
} |
| 201 |
if ( '/' === $path || '' === $path ) { |
| 202 |
return array( $relative ); |
| 203 |
} |
| 204 |
$bare = rtrim( $path, '/' ); |
| 205 |
// Keep the exact spelling too. `/path///` can be a distinct server key. |
| 206 |
return array_values( array_unique( array( $path . $query, $bare . $query, $bare . '/' . $query ) ) ); |
| 207 |
} |
| 208 |
|
| 209 |
/** |
| 210 |
* Is this URL served by the site we are running as? |
| 211 |
* |
| 212 |
* Host and port, because a site on a non-standard port is a different |
| 213 |
* origin. Unknown either way means no — a purge sent to the wrong cache is |
| 214 |
* worse than one not sent at all. |
| 215 |
*/ |
| 216 |
private static function is_this_site( string $url ): bool { |
| 217 |
if ( ! self::host_is_this_site( self::host_of( $url ) ) ) { |
| 218 |
return false; |
| 219 |
} |
| 220 |
|
| 221 |
// On a subdirectory network, equal hosts do not mean equal blogs. |
| 222 |
if ( function_exists( 'is_multisite' ) && is_multisite() |
| 223 |
&& function_exists( 'get_blog_details' ) && function_exists( 'get_current_blog_id' ) |
| 224 |
) { |
| 225 |
$parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- bounded URL ownership lookup. |
| 226 |
if ( ! is_array( $parts ) ) { |
| 227 |
return false; |
| 228 |
} |
| 229 |
$host = isset( $parts['host'] ) ? (string) $parts['host'] : ''; |
| 230 |
$path = isset( $parts['path'] ) ? (string) $parts['path'] : '/'; |
| 231 |
$segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ) ) ); |
| 232 |
for ( $take = min( count( $segments ), 2 ); $take >= 0; --$take ) { |
| 233 |
$candidate = 0 === $take ? '/' : '/' . implode( '/', array_slice( $segments, 0, $take ) ) . '/'; |
| 234 |
$details = get_blog_details( array( 'domain' => $host, 'path' => $candidate ), false ); |
| 235 |
if ( $details && isset( $details->blog_id ) ) { |
| 236 |
return (int) $details->blog_id === (int) get_current_blog_id(); |
| 237 |
} |
| 238 |
} |
| 239 |
} |
| 240 |
|
| 241 |
return true; |
| 242 |
} |
| 243 |
|
| 244 |
/** Compare a host[:port] against the running site's. */ |
| 245 |
private static function host_is_this_site( string $host ): bool { |
| 246 |
if ( ! function_exists( 'home_url' ) ) { |
| 247 |
return false; |
| 248 |
} |
| 249 |
$ours = self::host_of( (string) home_url( '/' ) ); |
| 250 |
return '' !== $ours && '' !== $host && $ours === strtolower( $host ); |
| 251 |
} |
| 252 |
|
| 253 |
/** |
| 254 |
* host[:port] of a URL, lowercased; '' when it has none. |
| 255 |
* |
| 256 |
* A port that is the default for the scheme is dropped, because it is not |
| 257 |
* part of the origin: `https://site.com:443/p/` and `https://site.com/p/` |
| 258 |
* are the same page, and RFC 3986 6.2.3 says so. Comparing them as raw |
| 259 |
* strings made `:443` look like a different site, so the purge stood down |
| 260 |
* and LiteSpeed was told nothing at all — while the caller was told the |
| 261 |
* page "was already cold". The page kept serving the old copy until its |
| 262 |
* TTL ran out. |
| 263 |
* |
| 264 |
* Reachable from `wp xspeed cache purge-url`, the MCP `purge_url` tool, |
| 265 |
* and any plugin passing a canonical URL that spells out the port. The |
| 266 |
* reverse direction was worse: a site whose own `home_url()` carries |
| 267 |
* `:443` — normal behind a proxy — matched none of its own URLs, so no |
| 268 |
* per-page purge ever reached the server cache, silently, site-wide. |
| 269 |
* |
| 270 |
* A NON-default port is still kept: `site.com:8443` genuinely is a |
| 271 |
* different origin from `site.com`, and collapsing those would send one |
| 272 |
* site's purge to another's cache. (QA #348) |
| 273 |
*/ |
| 274 |
private static function host_of( string $url ): string { |
| 275 |
$parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- host only. |
| 276 |
if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { |
| 277 |
return ''; |
| 278 |
} |
| 279 |
$host = strtolower( (string) $parts['host'] ); |
| 280 |
if ( empty( $parts['port'] ) ) { |
| 281 |
return $host; |
| 282 |
} |
| 283 |
$port = (int) $parts['port']; |
| 284 |
$scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''; |
| 285 |
if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) { |
| 286 |
return $host; |
| 287 |
} |
| 288 |
return $host . ':' . $port; |
| 289 |
} |
| 290 |
|
| 291 |
/** |
| 292 |
* Reduce an absolute URL to the site-relative path LiteSpeed keys on. |
| 293 |
* |
| 294 |
* LiteSpeed does this itself in `Utility::make_relative()`, by stripping a |
| 295 |
* `LSCWP_DOMAIN` built with `HTTP_URL_STRIP_ALL` — which strips the PORT. |
| 296 |
* On a site served from a non-standard port, `http://host:8244/page/` has |
| 297 |
* `http://host` removed and becomes `:8244/page/`, which is not a valid |
| 298 |
* URI tag, so the purge silently matches nothing and the server keeps |
| 299 |
* serving the page. Measured on OpenLiteSpeed 1.8.2 with LiteSpeed Cache |
| 300 |
* 7.9: an absolute URL left the entry a HIT, the same purge sent as a path |
| 301 |
* turned it into a MISS. |
| 302 |
* |
| 303 |
* Sending the path sidesteps their parsing entirely and is what they |
| 304 |
* ultimately hash, so it is correct on standard ports too — this is not a |
| 305 |
* workaround we would want to remove once they fix it. |
| 306 |
* |
| 307 |
* Query strings are preserved: LiteSpeed tags them separately, and a purge |
| 308 |
* for `/shop/` should not silently claim to have cleared `/shop/?page=2`. |
| 309 |
*/ |
| 310 |
private static function site_relative( string $url ): string { |
| 311 |
$parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- path extraction only. |
| 312 |
if ( ! is_array( $parts ) ) { |
| 313 |
return $url; |
| 314 |
} |
| 315 |
// An absolute origin with no path is the homepage. LiteSpeed expects |
| 316 |
// '/', never the original absolute URL. Preserve a root query below. |
| 317 |
$path = isset( $parts['path'] ) && '' !== (string) $parts['path'] ? (string) $parts['path'] : '/'; |
| 318 |
$relative = '/' . ltrim( $path, '/' ); |
| 319 |
// isset(), not empty(): a query of "0" is a real, distinct cache entry |
| 320 |
// and empty() calls it falsy, so `/shop/?0` would be sent as `/shop/` |
| 321 |
// and leave the entry the caller named stale. |
| 322 |
if ( isset( $parts['query'] ) && '' !== (string) $parts['query'] ) { |
| 323 |
$relative .= '?' . $parts['query']; |
| 324 |
} |
| 325 |
return $relative; |
| 326 |
} |
| 327 |
} |
| 328 |
|