| @@ -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,27 +94,43 @@ | ||
| 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. | |
| 99 | - */ | |
| 100 | - /** | |
| 101 | - * Probe whether the server is actually emitting the Cache-Control | |
| 102 | - * headers our nginx snippet is supposed to add. Picks a recognisable | |
| 103 | - * static asset (anything in `wp-includes/css/` is always served on | |
| 104 | - * a WP install) and HEADs it, looking for `immutable` in the response | |
| 105 | - * — the unique fingerprint our snippet adds that WP core's defaults | |
| 106 | - * never set. Cached in a 5-minute transient so this never adds | |
| 107 | - * latency to the dashboard. | |
| 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. | |
| 108 | 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 | + * | |
| 109 | 114 | * Returns: |
| 110 | - * true → headers present, no notice needed | |
| 111 | - * false → headers absent, snippet hasn't been pasted yet (or hasn't | |
| 112 | - * been reloaded into nginx), keep the notice up | |
| 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 | |
| 113 | 127 | */ |
| 114 | - public static function probe_headers_present(): bool { | |
| 128 | + public static function probe_headers_present() { | |
| 115 | 129 | $cached = get_transient( 'xspeed_browser_cache_probe' ); |
| 130 | + if ( '?' === $cached ) { | |
| 131 | + return null; | |
| 132 | + } | |
| 116 | 133 | if ( null !== $cached && false !== $cached ) { |
| 117 | 134 | return (bool) $cached; |
| 118 | 135 | } |
| 119 | 136 | |
| @@ -126,11 +143,11 @@ | ||
| 126 | 143 | 'redirection' => 0, |
| 127 | 144 | 'headers' => array( 'Cache-Control' => 'no-cache' ), |
| 128 | 145 | ) |
| 129 | 146 | ); |
| 130 | - if ( is_wp_error( $resp ) ) { | |
| 131 | - set_transient( 'xspeed_browser_cache_probe', 0, MINUTE_IN_SECONDS ); | |
| 132 | - return false; | |
| 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; | |
| 133 | 150 | } |
| 134 | 151 | // wp_remote_retrieve_header() returns a STRING for a single header |
| 135 | 152 | // but an ARRAY when the header appears more than once (common behind |
| 136 | 153 | // CDNs / proxies, or nginx with multiple add_header lines). Casting |
| @@ -139,25 +156,106 @@ | ||
| 139 | 156 | // check below silently false-negatives. Normalize array → string |
| 140 | 157 | // first. (FBS-82141) |
| 141 | 158 | $raw = wp_remote_retrieve_header( $resp, 'cache-control' ); |
| 142 | 159 | $cache_control = is_array( $raw ) ? implode( ', ', $raw ) : (string) $raw; |
| 143 | - $active = false !== stripos( $cache_control, 'immutable' ); | |
| 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 ); | |
| 144 | 164 | set_transient( 'xspeed_browser_cache_probe', $active ? 1 : 0, 5 * MINUTE_IN_SECONDS ); |
| 145 | 165 | return $active; |
| 146 | 166 | } |
| 147 | 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 | + */ | |
| 148 | 238 | public static function nginx_snippet( array $opts = array() ): string { |
| 149 | 239 | $asset = (int) ( $opts['asset_ttl'] ?? self::DEFAULT_ASSET_TTL ); |
| 150 | 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 | + } | |
| 151 | 249 | return implode( |
| 152 | 250 | "\n", |
| 153 | 251 | array( |
| 154 | 252 | 'location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff2|woff|ttf|otf|eot|mp4|webm|mp3|ogg)$ {', |
| 155 | - ' expires ' . $asset . 's;', | |
| 253 | + ' expires off;', | |
| 156 | 254 | ' add_header Cache-Control "public, max-age=' . $asset . ', immutable";', |
| 157 | 255 | '}', |
| 158 | 256 | 'location ~* \.html$ {', |
| 159 | - ' expires ' . $html . 's;', | |
| 257 | + ' expires off;', | |
| 160 | 258 | ' add_header Cache-Control "public, max-age=' . $html . '";', |
| 161 | 259 | '}', |
| 162 | 260 | ) |
| 163 | 261 | ); |