PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 1.2.1 All 27 releases
xspeed / includes / class-browser-cache.php

class-browser-cache.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.0, at includes/class-browser-cache.php

264 lines 10.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Browser_Cache — writes/removes browser-cache directives in the site
4 * root .htaccess so static assets get long Cache-Control + Expires
5 * headers, and serves an nginx snippet for non-Apache hosts (that one
6 * sends Cache-Control only — see nginx_snippet()).
7 *
8 * Same shape as Gzip: marker block, insert_with_markers, snippet
9 * fallback. Independent toggle so users can enable browser caching
10 * without compression and vice versa.
11 *
12 * The default TTLs follow LiteSpeed/WP Rocket conventions:
13 * - Static assets (CSS/JS/fonts/images): 1 year + immutable.
14 * - HTML: 1 hour (so post edits go live the same day even if a CDN
15 * has cached the document).
16 *
17 * @package XSpeed
18 */
19
20 declare(strict_types=1);
21
22 namespace XSpeed;
23
24 defined( 'ABSPATH' ) || exit;
25
26 final class Browser_Cache {
27
28 public const MARKER = 'xSpeed Browser Cache';
29
30 public const DEFAULT_ASSET_TTL = 31536000; // 1 year
31 public const DEFAULT_HTML_TTL = 3600; // 1 hour
32
33 public static function apply( bool $enabled, array $opts = array() ): bool {
34 if ( ! function_exists( 'XSpeed\\Server::supports_htaccess' ) && class_exists( '\\XSpeed\\Server' ) ) {
35 if ( ! Server::supports_htaccess() ) {
36 return false;
37 }
38 }
39 $htaccess = ABSPATH . '.htaccess';
40 if ( ! file_exists( $htaccess ) ) {
41 return false;
42 }
43 if ( ! function_exists( 'insert_with_markers' ) ) {
44 require_once ABSPATH . 'wp-admin/includes/misc.php';
45 }
46 $rules = $enabled ? self::apache_rules( $opts ) : array();
47 return (bool) insert_with_markers( $htaccess, self::MARKER, $rules );
48 }
49
50 /**
51 * Apache rule lines. mod_expires handles the Expires header; an
52 * inline mod_headers Cache-Control mirror lets us include
53 * `immutable` (mod_expires doesn't emit it).
54 *
55 * @return string[]
56 */
57 public static function apache_rules( array $opts = array() ): array {
58 $asset = (int) ( $opts['asset_ttl'] ?? self::DEFAULT_ASSET_TTL );
59 $html = (int) ( $opts['html_ttl'] ?? self::DEFAULT_HTML_TTL );
60 if ( $asset < 0 ) {
61 $asset = self::DEFAULT_ASSET_TTL;
62 }
63 if ( $html < 0 ) {
64 $html = self::DEFAULT_HTML_TTL;
65 }
66 return array(
67 '<IfModule mod_expires.c>',
68 ' ExpiresActive On',
69 ' ExpiresByType text/html "access plus ' . $html . ' seconds"',
70 ' ExpiresByType text/css "access plus ' . $asset . ' seconds"',
71 ' ExpiresByType text/javascript "access plus ' . $asset . ' seconds"',
72 ' ExpiresByType application/javascript "access plus ' . $asset . ' seconds"',
73 ' ExpiresByType application/json "access plus ' . $html . ' seconds"',
74 ' ExpiresByType image/jpeg "access plus ' . $asset . ' seconds"',
75 ' ExpiresByType image/png "access plus ' . $asset . ' seconds"',
76 ' ExpiresByType image/webp "access plus ' . $asset . ' seconds"',
77 ' ExpiresByType image/avif "access plus ' . $asset . ' seconds"',
78 ' ExpiresByType image/gif "access plus ' . $asset . ' seconds"',
79 ' ExpiresByType image/svg+xml "access plus ' . $asset . ' seconds"',
80 ' ExpiresByType image/x-icon "access plus ' . $asset . ' seconds"',
81 ' ExpiresByType font/woff2 "access plus ' . $asset . ' seconds"',
82 ' ExpiresByType font/woff "access plus ' . $asset . ' seconds"',
83 ' ExpiresByType font/ttf "access plus ' . $asset . ' seconds"',
84 ' ExpiresByType font/otf "access plus ' . $asset . ' seconds"',
85 '</IfModule>',
86 '<IfModule mod_headers.c>',
87 ' <FilesMatch "\.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff2|woff|ttf|otf|eot|mp4|webm|mp3|ogg)$">',
88 ' Header set Cache-Control "public, max-age=' . $asset . ', immutable"',
89 ' </FilesMatch>',
90 ' <FilesMatch "\.html$">',
91 ' Header set Cache-Control "public, max-age=' . $html . '"',
92 ' </FilesMatch>',
93 '</IfModule>',
94 );
95 }
96
97 /**
98 * Probe whether long-lived caching headers are actually reaching the
99 * browser. Picks a recognisable static asset (anything in
100 * `wp-includes/css/` is always served on a WP install) and HEADs it.
101 * Cached in a transient so this never adds latency to the dashboard.
102 *
103 * We test for the EFFECT, not for our own configuration (issue #329).
104 * The old check looked for `immutable` — the fingerprint *our* snippet
105 * writes — so any site whose headers come from another layer (an
106 * xCloud-generated vhost, a container nginx, a reverse proxy) was told
107 * "enabled but not active on the server" while demonstrably serving
108 * `Cache-Control: public, max-age=315360000` on every asset. That is a
109 * false alarm the operator cannot dismiss, and it pushed them toward
110 * pasting a snippet that would add a second, conflicting `location`
111 * block. A long `max-age`, an `Expires` date in the future, or
112 * `immutable` all mean the same thing to a browser, so all three count.
113 *
114 * Returns:
115 * true → caching headers present (ours or another layer's), no notice
116 * false → proven absent: nothing is being sent, keep the notice up
117 * null → no verdict; the loopback never completed (firewalled, TLS
118 * failure, timeout, WAF answering instead of the origin)
119 *
120 * The third state matters for the same reason it does on the GZIP probe
121 * (issue #18): folding "couldn't ask" into "the answer is no" pins a
122 * permanent warning onto sites where only the loopback is broken. Only a
123 * *proven* false may nag the user. A non-verdict is cached for a minute
124 * only, so a transient blip resolves itself on the next page load.
125 *
126 * @return bool|null
127 */
128 public static function probe_headers_present() {
129 $cached = get_transient( 'xspeed_browser_cache_probe' );
130 if ( '?' === $cached ) {
131 return null;
132 }
133 if ( null !== $cached && false !== $cached ) {
134 return (bool) $cached;
135 }
136
137 $asset_url = includes_url( 'css/dashicons.min.css' );
138 $resp = wp_remote_head(
139 $asset_url,
140 array(
141 'timeout' => 3,
142 'sslverify' => false,
143 'redirection' => 0,
144 'headers' => array( 'Cache-Control' => 'no-cache' ),
145 )
146 );
147 if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
148 set_transient( 'xspeed_browser_cache_probe', '?', MINUTE_IN_SECONDS );
149 return null;
150 }
151 // wp_remote_retrieve_header() returns a STRING for a single header
152 // but an ARRAY when the header appears more than once (common behind
153 // CDNs / proxies, or nginx with multiple add_header lines). Casting
154 // an array with (string) emits an "Array to string conversion"
155 // warning AND flattens to the literal "Array", so the immutable
156 // check below silently false-negatives. Normalize array → string
157 // first. (FBS-82141)
158 $raw = wp_remote_retrieve_header( $resp, 'cache-control' );
159 $cache_control = is_array( $raw ) ? implode( ', ', $raw ) : (string) $raw;
160 $raw_expires = wp_remote_retrieve_header( $resp, 'expires' );
161 $expires = is_array( $raw_expires ) ? implode( ', ', $raw_expires ) : (string) $raw_expires;
162
163 $active = self::headers_indicate_caching( $cache_control, $expires );
164 set_transient( 'xspeed_browser_cache_probe', $active ? 1 : 0, 5 * MINUTE_IN_SECONDS );
165 return $active;
166 }
167
168 /**
169 * Do these response headers tell a browser to cache the asset for a
170 * meaningful length of time?
171 *
172 * Any of three signals counts, because a browser honours all three
173 * equally and we must not privilege the one our own snippet happens to
174 * write (issue #329):
175 *
176 * - `immutable` — what our snippet adds
177 * - a long `max-age` — what most host templates emit
178 * - a future `Expires` — the older directive, still what nginx's
179 * `expires` emits alongside `Cache-Control`
180 *
181 * An explicit no-store / no-cache / max-age=0 is a proven negative and
182 * wins over everything else: that is a server actively refusing to let
183 * the asset be cached, which is exactly the state worth warning about.
184 *
185 * The one-hour floor keeps WP core's own short defaults from reading as
186 * "browser caching is configured".
187 *
188 * Pure — unit-tested.
189 */
190 public static function headers_indicate_caching( string $cache_control, string $expires = '' ): bool {
191 if ( preg_match( '#\b(?:no-store|no-cache)\b#i', $cache_control ) ) {
192 return false;
193 }
194 if ( preg_match( '#\bmax-age\s*=\s*(\d+)#i', $cache_control, $m ) ) {
195 return (int) $m[1] >= HOUR_IN_SECONDS;
196 }
197 if ( false !== stripos( $cache_control, 'immutable' ) ) {
198 return true;
199 }
200 if ( '' !== $expires ) {
201 $ts = strtotime( $expires );
202 // A past date (or the literal "0" some servers send) means
203 // "already stale", not "cached".
204 return false !== $ts && $ts > time() + HOUR_IN_SECONDS;
205 }
206 return false;
207 }
208
209 /**
210 * nginx snippet — one `add_header Cache-Control` per location, with
211 * `expires off;` pinned in front of it.
212 *
213 * nginx's `expires Ns;` emits its own `Cache-Control: max-age=N` (plus
214 * `Expires`), and `add_header` appends rather than replaces, so pairing
215 * the two — as this block used to — put two Cache-Control fields on
216 * every static asset it matched, WP core's own included (issue #259).
217 * `expires` can't say `immutable` and `immutable` is the point, so
218 * `add_header` is the one that stays.
219 *
220 * `off` rather than simply dropping the directive: `expires` is
221 * inherited from `server {}`, so a host template that sets one there
222 * would recreate the duplicate inside this location.
223 *
224 * Losing `Expires:` on nginx costs nothing — `max-age` outranks it in
225 * any HTTP/1.1 cache, and Apache still emits both via mod_expires.
226 *
227 * Deliberately no `always`. Without it nginx applies `add_header` only
228 * to the statuses its header filter treats as safe — 200, 201, 204,
229 * 206, 301, 302, 303, 304, 307, 308 — which is the coverage we want,
230 * and the same coverage Apache gives: `Header set` in apache_rules()
231 * runs under the default `onsuccess` condition, so it is off on error
232 * responses too. `always` would put `max-age=31536000, immutable` on a
233 * 404, and minified asset URLs are deterministic (Minifier keys them on
234 * path + mtime), so a file that 404s in the window after a purge comes
235 * back at the URL a client has already cached the 404 for — for a year,
236 * with no revalidation.
237 */
238 public static function nginx_snippet( array $opts = array() ): string {
239 $asset = (int) ( $opts['asset_ttl'] ?? self::DEFAULT_ASSET_TTL );
240 $html = (int) ( $opts['html_ttl'] ?? self::DEFAULT_HTML_TTL );
241 // Same guard as apache_rules(): a negative TTL would otherwise
242 // reach the vhost as `max-age=-1`.
243 if ( $asset < 0 ) {
244 $asset = self::DEFAULT_ASSET_TTL;
245 }
246 if ( $html < 0 ) {
247 $html = self::DEFAULT_HTML_TTL;
248 }
249 return implode(
250 "\n",
251 array(
252 'location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff2|woff|ttf|otf|eot|mp4|webm|mp3|ogg)$ {',
253 ' expires off;',
254 ' add_header Cache-Control "public, max-age=' . $asset . ', immutable";',
255 '}',
256 'location ~* \.html$ {',
257 ' expires off;',
258 ' add_header Cache-Control "public, max-age=' . $html . '";',
259 '}',
260 )
261 );
262 }
263 }
264