', ' ExpiresActive On', ' ExpiresByType text/html "access plus ' . $html . ' seconds"', ' ExpiresByType text/css "access plus ' . $asset . ' seconds"', ' ExpiresByType text/javascript "access plus ' . $asset . ' seconds"', ' ExpiresByType application/javascript "access plus ' . $asset . ' seconds"', ' ExpiresByType application/json "access plus ' . $html . ' seconds"', ' ExpiresByType image/jpeg "access plus ' . $asset . ' seconds"', ' ExpiresByType image/png "access plus ' . $asset . ' seconds"', ' ExpiresByType image/webp "access plus ' . $asset . ' seconds"', ' ExpiresByType image/avif "access plus ' . $asset . ' seconds"', ' ExpiresByType image/gif "access plus ' . $asset . ' seconds"', ' ExpiresByType image/svg+xml "access plus ' . $asset . ' seconds"', ' ExpiresByType image/x-icon "access plus ' . $asset . ' seconds"', ' ExpiresByType font/woff2 "access plus ' . $asset . ' seconds"', ' ExpiresByType font/woff "access plus ' . $asset . ' seconds"', ' ExpiresByType font/ttf "access plus ' . $asset . ' seconds"', ' ExpiresByType font/otf "access plus ' . $asset . ' seconds"', '', '', ' ', ' Header set Cache-Control "public, max-age=' . $asset . ', immutable"', ' ', ' ', ' Header set Cache-Control "public, max-age=' . $html . '"', ' ', '', ); } /** * Probe whether long-lived caching headers are actually reaching the * browser. Picks a recognisable static asset (anything in * `wp-includes/css/` is always served on a WP install) and HEADs it. * Cached in a transient so this never adds latency to the dashboard. * * We test for the EFFECT, not for our own configuration (issue #329). * The old check looked for `immutable` — the fingerprint *our* snippet * writes — so any site whose headers come from another layer (an * xCloud-generated vhost, a container nginx, a reverse proxy) was told * "enabled but not active on the server" while demonstrably serving * `Cache-Control: public, max-age=315360000` on every asset. That is a * false alarm the operator cannot dismiss, and it pushed them toward * pasting a snippet that would add a second, conflicting `location` * block. A long `max-age`, an `Expires` date in the future, or * `immutable` all mean the same thing to a browser, so all three count. * * Returns: * true → caching headers present (ours or another layer's), no notice * false → proven absent: nothing is being sent, keep the notice up * null → no verdict; the loopback never completed (firewalled, TLS * failure, timeout, WAF answering instead of the origin) * * The third state matters for the same reason it does on the GZIP probe * (issue #18): folding "couldn't ask" into "the answer is no" pins a * permanent warning onto sites where only the loopback is broken. Only a * *proven* false may nag the user. A non-verdict is cached for a minute * only, so a transient blip resolves itself on the next page load. * * @return bool|null */ public static function probe_headers_present() { $cached = get_transient( 'xspeed_browser_cache_probe' ); if ( '?' === $cached ) { return null; } if ( null !== $cached && false !== $cached ) { return (bool) $cached; } $asset_url = includes_url( 'css/dashicons.min.css' ); $resp = wp_remote_head( $asset_url, array( 'timeout' => 3, 'sslverify' => false, 'redirection' => 0, 'headers' => array( 'Cache-Control' => 'no-cache' ), ) ); if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) { set_transient( 'xspeed_browser_cache_probe', '?', MINUTE_IN_SECONDS ); return null; } // wp_remote_retrieve_header() returns a STRING for a single header // but an ARRAY when the header appears more than once (common behind // CDNs / proxies, or nginx with multiple add_header lines). Casting // an array with (string) emits an "Array to string conversion" // warning AND flattens to the literal "Array", so the immutable // check below silently false-negatives. Normalize array → string // first. (FBS-82141) $raw = wp_remote_retrieve_header( $resp, 'cache-control' ); $cache_control = is_array( $raw ) ? implode( ', ', $raw ) : (string) $raw; $raw_expires = wp_remote_retrieve_header( $resp, 'expires' ); $expires = is_array( $raw_expires ) ? implode( ', ', $raw_expires ) : (string) $raw_expires; $active = self::headers_indicate_caching( $cache_control, $expires ); set_transient( 'xspeed_browser_cache_probe', $active ? 1 : 0, 5 * MINUTE_IN_SECONDS ); return $active; } /** * Do these response headers tell a browser to cache the asset for a * meaningful length of time? * * Any of three signals counts, because a browser honours all three * equally and we must not privilege the one our own snippet happens to * write (issue #329): * * - `immutable` — what our snippet adds * - a long `max-age` — what most host templates emit * - a future `Expires` — the older directive, still what nginx's * `expires` emits alongside `Cache-Control` * * An explicit no-store / no-cache / max-age=0 is a proven negative and * wins over everything else: that is a server actively refusing to let * the asset be cached, which is exactly the state worth warning about. * * The one-hour floor keeps WP core's own short defaults from reading as * "browser caching is configured". * * Pure — unit-tested. */ public static function headers_indicate_caching( string $cache_control, string $expires = '' ): bool { if ( preg_match( '#\b(?:no-store|no-cache)\b#i', $cache_control ) ) { return false; } if ( preg_match( '#\bmax-age\s*=\s*(\d+)#i', $cache_control, $m ) ) { return (int) $m[1] >= HOUR_IN_SECONDS; } if ( false !== stripos( $cache_control, 'immutable' ) ) { return true; } if ( '' !== $expires ) { $ts = strtotime( $expires ); // A past date (or the literal "0" some servers send) means // "already stale", not "cached". return false !== $ts && $ts > time() + HOUR_IN_SECONDS; } return false; } /** * nginx snippet — one `add_header Cache-Control` per location, with * `expires off;` pinned in front of it. * * nginx's `expires Ns;` emits its own `Cache-Control: max-age=N` (plus * `Expires`), and `add_header` appends rather than replaces, so pairing * the two — as this block used to — put two Cache-Control fields on * every static asset it matched, WP core's own included (issue #259). * `expires` can't say `immutable` and `immutable` is the point, so * `add_header` is the one that stays. * * `off` rather than simply dropping the directive: `expires` is * inherited from `server {}`, so a host template that sets one there * would recreate the duplicate inside this location. * * Losing `Expires:` on nginx costs nothing — `max-age` outranks it in * any HTTP/1.1 cache, and Apache still emits both via mod_expires. * * Deliberately no `always`. Without it nginx applies `add_header` only * to the statuses its header filter treats as safe — 200, 201, 204, * 206, 301, 302, 303, 304, 307, 308 — which is the coverage we want, * and the same coverage Apache gives: `Header set` in apache_rules() * runs under the default `onsuccess` condition, so it is off on error * responses too. `always` would put `max-age=31536000, immutable` on a * 404, and minified asset URLs are deterministic (Minifier keys them on * path + mtime), so a file that 404s in the window after a purge comes * back at the URL a client has already cached the 404 for — for a year, * with no revalidation. */ public static function nginx_snippet( array $opts = array() ): string { $asset = (int) ( $opts['asset_ttl'] ?? self::DEFAULT_ASSET_TTL ); $html = (int) ( $opts['html_ttl'] ?? self::DEFAULT_HTML_TTL ); // Same guard as apache_rules(): a negative TTL would otherwise // reach the vhost as `max-age=-1`. if ( $asset < 0 ) { $asset = self::DEFAULT_ASSET_TTL; } if ( $html < 0 ) { $html = self::DEFAULT_HTML_TTL; } return implode( "\n", array( 'location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff2|woff|ttf|otf|eot|mp4|webm|mp3|ogg)$ {', ' expires off;', ' add_header Cache-Control "public, max-age=' . $asset . ', immutable";', '}', 'location ~* \.html$ {', ' expires off;', ' add_header Cache-Control "public, max-age=' . $html . '";', '}', ) ); } }