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

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

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