PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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
← All changes | includes/class-browser-cache.php +151 -5 1.0.21.3.3 View file →
@@ -1,9 +1,10 @@
1 1 <?php
2 2 /**
3 3 * Browser_Cache — writes/removes browser-cache directives in the site
4 4 * root .htaccess so static assets get long Cache-Control + Expires
5 - * headers, and serves an nginx snippet for non-Apache hosts.
5 + * headers, and serves an nginx snippet for non-Apache hosts (that one
6 + * sends Cache-Control only — see nginx_snippet()).
6 7 *
7 8 * Same shape as Gzip: marker block, insert_with_markers, snippet
8 9 * fallback. Independent toggle so users can enable browser caching
9 10 * without compression and vice versa.
@@ -93,23 +94,168 @@
93 94 );
94 95 }
95 96
96 97 /**
97 - * nginx snippet — uses `expires` directive (the canonical nginx way)
98 - * plus an `add_header` line for the immutable flag.
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
99 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 + */
100 238 public static function nginx_snippet( array $opts = array() ): string {
101 239 $asset = (int) ( $opts['asset_ttl'] ?? self::DEFAULT_ASSET_TTL );
102 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 + }
103 249 return implode(
104 250 "\n",
105 251 array(
106 252 'location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff2|woff|ttf|otf|eot|mp4|webm|mp3|ogg)$ {',
107 - ' expires ' . $asset . 's;',
253 + ' expires off;',
108 254 ' add_header Cache-Control "public, max-age=' . $asset . ', immutable";',
109 255 '}',
110 256 'location ~* \.html$ {',
111 - ' expires ' . $html . 's;',
257 + ' expires off;',
112 258 ' add_header Cache-Control "public, max-age=' . $html . '";',
113 259 '}',
114 260 )
115 261 );