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

334 lines 11.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 private 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 // Resolve to a local path; bail if external or unresolvable.
154 $path = self::url_to_path( $src );
155 if ( ! $path || ! is_readable( $path ) ) {
156 return $src;
157 }
158
159 // Build a cache filename keyed on path + mtime so edits invalidate.
160 $mtime = filemtime( $path );
161 $key = md5( $path . '|' . $mtime );
162 $cache = self::cache_path( $key, $type );
163
164 if ( ! file_exists( $cache ) ) {
165 $ok = self::minify_file( $path, $cache, $type );
166 if ( ! $ok ) {
167 return $src;
168 }
169 }
170
171 // Return a URL to the cached file. Built from known constants — never
172 // from str_replace on a filesystem path (which would assume the FS
173 // layout mirrors the URL layout).
174 return self::min_url() . '/' . $key . '.' . $type;
175 }
176
177 private static function minify_file( $source_path, $target_path, $type ) {
178 if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
179 return false;
180 }
181
182 // Path-traversal guard: refuse to write anywhere outside our cache
183 // dir, even if a malicious filter ever produced a poisoned key.
184 $cache_root = self::min_dir();
185 self::ensure_dir( $cache_root );
186 $real_root = realpath( $cache_root );
187 $real_dir = realpath( dirname( $target_path ) );
188 if ( ! $real_root || ! $real_dir || 0 !== strpos( $real_dir, $real_root ) ) {
189 return false;
190 }
191
192 try {
193 $minifier = ( 'css' === $type )
194 ? new \MatthiasMullie\Minify\CSS( $source_path )
195 : new \MatthiasMullie\Minify\JS( $source_path );
196
197 $minified = $minifier->minify();
198
199 // Sanity check: paren/brace/bracket/backtick balance must be preserved.
200 // matthiasmullie/minify can silently truncate mid-template-literal on
201 // complex modern JS — bail rather than ship a broken file.
202 // 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.
203 $source = file_get_contents( $source_path );
204 if ( false === $source || ! self::balanced( $source, $minified ) ) {
205 return false;
206 }
207
208 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders.
209 $bytes = file_put_contents( $target_path, $minified );
210 return false !== $bytes && file_exists( $target_path );
211 } catch ( \Throwable $e ) {
212 return false;
213 }
214 }
215
216 /**
217 * Cheap structural sanity check between source + minified bodies.
218 *
219 * Counts paired-delimiter tokens (parens, braces, brackets, backticks)
220 * in each and bails when the counts disagree — matthiasmullie/minify
221 * has been observed to silently truncate inside template literals on
222 * complex modern JS (see commit history), shipping a body that LOOKS
223 * minified but is structurally broken and crashes the page at parse.
224 *
225 * Backticks are paired (open + close = same token), so the count
226 * itself must match exactly. Strings inside the source can contain
227 * literal `{` / `}` / `[` / `]` that throw off the count by the same
228 * amount in both bodies (since they survive minification as-is), so
229 * the equality check is robust to that noise.
230 */
231 private static function balanced( string $source, string $minified ): bool {
232 $pairs = array( '(', ')', '{', '}', '[', ']', '`' );
233 foreach ( $pairs as $token ) {
234 if ( substr_count( $source, $token ) !== substr_count( $minified, $token ) ) {
235 return false;
236 }
237 }
238 return true;
239 }
240
241 /**
242 * Resolve a local asset URL to a filesystem path using a strict allowlist
243 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
244 *
245 * We never assume `site_url()` maps to `ABSPATH` (the WordPress root can
246 * live above the document root in Bedrock-style installs, behind a proxy,
247 * or on multisite with mapped domains). Each branch resolves through a
248 * known WP API (plugins, themes, content, includes) and validates that
249 * `realpath()` of the result still lives under the expected base — so a
250 * crafted `..`-laden URL cannot escape into the filesystem.
251 *
252 * @param string $url Asset URL (may be protocol-relative or absolute).
253 * @return string|false Absolute filesystem path on success, false otherwise.
254 */
255 private static function url_to_path( $url ) {
256 if ( ! is_string( $url ) || '' === $url ) {
257 return false;
258 }
259
260 // Drop query string + fragment.
261 $clean = strtok( $url, '?#' );
262
263 // Normalise protocol-relative + scheme variants of the host so we
264 // match regardless of whether the asset URL came in over http/https.
265 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
266 if ( 0 === strpos( $clean, '//' ) ) {
267 $clean = 'https:' . $clean;
268 }
269 if ( $site_host ) {
270 $asset_host = wp_parse_url( $clean, PHP_URL_HOST );
271 if ( $asset_host && $asset_host !== $site_host ) {
272 return false; // External asset — never touch.
273 }
274 }
275
276 $candidates = array(
277 array( plugins_url(), WP_PLUGIN_DIR ),
278 array( get_stylesheet_directory_uri(), get_stylesheet_directory() ),
279 array( get_template_directory_uri(), get_template_directory() ),
280 array( content_url(), WP_CONTENT_DIR ),
281 array( includes_url(), ABSPATH . WPINC ),
282 );
283
284 foreach ( $candidates as $pair ) {
285 list( $url_base, $path_base ) = $pair;
286 if ( ! $url_base || ! $path_base ) {
287 continue;
288 }
289 $url_base = rtrim( $url_base, '/' );
290 if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
291 continue;
292 }
293
294 $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
295 $candidate = trailingslashit( $path_base ) . $relative;
296
297 $real_base = realpath( $path_base );
298 $real = realpath( $candidate );
299 if ( ! $real_base || ! $real ) {
300 return false;
301 }
302 // Guard against `..`-traversal: resolved path must stay inside
303 // the registered base.
304 if ( 0 !== strpos( $real, $real_base ) ) {
305 return false;
306 }
307 return $real;
308 }
309
310 return false;
311 }
312
313 private static function cache_path( $key, $type ) {
314 return self::min_dir() . '/' . $key . '.' . $type;
315 }
316
317 private static function ensure_dir( $dir ) {
318 if ( ! file_exists( $dir ) ) {
319 wp_mkdir_p( $dir );
320 Cache::write_silence( $dir );
321 }
322 }
323
324 public static function purge_minified() {
325 $dir = self::min_dir();
326 if ( ! is_dir( $dir ) ) {
327 return;
328 }
329 foreach ( glob( $dir . '/*' ) as $file ) {
330 wp_delete_file( $file );
331 }
332 }
333 }
334