PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.2 1.2.3
xspeed / includes / class-browser-cache.php

class-browser-cache.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, 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