| 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 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. |
| 108 |
* |
| 109 |
* 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 |
| 113 |
*/ |
| 114 |
public static function probe_headers_present(): bool { |
| 115 |
$cached = get_transient( 'xspeed_browser_cache_probe' ); |
| 116 |
if ( null !== $cached && false !== $cached ) { |
| 117 |
return (bool) $cached; |
| 118 |
} |
| 119 |
|
| 120 |
$asset_url = includes_url( 'css/dashicons.min.css' ); |
| 121 |
$resp = wp_remote_head( |
| 122 |
$asset_url, |
| 123 |
array( |
| 124 |
'timeout' => 3, |
| 125 |
'sslverify' => false, |
| 126 |
'redirection' => 0, |
| 127 |
'headers' => array( 'Cache-Control' => 'no-cache' ), |
| 128 |
) |
| 129 |
); |
| 130 |
if ( is_wp_error( $resp ) ) { |
| 131 |
set_transient( 'xspeed_browser_cache_probe', 0, MINUTE_IN_SECONDS ); |
| 132 |
return false; |
| 133 |
} |
| 134 |
// wp_remote_retrieve_header() returns a STRING for a single header |
| 135 |
// but an ARRAY when the header appears more than once (common behind |
| 136 |
// CDNs / proxies, or nginx with multiple add_header lines). Casting |
| 137 |
// an array with (string) emits an "Array to string conversion" |
| 138 |
// warning AND flattens to the literal "Array", so the immutable |
| 139 |
// check below silently false-negatives. Normalize array → string |
| 140 |
// first. (FBS-82141) |
| 141 |
$raw = wp_remote_retrieve_header( $resp, 'cache-control' ); |
| 142 |
$cache_control = is_array( $raw ) ? implode( ', ', $raw ) : (string) $raw; |
| 143 |
$active = false !== stripos( $cache_control, 'immutable' ); |
| 144 |
set_transient( 'xspeed_browser_cache_probe', $active ? 1 : 0, 5 * MINUTE_IN_SECONDS ); |
| 145 |
return $active; |
| 146 |
} |
| 147 |
|
| 148 |
public static function nginx_snippet( array $opts = array() ): string { |
| 149 |
$asset = (int) ( $opts['asset_ttl'] ?? self::DEFAULT_ASSET_TTL ); |
| 150 |
$html = (int) ( $opts['html_ttl'] ?? self::DEFAULT_HTML_TTL ); |
| 151 |
return implode( |
| 152 |
"\n", |
| 153 |
array( |
| 154 |
'location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff2|woff|ttf|otf|eot|mp4|webm|mp3|ogg)$ {', |
| 155 |
' expires ' . $asset . 's;', |
| 156 |
' add_header Cache-Control "public, max-age=' . $asset . ', immutable";', |
| 157 |
'}', |
| 158 |
'location ~* \.html$ {', |
| 159 |
' expires ' . $html . 's;', |
| 160 |
' add_header Cache-Control "public, max-age=' . $html . '";', |
| 161 |
'}', |
| 162 |
) |
| 163 |
); |
| 164 |
} |
| 165 |
} |
| 166 |
|