PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.7
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 1.2.0 All 28 releases
xspeed / includes / class-minifier.php

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

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