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

425 lines 16.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 /**
104 * HTML elements that participate in an inline formatting context, where
105 * whitespace between two of them renders as a visible space.
106 *
107 * Deliberately excludes <br> (nothing to separate) and replaced/embedded
108 * inline elements that sit alone. Anything not listed is treated as block
109 * level, where inter-tag whitespace collapses to nothing and is safe to
110 * strip. (FBS-84090)
111 */
112 private const INLINE_TAGS = array(
113 'a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'data', 'del', 'dfn',
114 'em', 'i', 'ins', 'kbd', 'label', 'mark', 'q', 'rp', 'rt', 'ruby',
115 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u',
116 'var', 'wbr', 'img', 'button', 'select', 'output',
117 );
118
119 /** True when $tag renders inline, so whitespace beside it is visible. */
120 private static function is_inline( string $tag ): bool {
121 return in_array( strtolower( $tag ), self::INLINE_TAGS, true );
122 }
123
124 public static function minify_html( $html ) {
125 $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
126 if ( apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
127 return $html;
128 }
129
130 $placeholders = array();
131 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
132 $html = preg_replace_callback(
133 $pattern,
134 function ( $m ) use ( &$placeholders ) {
135 $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
136 $placeholders[ $key ] = $m[0];
137 return $key;
138 },
139 $html
140 );
141
142 $html = preg_replace( '/<!--(?!\[if).*?-->/s', '', $html );
143 $html = preg_replace( '/\s+/', ' ', $html );
144
145 /*
146 * Collapse whitespace BETWEEN TAGS — but never where it is visible.
147 *
148 * Whitespace separating two INLINE elements is a real, rendered space:
149 * WooCommerce emits `</del> <ins>` for a sale price, and that single
150 * character is the gap between "$32.50" and "$29.50". Stripping it
151 * printed "$32.50$29.50" run together, and only with cache on — the
152 * un-minified page was fine. (FBS-84090)
153 *
154 * So the strip only applies when at least one side is a BLOCK-level
155 * (or non-rendered) tag, where the whitespace collapses away anyway.
156 * Inline-to-inline boundaries keep their single space.
157 */
158 $html = preg_replace_callback(
159 // left tag name (may be a closing tag) … whitespace … right tag name
160 '#</?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>\s+<(/?)([a-zA-Z][a-zA-Z0-9-]*)#',
161 static function ( $m ) {
162 // Keep the space only when BOTH sides are inline elements —
163 // that is the one case where it is actually rendered.
164 $keep = self::is_inline( $m[1] ) && self::is_inline( $m[3] );
165 $open = substr( $m[0], 0, strrpos( $m[0], '<' ) ); // through the left tag's '>'
166 return rtrim( $open ) . ( $keep ? ' ' : '' ) . '<' . $m[2] . $m[3];
167 },
168 $html
169 );
170 $html = trim( $html );
171
172 foreach ( $placeholders as $key => $original ) {
173 $html = str_replace( $key, $original, $html );
174 }
175
176 return $html;
177 }
178
179 public static function rewrite_style( $src, $handle ) {
180 unset( $handle );
181 return self::rewrite_asset( $src, 'css' );
182 }
183
184 public static function rewrite_script( $src, $handle ) {
185 unset( $handle );
186 return self::rewrite_asset( $src, 'js' );
187 }
188
189 /**
190 * Replace a local CSS/JS URL with a cached, minified equivalent.
191 *
192 * @param string $src Original asset URL.
193 * @param string $type 'css' or 'js'.
194 * @return string Possibly rewritten URL.
195 */
196 private static function rewrite_asset( $src, $type ) {
197 if ( ! is_string( $src ) || '' === $src ) {
198 return $src;
199 }
200
201 // Skip already-minified files.
202 if ( false !== strpos( $src, '.min.' ) ) {
203 return $src;
204 }
205
206 // Skip anything we already produced. The Asset_Combiner writes a
207 // pre-minified combined-<hash>.css under min/combined/ and enqueues it
208 // as `xspeed-combined-css`; the per-file minifier used to re-minify
209 // that combined output into a SECOND file (min/<hash2>.css) with its
210 // own mtime-derived hash. The served HTML then pinned that second
211 // hash, so a purge/regeneration (which changes the combined file's
212 // mtime -> a new hash2) left the cached page pointing at a file that
213 // no longer existed -> 404 -> unstyled/broken frontend. Leaving our
214 // own cache output untouched keeps a single, stable URL end-to-end.
215 if ( false !== strpos( $src, '/cache/xspeed/' ) ) {
216 return $src;
217 }
218
219 // Resolve to a local path; bail if external or unresolvable.
220 $path = self::url_to_path( $src );
221 if ( ! $path || ! is_readable( $path ) ) {
222 return $src;
223 }
224
225 // Build a cache filename keyed on path + mtime so edits invalidate.
226 $mtime = filemtime( $path );
227 $key = md5( $path . '|' . $mtime );
228 $cache = self::cache_path( $key, $type );
229
230 if ( ! file_exists( $cache ) ) {
231 $ok = self::minify_file( $path, $cache, $type );
232 if ( ! $ok ) {
233 return $src;
234 }
235 }
236
237 // Return a URL to the cached file. Built from known constants — never
238 // from str_replace on a filesystem path (which would assume the FS
239 // layout mirrors the URL layout).
240 return self::min_url() . '/' . $key . '.' . $type;
241 }
242
243 private static function minify_file( $source_path, $target_path, $type ) {
244 if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
245 return false;
246 }
247
248 // Path-traversal guard: refuse to write anywhere outside our cache
249 // dir, even if a malicious filter ever produced a poisoned key.
250 $cache_root = self::min_dir();
251 self::ensure_dir( $cache_root );
252 $real_root = realpath( $cache_root );
253 $real_dir = realpath( dirname( $target_path ) );
254 if ( ! $real_root || ! $real_dir || 0 !== strpos( $real_dir, $real_root ) ) {
255 return false;
256 }
257
258 try {
259 if ( 'css' === $type ) {
260 // Passing the TARGET path makes matthiasmullie/minify rebase every
261 // relative url(...) / @import against the minified file's location.
262 // Without it, a stylesheet moved from e.g.
263 // .../font-awesome/css/all.css to cache/xspeed/min/<key>.css keeps
264 // its original url(../webfonts/…) — which then resolves against the
265 // cache dir and 404s (missing FontAwesome/eicons/WooCommerce fonts).
266 $minifier = new \MatthiasMullie\Minify\CSS( $source_path );
267 $minified = $minifier->minify( $target_path );
268 return '' !== $minified && file_exists( $target_path );
269 }
270
271 $minifier = new \MatthiasMullie\Minify\JS( $source_path );
272 $minified = $minifier->minify();
273
274 // Sanity check: paren/brace/bracket/backtick balance must be preserved.
275 // matthiasmullie/minify can silently truncate mid-template-literal on
276 // complex modern JS — bail rather than ship a broken file.
277 // 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.
278 $source = file_get_contents( $source_path );
279 if ( false === $source || ! self::balanced( $source, $minified ) ) {
280 return false;
281 }
282
283 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders.
284 $bytes = file_put_contents( $target_path, $minified );
285 return false !== $bytes && file_exists( $target_path );
286 } catch ( \Throwable $e ) {
287 return false;
288 }
289 }
290
291 /**
292 * Cheap structural sanity check between source + minified bodies.
293 *
294 * Counts paired-delimiter tokens (parens, braces, brackets, backticks)
295 * in each and bails when the counts disagree — matthiasmullie/minify
296 * has been observed to silently truncate inside template literals on
297 * complex modern JS (see commit history), shipping a body that LOOKS
298 * minified but is structurally broken and crashes the page at parse.
299 *
300 * Backticks are paired (open + close = same token), so the count
301 * itself must match exactly. Strings inside the source can contain
302 * literal `{` / `}` / `[` / `]` that throw off the count by the same
303 * amount in both bodies (since they survive minification as-is), so
304 * the equality check is robust to that noise.
305 */
306 private static function balanced( string $source, string $minified ): bool {
307 $pairs = array( '(', ')', '{', '}', '[', ']', '`' );
308 foreach ( $pairs as $token ) {
309 if ( substr_count( $source, $token ) !== substr_count( $minified, $token ) ) {
310 return false;
311 }
312 }
313 return true;
314 }
315
316 /**
317 * Resolve a local asset URL to a filesystem path using a strict allowlist
318 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
319 *
320 * We never assume `site_url()` maps to `ABSPATH` (the WordPress root can
321 * live above the document root in Bedrock-style installs, behind a proxy,
322 * or on multisite with mapped domains). Each branch resolves through a
323 * known WP API (plugins, themes, content, includes) and validates that
324 * `realpath()` of the result still lives under the expected base — so a
325 * crafted `..`-laden URL cannot escape into the filesystem.
326 *
327 * @param string $url Asset URL (may be protocol-relative or absolute).
328 * @return string|false Absolute filesystem path on success, false otherwise.
329 */
330 private static function url_to_path( $url ) {
331 if ( ! is_string( $url ) || '' === $url ) {
332 return false;
333 }
334
335 // Drop query string + fragment.
336 $clean = strtok( $url, '?#' );
337
338 // Normalise protocol-relative + scheme variants of the host so we
339 // match regardless of whether the asset URL came in over http/https.
340 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
341 if ( 0 === strpos( $clean, '//' ) ) {
342 $clean = 'https:' . $clean;
343 }
344 if ( $site_host ) {
345 $asset_host = wp_parse_url( $clean, PHP_URL_HOST );
346 if ( $asset_host && $asset_host !== $site_host ) {
347 return false; // External asset — never touch.
348 }
349 }
350
351 $candidates = array(
352 array( plugins_url(), WP_PLUGIN_DIR ),
353 array( get_stylesheet_directory_uri(), get_stylesheet_directory() ),
354 array( get_template_directory_uri(), get_template_directory() ),
355 array( content_url(), WP_CONTENT_DIR ),
356 array( includes_url(), ABSPATH . WPINC ),
357 );
358
359 foreach ( $candidates as $pair ) {
360 list( $url_base, $path_base ) = $pair;
361 if ( ! $url_base || ! $path_base ) {
362 continue;
363 }
364 $url_base = rtrim( $url_base, '/' );
365 if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
366 continue;
367 }
368
369 $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
370 $candidate = trailingslashit( $path_base ) . $relative;
371
372 $real_base = realpath( $path_base );
373 $real = realpath( $candidate );
374 if ( ! $real_base || ! $real ) {
375 return false;
376 }
377 // Guard against `..`-traversal: resolved path must stay inside
378 // the registered base.
379 if ( 0 !== strpos( $real, $real_base ) ) {
380 return false;
381 }
382 return $real;
383 }
384
385 return false;
386 }
387
388 private static function cache_path( $key, $type ) {
389 return self::min_dir() . '/' . $key . '.' . $type;
390 }
391
392 private static function ensure_dir( $dir ) {
393 if ( ! file_exists( $dir ) ) {
394 wp_mkdir_p( $dir );
395 Cache::write_silence( $dir );
396 }
397 }
398
399 public static function purge_minified() {
400 self::rmtree_files( self::min_dir() );
401 }
402
403 /**
404 * Recursively delete every file under $dir (and the emptied
405 * subdirectories), keeping $dir itself. The previous glob('$dir/*')
406 * was non-recursive and no-ops on directories, so combined assets in
407 * min/combined/ were never cleared — a purge left a stale
408 * combined-<hash>.css the regenerated page no longer referenced.
409 * (FBS-83114 / FBS-83116)
410 */
411 private static function rmtree_files( string $dir ): void {
412 if ( ! is_dir( $dir ) ) {
413 return;
414 }
415 foreach ( (array) glob( $dir . '/*' ) as $path ) {
416 if ( is_dir( $path ) ) {
417 self::rmtree_files( $path );
418 @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.
419 continue;
420 }
421 wp_delete_file( $path );
422 }
423 }
424 }
425