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
← All changes | includes/advanced-cache.php +157 -5 1.3.3 → 1.4.0 View file →
@@ -1,8 +1,8 @@
1 1 <?php
2 2 /**
3 3 * XSPEED_DROPIN
4 - * XSPEED_DROPIN_VERSION: 9
4 + * XSPEED_DROPIN_VERSION: 15
5 5 * Drop-in cache loader. Serves cached HTML before WordPress fully boots.
6 6 *
7 7 * Bump XSPEED_DROPIN_VERSION whenever this file's serve logic changes so
8 8 * Cache::ensure_dropin_current() reinstalls it on existing sites (the
@@ -28,8 +28,47 @@
28 28 * v8: never serve an empty, stale, or short `.br` sibling — an uninflatable
29 29 * brotli stream renders as a blank page. THIS FILE IS A COPY made when
30 30 * caching was enabled, so without the bump an updated site keeps the old
31 31 * serve logic and never receives the fix (#286).
32 + * v9: emit the edge/CDN headers baked in from `xspeed_edge_cache_headers`
33 + * on a HIT. Without the bump an existing site keeps a drop-in with no
34 + * placeholder to bake into, so an add-on's CDN headers appear on every
35 + * serve path except this one.
36 + * v10: emit `X-XSpeed-Built` — the served file's mtime — so a CDN that has
37 + * just purged can tell whether the origin answered with newer HTML or
38 + * with the same page again. Without the bump an existing site's drop-in
39 + * stays silent and every purge through it reads as unverifiable.
40 + * v11: a page whose edge answer differs from the site-wide one carries its
41 + * own pairs in the `.meta` sidecar, and they REPLACE the baked set.
42 + * Without the bump an existing drop-in keeps sending the baked pairs
43 + * for that page, lifetime and all.
44 + * v12: `XSPEED_EDGE_PROVIDER=off` empties the edge pairs here too, before
45 + * they are sent, so the emergency switch does not wait for a re-bake.
46 + * `X-XSpeed-Built` still goes out: it is a diagnostic, not an
47 + * instruction to the edge.
48 + * v13: a merge of two lines of history that had both used 9 and 10. On `dev`
49 + * they were: v9 carries the baked edge-header answer so a hold set for a
50 + * page reaches the paths that run without PHP, and v10 keeps bots,
51 + * scanners, cached 404s and xSpeed's own requests (by UA or the
52 + * X-XSpeed-Self header) out of hits.log. Everything in v9 to v12 above
53 + * and both of those are in this file. The bump is what makes a site on
54 + * either line rewrite its drop-in.
55 + * v14: the key path lowercases percent-escapes and escapes non-ASCII bytes,
56 + * the spelling Cache::normalize_path() now hashes, so `%E7`, `%e7` and
57 + * raw UTF-8 spellings of a non-ASCII slug share one key with the entry
58 + * PHP wrote. Before, PHP keyed a sanitize_text_field() copy that had
59 + * lost every escape, so these pages were never a HIT here. Without the
60 + * bump an existing site keeps computing the old key for them.
61 + * v15: two changes, one bump.
62 + * - A page with a lifetime of its own (the sidecar `ttl`: a nonce cap,
63 + * a per-post expiry) has every edge lifetime in its pairs cut to what
64 + * the copy has left. Without the bump an existing drop-in keeps
65 + * telling the edge to hold a nonce page for the site's whole lifetime.
66 + * - A HIT served for a URL carrying an ignored param (`?utm_source=…`)
67 + * sends the baked `query-variant` hold instead of the edge lifetime,
68 + * so an edge keeps only URLs a purge can name. A query string of `0`
69 + * is no longer read as no query string. Without the bump an existing
70 + * drop-in keeps telling the edge to store every variant.
32 71 *
33 72 * IMPORTANT: This file is included by wp-settings.php BEFORE
34 73 * wp-includes/formatting.php and wp-includes/load.php are loaded, so NO
35 74 * WordPress functions (sanitize_text_field, wp_unslash, is_admin,
@@ -61,9 +100,18 @@
61 100 // `ignored_query_params` into a regex next to the cache files. Every key
62 101 // must match it; one that doesn't means the response could genuinely vary,
63 102 // so we stand down and let PHP decide. A missing sidecar means the same —
64 103 // fail safe, never guess.
65 -if ( ! empty( $_SERVER['QUERY_STRING'] ) ) {
104 +//
105 +// A key that matches is one the cache key leaves out, so the URL it makes is
106 +// one no purge names. `$xspeed_qs_unpurged` records that for the edge hold
107 +// further down, the same test Cache::query_carries_unpurged_param() makes.
108 +//
109 +// An empty string test rather than empty(): a query string of `0` is still a
110 +// query string, and empty() let `/post?0` through as if it were `/post`.
111 +$xspeed_qs_unpurged = false;
112 +// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Drop-in runs pre-WP. Only tested for emptiness here; parsed for its keys below.
113 +if ( isset( $_SERVER['QUERY_STRING'] ) && '' !== (string) $_SERVER['QUERY_STRING'] ) {
66 114 $xspeed_allow_file = WP_CONTENT_DIR . '/cache/xspeed/.ignored-query-params';
67 115 if ( ! is_readable( $xspeed_allow_file ) ) {
68 116 return;
69 117 }
@@ -84,8 +132,9 @@
84 132 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed baked pattern degrades to "let PHP handle it", never a warning per request.
85 133 if ( 1 !== @preg_match( '#^' . $xspeed_allow_re . '$#', (string) $xspeed_qs_key ) ) {
86 134 return;
87 135 }
136 + $xspeed_qs_unpurged = true;
88 137 }
89 138 }
90 139
91 140 // Honor explicit bypass header. xSpeed's own benchmark REST endpoint
@@ -182,10 +231,25 @@
182 231 $xspeed_host = str_replace( "\0", '', $xspeed_host );
183 232 // Restrict host to a safe charset (letters, digits, dot, hyphen, colon for port).
184 233 $xspeed_host = preg_replace( '/[^a-zA-Z0-9.\-:]/', '', $xspeed_host );
185 234
186 -$xspeed_path_only = strtok( $xspeed_request_uri, '?' );
235 +$xspeed_path_only = (string) strtok( $xspeed_request_uri, '?' );
187 236
237 +// Path spelling. MUST mirror XSpeed\Cache::normalize_path() exactly.
238 +// WordPress links carry lower-case escapes and browsers send upper-case ones,
239 +// so escapes are lowercased, and a byte outside printable ASCII is escaped the
240 +// same way. Nothing is decoded. Without this a non-ASCII slug requested as
241 +// `%E7…` or as raw UTF-8 hashes to a different key than the one
242 +// Cache::store() wrote. A plain ASCII path passes through unchanged. The
243 +// site-path match below sees this spelling too; blog paths are plain ASCII.
244 +$xspeed_path_only = (string) preg_replace_callback(
245 + '/%[0-9a-fA-F]{2}|[^\x21-\x7E]/',
246 + static function ( $xspeed_m ) {
247 + return '%' === $xspeed_m[0][0] ? strtolower( $xspeed_m[0] ) : sprintf( '%%%02x', ord( $xspeed_m[0] ) );
248 + },
249 + $xspeed_path_only
250 +);
251 +
188 252 // Device bucket — MUST mirror XSpeed\Cache::cache_key() exactly, or the key
189 253 // the drop-in computes won't match the file Cache::store() wrote, the HIT
190 254 // branch below never fires, and every request falls through to a full
191 255 // WordPress boot (defeating the whole point of the pre-WP drop-in).
@@ -337,8 +401,31 @@
337 401 if ( isset( $xspeed_meta['edge_headers'] ) && is_array( $xspeed_meta['edge_headers'] ) ) {
338 402 $xspeed_edge_headers = $xspeed_meta['edge_headers'];
339 403 }
340 404
405 + // A URL carrying an ignored param gets the hold baked for it instead.
406 + // This file serves `/post?utm_source=x` from the entry stored for
407 + // `/post`, but an edge keys on the full URL, so it would store each
408 + // variant as its own copy, and a purge of `/post` never reaches them.
409 + // Holding the variants keeps the edge to URLs a purge can name. This
410 + // file keeps serving them; only the edge copy is refused.
411 + //
412 + // It replaces the sidecar too, since that carries a lifetime. The
413 + // page's `Cache-Tag` is kept, as Cache::edge_headers_for() keeps it on
414 + // every hold. An empty literal means the hold would say the same as
415 + // the plain answer, and nothing changes. An un-substituted token
416 + // stays a string and is ignored.
417 + $xspeed_query_hold = '@@XSPEED_EDGE_QUERY_HOLD@@';
418 + if ( $xspeed_qs_unpurged && is_array( $xspeed_query_hold ) && array() !== $xspeed_query_hold ) {
419 + $xspeed_query_tag = ( is_array( $xspeed_edge_headers ) && isset( $xspeed_edge_headers['Cache-Tag'] ) )
420 + ? $xspeed_edge_headers['Cache-Tag']
421 + : null;
422 + $xspeed_edge_headers = $xspeed_query_hold;
423 + if ( null !== $xspeed_query_tag ) {
424 + $xspeed_edge_headers['Cache-Tag'] = $xspeed_query_tag;
425 + }
426 + }
427 +
341 428 // The one setting this file reads for itself. Everything else about
342 429 // the edge answer is baked, because re-deriving it here would mean
343 430 // loading options before WordPress exists. `off` is the exception
344 431 // because it is the emergency switch: when something is wrong in
@@ -347,8 +434,36 @@
347 434 if ( defined( 'XSPEED_EDGE_PROVIDER' ) && 'off' === strtolower( (string) XSPEED_EDGE_PROVIDER ) ) {
348 435 $xspeed_edge_headers = array();
349 436 }
350 437
438 + // A page with a lifetime of its own (a nonce it carries, a per-post
439 + // expiry: the sidecar `ttl`) may not be kept at the edge past it. A
440 + // lifetime in the pairs is the site's, so it is cut to what this copy
441 + // has left. Without the cut a page capped to its nonce went to the edge
442 + // with the site's lifetime and served a dead nonce until the next purge.
443 + //
444 + // The two patterns copy Cache::EDGE_LIFETIME_HEADER and
445 + // Cache::EDGE_LIFETIME_DIRECTIVE, and the cut copies
446 + // Cache::cap_edge_lifetime(): the class is not loaded yet.
447 + if ( is_array( $xspeed_edge_headers ) && isset( $xspeed_meta['ttl'] ) && (int) $xspeed_meta['ttl'] > 0 ) {
448 + $xspeed_left = max( 0, $xspeed_ttl - $xspeed_age );
449 + foreach ( $xspeed_edge_headers as $xspeed_edge_name => $xspeed_edge_value ) {
450 + if ( ! preg_match( '/(?:^|-)control$/i', (string) $xspeed_edge_name ) ) {
451 + continue;
452 + }
453 + $xspeed_capped = preg_replace_callback(
454 + '/(?<![\w-])(max-age|s-maxage)\s*=\s*"?(\d+)"?/i',
455 + static function ( array $m ) use ( $xspeed_left ): string {
456 + return $m[1] . '=' . min( (int) $m[2], $xspeed_left );
457 + },
458 + (string) $xspeed_edge_value
459 + );
460 + if ( is_string( $xspeed_capped ) ) {
461 + $xspeed_edge_headers[ $xspeed_edge_name ] = $xspeed_capped;
462 + }
463 + }
464 + }
465 +
351 466 if ( is_array( $xspeed_edge_headers ) ) {
352 467 foreach ( $xspeed_edge_headers as $xspeed_edge_name => $xspeed_edge_value ) {
353 468 header( $xspeed_edge_name . ': ' . $xspeed_edge_value );
354 469 }
@@ -353,8 +468,22 @@
353 468 header( $xspeed_edge_name . ': ' . $xspeed_edge_value );
354 469 }
355 470 }
356 471
472 + // When this page's HTML was generated, so a CDN that just asked for a
473 + // purge can check whether the origin actually served something newer.
474 + // The cache file's mtime is that moment: store_static() writes the
475 + // file at the end of the render it came from.
476 + //
477 + // Emitted AFTER the baked pairs above and therefore replacing any
478 + // build stamp among them. A baked value is the time the DROP-IN was
479 + // installed, identical on every page for as long as it stays
480 + // installed, so it would answer "yes, freshly built" to every purge
481 + // check forever. Cache::edge_headers_for() already drops it from the
482 + // bake; this ordering means a drop-in installed by an older version
483 + // cannot lie either.
484 + header( 'X-XSpeed-Built: ' . (int) filemtime( $xspeed_cache_file ) );
485 +
357 486 // Record the HIT for the dashboard hit-ratio. The drop-in runs
358 487 // BEFORE WordPress loads, so it can't call Hit_Counter — instead
359 488 // it appends one line to the same hits.log the nginx static path
360 489 // uses, and Hit_Counter::collect_nginx_log_hits() drains + counts
@@ -372,10 +501,33 @@
372 501 $xspeed_hits_log = '@@XSPEED_HITS_LOG@@'; // replaced at install
373 502 if ( '@@' === substr( $xspeed_hits_log, 0, 2 ) ) {
374 503 $xspeed_hits_log = WP_CONTENT_DIR . '/uploads/xspeed/hits.log';
375 504 }
376 - // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- pre-WP drop-in; WP_Filesystem isn't loaded. One short line, append + lock; failures are non-fatal (the ratio just under-counts).
377 - @file_put_contents( $xspeed_hits_log, "hit\n", FILE_APPEND | LOCK_EX );
505 + // Don't count a bot, a scanner, a 404 or one of xSpeed's own
506 + // requests as a visitor hit. It has to be decided HERE: the log line
507 + // is just "hit" with no user agent, so Hit_Counter batch-counts these
508 + // lines blind and nothing downstream can reclassify one. The UA
509 + // pattern is baked in at install time from
510 + // Hit_Counter::excluded_ua_regex() (the drop-in runs before
511 + // WordPress, so it cannot ask). xSpeed's own requests also carry the
512 + // X-XSpeed-Self header (Self_Traffic::HEADER), which is what catches
513 + // a warmer renamed to a real browser's UA without dropping real
514 + // visitors on that browser. An empty UA counts as automated, like
515 + // is_bot_ua(''). A cached 404 is excluded on the PHP path too.
516 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- pre-WP drop-in; only matched against a baked pattern, never echoed or stored.
517 + $xspeed_hit_ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
518 + $xspeed_hit_ex = '@@XSPEED_HIT_EXCLUDE_RE@@';
519 + $xspeed_self = '' === $xspeed_hit_ua
520 + || ! empty( $_SERVER['HTTP_X_XSPEED_SELF'] )
521 + || ( isset( $xspeed_meta['status'] ) && 404 === (int) $xspeed_meta['status'] );
522 + if ( ! $xspeed_self && '@@' !== substr( $xspeed_hit_ex, 0, 2 ) && '' !== $xspeed_hit_ex ) {
523 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a pattern this file did not compose is not worth a warning on every hit.
524 + $xspeed_self = 1 === @preg_match( '#(' . $xspeed_hit_ex . ')#i', $xspeed_hit_ua );
525 + }
526 + if ( ! $xspeed_self ) {
527 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- pre-WP drop-in; WP_Filesystem isn't loaded. One short line, append + lock; failures are non-fatal (the ratio just under-counts).
528 + @file_put_contents( $xspeed_hits_log, "hit\n", FILE_APPEND | LOCK_EX );
529 + }
378 530
379 531 // Replay the cached response's status + content-type from .meta, so a
380 532 // cached 404 serves 404 (not a soft-404 200) and a cached feed serves
381 533 // application/rss+xml (not text/html). (FBS-82406, FBS-82407)