PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.1
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
xspeed / includes / class-minifier.php

class-minifier.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.1, at includes/class-minifier.php

379 lines 14.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Asset minifier — HTML, CSS, JS.
4 *
5 * Uses matthiasmullie/minify for CSS/JS. Local enqueued assets are minified
6 * once, cached on disk, and the loader URL is rewritten to point at the
7 * cached file.
8 *
9 * @package XSpeed
10 */
11
12 namespace XSpeed;
13
14 defined( 'ABSPATH' ) || exit;
15
16 class Minifier {
17
18 const MIN_SUBDIR = 'min';
19
20 /**
21 * Absolute path to the minified-cache directory. Always derived from
22 * XSPEED_CACHE_DIR (the plugin's own cache root) — never assembled from
23 * arbitrary URL fragments.
24 */
25 public static function min_dir() {
26 return trailingslashit( XSPEED_CACHE_DIR ) . self::MIN_SUBDIR;
27 }
28
29 /**
30 * Public URL of the minified-cache directory. Built from content_url() +
31 * the known relative path, not by string-replacing WP_CONTENT_DIR out of
32 * a filesystem path (which would assume the filesystem layout matches
33 * the URL layout — it does not on Bedrock-style installs, multisite with
34 * mapped domains, or any setup with a relocated wp-content).
35 */
36 private static function min_url() {
37 // XSPEED_CACHE_DIR lives under wp-content (defined in xspeed.php as
38 // WP_CONTENT_DIR . '/cache/xspeed'), so the URL is content_url() +
39 // the known suffix. We do not derive URLs from arbitrary filesystem
40 // paths anywhere in this plugin.
41 $url = trailingslashit( content_url( 'cache/xspeed' ) ) . self::MIN_SUBDIR;
42 // Force the site's scheme: content_url() derives its scheme from
43 // is_ssl(), which is false behind a TLS-terminating reverse proxy, so
44 // it can emit an http:// URL on an https page — the browser then blocks
45 // the minified stylesheet as mixed content and the page renders
46 // unstyled. Match home_url()'s registered scheme instead. (FBS-83633)
47 $scheme = wp_parse_url( home_url(), PHP_URL_SCHEME ) ?: 'https';
48 return set_url_scheme( $url, $scheme );
49 }
50
51 public function __construct() {
52 // Only run on the frontend — never minify wp-admin, AJAX, REST or cron
53 // asset URLs. Page caching already handles the logged-in case for
54 // the HTML response; minify scope is the public frontend.
55 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
56 return;
57 }
58
59 // Settings now live in the per-module option (xspeed_module_minify),
60 // owned by XSpeed\Modules\Minify\MinifyModule. We read through
61 // Settings_Manager so schema-validated values are returned even
62 // if the option was hand-edited.
63 $opts = Settings_Manager::get( 'minify' );
64
65 if ( ! empty( $opts['minify_css'] ) ) {
66 add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 );
67 }
68 if ( ! empty( $opts['minify_js'] ) ) {
69 add_filter( 'script_loader_src', array( __CLASS__, 'rewrite_script' ), 10, 2 );
70 }
71
72 // Phase 4.1a — filter-only "smarter minifier" features. Each is
73 // gated on its own toggle so users can enable any subset.
74 if ( ! empty( $opts['remove_query_strings'] ) ) {
75 add_filter( 'style_loader_src', array( Minify_Filters::class, 'strip_version_query' ), 20 );
76 add_filter( 'script_loader_src', array( Minify_Filters::class, 'strip_version_query' ), 20 );
77 }
78 if ( ! empty( $opts['defer_js'] ) ) {
79 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'defer_script_tag' ), 20, 3 );
80 }
81 if ( ! empty( $opts['delay_js'] ) ) {
82 // Delay applies a transform that's mutually exclusive with
83 // plain defer — when both are on, delay wins (the bootstrap
84 // will re-attach as a regular <script> on interaction).
85 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'delay_script_tag' ), 30, 3 );
86 add_action( 'wp_footer', array( Minify_Filters::class, 'print_delay_bootstrap' ), 1000 );
87 }
88 if ( ! empty( $opts['async_css'] ) ) {
89 add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 );
90 }
91
92 // Phase 4.1b — combine engine. Hook late so every plugin /
93 // theme has finished enqueueing by the time we walk the queue.
94 // Priority 999 mirrors the WP-Optimize / Rocket convention.
95 if ( ! empty( $opts['combine_css'] ) ) {
96 add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_styles' ), 999 );
97 }
98 if ( ! empty( $opts['combine_js'] ) ) {
99 add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_scripts' ), 999 );
100 }
101 }
102
103 public static function minify_html( $html ) {
104 $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
105 if ( apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
106 return $html;
107 }
108
109 $placeholders = array();
110 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
111 $html = preg_replace_callback(
112 $pattern,
113 function ( $m ) use ( &$placeholders ) {
114 $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
115 $placeholders[ $key ] = $m[0];
116 return $key;
117 },
118 $html
119 );
120
121 $html = preg_replace( '/<!--(?!\[if).*?-->/s', '', $html );
122 $html = preg_replace( '/\s+/', ' ', $html );
123 $html = preg_replace( '/>\s+</', '><', $html );
124 $html = trim( $html );
125
126 foreach ( $placeholders as $key => $original ) {
127 $html = str_replace( $key, $original, $html );
128 }
129
130 return $html;
131 }
132
133 public static function rewrite_style( $src, $handle ) {
134 unset( $handle );
135 return self::rewrite_asset( $src, 'css' );
136 }
137
138 public static function rewrite_script( $src, $handle ) {
139 unset( $handle );
140 return self::rewrite_asset( $src, 'js' );
141 }
142
143 /**
144 * Replace a local CSS/JS URL with a cached, minified equivalent.
145 *
146 * @param string $src Original asset URL.
147 * @param string $type 'css' or 'js'.
148 * @return string Possibly rewritten URL.
149 */
150 private static function rewrite_asset( $src, $type ) {
151 if ( ! is_string( $src ) || '' === $src ) {
152 return $src;
153 }
154
155 // Skip already-minified files.
156 if ( false !== strpos( $src, '.min.' ) ) {
157 return $src;
158 }
159
160 // Skip anything we already produced. The Asset_Combiner writes a
161 // pre-minified combined-<hash>.css under min/combined/ and enqueues it
162 // as `xspeed-combined-css`; the per-file minifier used to re-minify
163 // that combined output into a SECOND file (min/<hash2>.css) with its
164 // own mtime-derived hash. The served HTML then pinned that second
165 // hash, so a purge/regeneration (which changes the combined file's
166 // mtime -> a new hash2) left the cached page pointing at a file that
167 // no longer existed -> 404 -> unstyled/broken frontend. Leaving our
168 // own cache output untouched keeps a single, stable URL end-to-end.
169 if ( false !== strpos( $src, '/cache/xspeed/' ) ) {
170 return $src;
171 }
172
173 // Resolve to a local path; bail if external or unresolvable.
174 $path = self::url_to_path( $src );
175 if ( ! $path || ! is_readable( $path ) ) {
176 return $src;
177 }
178
179 // Build a cache filename keyed on path + mtime so edits invalidate.
180 $mtime = filemtime( $path );
181 $key = md5( $path . '|' . $mtime );
182 $cache = self::cache_path( $key, $type );
183
184 if ( ! file_exists( $cache ) ) {
185 $ok = self::minify_file( $path, $cache, $type );
186 if ( ! $ok ) {
187 return $src;
188 }
189 }
190
191 // Return a URL to the cached file. Built from known constants — never
192 // from str_replace on a filesystem path (which would assume the FS
193 // layout mirrors the URL layout).
194 return self::min_url() . '/' . $key . '.' . $type;
195 }
196
197 private static function minify_file( $source_path, $target_path, $type ) {
198 if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
199 return false;
200 }
201
202 // Path-traversal guard: refuse to write anywhere outside our cache
203 // dir, even if a malicious filter ever produced a poisoned key.
204 $cache_root = self::min_dir();
205 self::ensure_dir( $cache_root );
206 $real_root = realpath( $cache_root );
207 $real_dir = realpath( dirname( $target_path ) );
208 if ( ! $real_root || ! $real_dir || 0 !== strpos( $real_dir, $real_root ) ) {
209 return false;
210 }
211
212 try {
213 if ( 'css' === $type ) {
214 // Passing the TARGET path makes matthiasmullie/minify rebase every
215 // relative url(...) / @import against the minified file's location.
216 // Without it, a stylesheet moved from e.g.
217 // .../font-awesome/css/all.css to cache/xspeed/min/<key>.css keeps
218 // its original url(../webfonts/…) — which then resolves against the
219 // cache dir and 404s (missing FontAwesome/eicons/WooCommerce fonts).
220 $minifier = new \MatthiasMullie\Minify\CSS( $source_path );
221 $minified = $minifier->minify( $target_path );
222 return '' !== $minified && file_exists( $target_path );
223 }
224
225 $minifier = new \MatthiasMullie\Minify\JS( $source_path );
226 $minified = $minifier->minify();
227
228 // Sanity check: paren/brace/bracket/backtick balance must be preserved.
229 // matthiasmullie/minify can silently truncate mid-template-literal on
230 // complex modern JS — bail rather than ship a broken file.
231 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders. Source already validated as readable on line 121.
232 $source = file_get_contents( $source_path );
233 if ( false === $source || ! self::balanced( $source, $minified ) ) {
234 return false;
235 }
236
237 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders.
238 $bytes = file_put_contents( $target_path, $minified );
239 return false !== $bytes && file_exists( $target_path );
240 } catch ( \Throwable $e ) {
241 return false;
242 }
243 }
244
245 /**
246 * Cheap structural sanity check between source + minified bodies.
247 *
248 * Counts paired-delimiter tokens (parens, braces, brackets, backticks)
249 * in each and bails when the counts disagree — matthiasmullie/minify
250 * has been observed to silently truncate inside template literals on
251 * complex modern JS (see commit history), shipping a body that LOOKS
252 * minified but is structurally broken and crashes the page at parse.
253 *
254 * Backticks are paired (open + close = same token), so the count
255 * itself must match exactly. Strings inside the source can contain
256 * literal `{` / `}` / `[` / `]` that throw off the count by the same
257 * amount in both bodies (since they survive minification as-is), so
258 * the equality check is robust to that noise.
259 */
260 private static function balanced( string $source, string $minified ): bool {
261 $pairs = array( '(', ')', '{', '}', '[', ']', '`' );
262 foreach ( $pairs as $token ) {
263 if ( substr_count( $source, $token ) !== substr_count( $minified, $token ) ) {
264 return false;
265 }
266 }
267 return true;
268 }
269
270 /**
271 * Resolve a local asset URL to a filesystem path using a strict allowlist
272 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
273 *
274 * We never assume `site_url()` maps to `ABSPATH` (the WordPress root can
275 * live above the document root in Bedrock-style installs, behind a proxy,
276 * or on multisite with mapped domains). Each branch resolves through a
277 * known WP API (plugins, themes, content, includes) and validates that
278 * `realpath()` of the result still lives under the expected base — so a
279 * crafted `..`-laden URL cannot escape into the filesystem.
280 *
281 * @param string $url Asset URL (may be protocol-relative or absolute).
282 * @return string|false Absolute filesystem path on success, false otherwise.
283 */
284 private static function url_to_path( $url ) {
285 if ( ! is_string( $url ) || '' === $url ) {
286 return false;
287 }
288
289 // Drop query string + fragment.
290 $clean = strtok( $url, '?#' );
291
292 // Normalise protocol-relative + scheme variants of the host so we
293 // match regardless of whether the asset URL came in over http/https.
294 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
295 if ( 0 === strpos( $clean, '//' ) ) {
296 $clean = 'https:' . $clean;
297 }
298 if ( $site_host ) {
299 $asset_host = wp_parse_url( $clean, PHP_URL_HOST );
300 if ( $asset_host && $asset_host !== $site_host ) {
301 return false; // External asset — never touch.
302 }
303 }
304
305 $candidates = array(
306 array( plugins_url(), WP_PLUGIN_DIR ),
307 array( get_stylesheet_directory_uri(), get_stylesheet_directory() ),
308 array( get_template_directory_uri(), get_template_directory() ),
309 array( content_url(), WP_CONTENT_DIR ),
310 array( includes_url(), ABSPATH . WPINC ),
311 );
312
313 foreach ( $candidates as $pair ) {
314 list( $url_base, $path_base ) = $pair;
315 if ( ! $url_base || ! $path_base ) {
316 continue;
317 }
318 $url_base = rtrim( $url_base, '/' );
319 if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
320 continue;
321 }
322
323 $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
324 $candidate = trailingslashit( $path_base ) . $relative;
325
326 $real_base = realpath( $path_base );
327 $real = realpath( $candidate );
328 if ( ! $real_base || ! $real ) {
329 return false;
330 }
331 // Guard against `..`-traversal: resolved path must stay inside
332 // the registered base.
333 if ( 0 !== strpos( $real, $real_base ) ) {
334 return false;
335 }
336 return $real;
337 }
338
339 return false;
340 }
341
342 private static function cache_path( $key, $type ) {
343 return self::min_dir() . '/' . $key . '.' . $type;
344 }
345
346 private static function ensure_dir( $dir ) {
347 if ( ! file_exists( $dir ) ) {
348 wp_mkdir_p( $dir );
349 Cache::write_silence( $dir );
350 }
351 }
352
353 public static function purge_minified() {
354 self::rmtree_files( self::min_dir() );
355 }
356
357 /**
358 * Recursively delete every file under $dir (and the emptied
359 * subdirectories), keeping $dir itself. The previous glob('$dir/*')
360 * was non-recursive and no-ops on directories, so combined assets in
361 * min/combined/ were never cleared — a purge left a stale
362 * combined-<hash>.css the regenerated page no longer referenced.
363 * (FBS-83114 / FBS-83116)
364 */
365 private static function rmtree_files( string $dir ): void {
366 if ( ! is_dir( $dir ) ) {
367 return;
368 }
369 foreach ( (array) glob( $dir . '/*' ) as $path ) {
370 if ( is_dir( $path ) ) {
371 self::rmtree_files( $path );
372 @rmdir( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup of our own cache subdir; WP_Filesystem is unavailable on the frontend purge path.
373 continue;
374 }
375 wp_delete_file( $path );
376 }
377 }
378 }
379