| 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 |
|