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

637 lines 24.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 $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 // A page-builder editing screen is a front-end URL, so none of the
60 // guards above catch it. Optimizing it breaks the editor outright --
61 // Combine JS reorders the builder's own dependency graph and the
62 // toolbar never renders. There is no speed to win on a logged-in,
63 // uncacheable editing request anyway. (#281)
64 if ( Builder_Editor::is_active() ) {
65 return;
66 }
67
68 // Settings now live in the per-module option (xspeed_module_minify),
69 // owned by XSpeed\Modules\Minify\MinifyModule. We read through
70 // Settings_Manager so schema-validated values are returned even
71 // if the option was hand-edited.
72 $opts = Settings_Manager::get( 'minify' );
73
74 if ( ! empty( $opts['minify_css'] ) ) {
75 add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 );
76 }
77 if ( ! empty( $opts['minify_js'] ) ) {
78 add_filter( 'script_loader_src', array( __CLASS__, 'rewrite_script' ), 10, 2 );
79 }
80
81 // Phase 4.1a — filter-only "smarter minifier" features. Each is
82 // gated on its own toggle so users can enable any subset.
83 if ( ! empty( $opts['remove_query_strings'] ) ) {
84 add_filter( 'style_loader_src', array( Minify_Filters::class, 'strip_version_query' ), 20 );
85 add_filter( 'script_loader_src', array( Minify_Filters::class, 'strip_version_query' ), 20 );
86 }
87 if ( ! empty( $opts['defer_js'] ) ) {
88 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'defer_script_tag' ), 20, 3 );
89 }
90 if ( ! empty( $opts['delay_js'] ) ) {
91 // Delay applies a transform that's mutually exclusive with
92 // plain defer — when both are on, delay wins (the bootstrap
93 // will re-attach as a regular <script> on interaction).
94 add_filter( 'script_loader_tag', array( Minify_Filters::class, 'delay_script_tag' ), 30, 3 );
95 add_action( 'wp_footer', array( Minify_Filters::class, 'print_delay_bootstrap' ), 1000 );
96 // script_loader_tag only fires for wp_enqueue_script()'d assets.
97 // Analytics / pixel / chat-widget tags printed straight into
98 // wp_head bypass it, and those are usually the heaviest scripts
99 // on the page — so sweep the finished buffer too. Runs before
100 // minify_html (same filter, default priority) and is baked into
101 // the cache file, so it replays on static hits where PHP never
102 // boots.
103 add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_raw_script_tags' ), 20 );
104 }
105 if ( ! empty( $opts['async_css'] ) ) {
106 add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 );
107 }
108
109 /*
110 * CSS combining runs on the FINISHED HTML, not the enqueue queue.
111 *
112 * The queue-walking version could not be made correct: whatever it
113 * wrote at priority 999, WordPress edited afterwards. Core's
114 * wp_maybe_inline_styles() inlines any queued handle carrying a `path`
115 * and sets src=false on it, which silently threw away the combined URL
116 * and took the sheets we had blanked with it — six stylesheets became
117 * one and the site rendered unstyled. See Css_Combine_Buffer's header
118 * for the full trace. (#195)
119 *
120 * Two entry points, because the page cache's filter is not always
121 * available: `xspeed_cache_final_html` fires only on a cacheable MISS,
122 * so on a site with the cache off — or on an excluded URL like /cart —
123 * combining would silently stop working. Css_Combine_Buffer::boot()
124 * opens its own buffer in exactly those cases and no-ops otherwise, so
125 * the page is transformed once either way.
126 */
127 if ( ! empty( $opts['combine_css'] ) ) {
128 add_filter( 'xspeed_cache_final_html', array( Css_Combine_Buffer::class, 'process' ), 5 );
129 Css_Combine_Buffer::boot();
130 }
131 if ( ! empty( $opts['combine_js'] ) ) {
132 // JS stays on the enqueue path for now: dependency order,
133 // async/defer and wp_add_inline_script make it a different
134 // problem, and the reported break is CSS-only. Moving it is worth
135 // its own change rather than doubling the blast radius here.
136 add_action( 'wp_enqueue_scripts', array( Asset_Combiner::class, 'combine_scripts' ), 999 );
137 }
138 }
139
140 /**
141 * HTML elements that participate in an inline formatting context, where
142 * whitespace between two of them renders as a visible space.
143 *
144 * Deliberately excludes <br> (nothing to separate) and replaced/embedded
145 * inline elements that sit alone. Anything not listed is treated as block
146 * level, where inter-tag whitespace collapses to nothing and is safe to
147 * strip. (FBS-84090)
148 */
149 private const INLINE_TAGS = array(
150 'a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'data', 'del', 'dfn',
151 'em', 'i', 'ins', 'kbd', 'label', 'mark', 'q', 'rp', 'rt', 'ruby',
152 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u',
153 'var', 'wbr', 'img', 'button', 'select', 'output',
154 );
155
156 /** True when $tag renders inline, so whitespace beside it is visible. */
157 private static function is_inline( string $tag ): bool {
158 return in_array( strtolower( $tag ), self::INLINE_TAGS, true );
159 }
160
161 /**
162 * Why minification is being skipped, when it is. '' when it will run.
163 *
164 * minify_html can read "on" in every settings surface while producing
165 * byte-identical HTML, because the guard below silently returns the
166 * input. Field report: a live site showed `minify_html: on` with 3,856
167 * indented lines in the delivered HTML and nothing anywhere explaining
168 * the contradiction — the setting looked broken rather than suppressed.
169 * Callers that report status MUST consult this so the refusal is
170 * visible. (Same class as Cache::static_rewrite_block_reason().)
171 *
172 * @return string 'wp_debug', 'filter', or ''.
173 */
174 public static function skip_reason(): string {
175 $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
176 if ( ! apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
177 return '';
178 }
179 // Distinguish the built-in WP_DEBUG rule from a third party
180 // filtering the escape hatch — the fixes are different.
181 return $debug_skip ? 'wp_debug' : 'filter';
182 }
183
184 public static function minify_html( $html ) {
185 if ( '' !== self::skip_reason() ) {
186 return $html;
187 }
188
189 $placeholders = array();
190 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
191 $html = preg_replace_callback(
192 $pattern,
193 function ( $m ) use ( &$placeholders ) {
194 $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
195 // The placeholder pass exists to protect content whose
196 // whitespace is significant (<pre>, <textarea>) and to keep
197 // the tag-boundary regex off script bodies. <style> and
198 // <script> were grouped in with them, so protection became a
199 // permanent exemption: on builder sites where most CSS is
200 // inline, a page with minify ON shipped fully indented. Minify
201 // the BODY here, before it's stashed, so the outer passes
202 // still never see it. (#2)
203 $placeholders[ $key ] = self::minify_inline_block( $m[0], strtolower( $m[1] ) );
204 return $key;
205 },
206 $html
207 );
208
209 $html = preg_replace( '/<!--(?!\[if).*?-->/s', '', $html );
210 $html = preg_replace( '/\s+/', ' ', $html );
211
212 /*
213 * Collapse whitespace BETWEEN TAGS — but never where it is visible.
214 *
215 * Whitespace separating two INLINE elements is a real, rendered space:
216 * WooCommerce emits `</del> <ins>` for a sale price, and that single
217 * character is the gap between "$32.50" and "$29.50". Stripping it
218 * printed "$32.50$29.50" run together, and only with cache on — the
219 * un-minified page was fine. (FBS-84090)
220 *
221 * So the strip only applies when at least one side is a BLOCK-level
222 * (or non-rendered) tag, where the whitespace collapses away anyway.
223 * Inline-to-inline boundaries keep their single space.
224 */
225 $html = preg_replace_callback(
226 // left tag name (may be a closing tag) … whitespace … right tag name
227 '#</?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>\s+<(/?)([a-zA-Z][a-zA-Z0-9-]*)#',
228 static function ( $m ) {
229 // Keep the space only when BOTH sides are inline elements —
230 // that is the one case where it is actually rendered.
231 $keep = self::is_inline( $m[1] ) && self::is_inline( $m[3] );
232 $open = substr( $m[0], 0, strrpos( $m[0], '<' ) ); // through the left tag's '>'
233 return rtrim( $open ) . ( $keep ? ' ' : '' ) . '<' . $m[2] . $m[3];
234 },
235 $html
236 );
237 $html = trim( $html );
238
239 foreach ( $placeholders as $key => $original ) {
240 $html = str_replace( $key, $original, $html );
241 }
242
243 return $html;
244 }
245
246 public static function rewrite_style( $src, $handle ) {
247 unset( $handle );
248 return self::rewrite_asset( $src, 'css' );
249 }
250
251 public static function rewrite_script( $src, $handle ) {
252 $rewritten = self::rewrite_asset( $src, 'js' );
253
254 // Remember the pre-minify URL for this handle. script_loader_tag
255 // runs later and only ever sees the rewritten src (a hashed
256 // /cache/xspeed/min/<key>.js path), so a user's URL-substring
257 // delay/exclusion target would never match once minification is
258 // on. Minify_Filters::original_src() gives those checks the URL
259 // the user actually wrote their target against. (FBS field report)
260 if ( is_string( $handle ) && '' !== $handle && is_string( $src ) && $src !== $rewritten ) {
261 Minify_Filters::remember_original_src( $handle, $src );
262 }
263
264 return $rewritten;
265 }
266
267 /**
268 * Replace a local CSS/JS URL with a cached, minified equivalent.
269 *
270 * @param string $src Original asset URL.
271 * @param string $type 'css' or 'js'.
272 * @return string Possibly rewritten URL.
273 */
274 private static function rewrite_asset( $src, $type ) {
275 if ( ! is_string( $src ) || '' === $src ) {
276 return $src;
277 }
278
279 // Skip already-minified files.
280 if ( false !== strpos( $src, '.min.' ) ) {
281 return $src;
282 }
283
284 // Skip anything we already produced. The Asset_Combiner minifies the
285 // combined body itself before writing combined-<hash>.css under
286 // min/combined/ (issue #331 — that used to be asserted here but was
287 // not actually true, so the artifact shipped unminified), and enqueues it
288 // as `xspeed-combined-css`; the per-file minifier used to re-minify
289 // that combined output into a SECOND file (min/<hash2>.css) with its
290 // own mtime-derived hash. The served HTML then pinned that second
291 // hash, so a purge/regeneration (which changes the combined file's
292 // mtime -> a new hash2) left the cached page pointing at a file that
293 // no longer existed -> 404 -> unstyled/broken frontend. Leaving our
294 // own cache output untouched keeps a single, stable URL end-to-end.
295 if ( false !== strpos( $src, '/cache/xspeed/' ) ) {
296 return $src;
297 }
298
299 // Resolve to a local path; bail if external or unresolvable.
300 $path = self::url_to_path( $src );
301 if ( ! $path || ! is_readable( $path ) ) {
302 return $src;
303 }
304
305 // Build a cache filename keyed on path + mtime so edits invalidate.
306 $mtime = filemtime( $path );
307 $key = md5( $path . '|' . $mtime );
308 $cache = self::cache_path( $key, $type );
309
310 if ( ! file_exists( $cache ) ) {
311 $ok = self::minify_file( $path, $cache, $type );
312 if ( ! $ok ) {
313 return $src;
314 }
315 }
316
317 // Return a URL to the cached file. Built from known constants — never
318 // from str_replace on a filesystem path (which would assume the FS
319 // layout mirrors the URL layout).
320 return self::min_url() . '/' . $key . '.' . $type;
321 }
322
323 private static function minify_file( $source_path, $target_path, $type ) {
324 if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
325 return false;
326 }
327
328 // Path-traversal guard: refuse to write anywhere outside our cache
329 // dir, even if a malicious filter ever produced a poisoned key.
330 $cache_root = self::min_dir();
331 self::ensure_dir( $cache_root );
332 $real_root = realpath( $cache_root );
333 $real_dir = realpath( dirname( $target_path ) );
334 if ( ! $real_root || ! $real_dir || 0 !== strpos( $real_dir, $real_root ) ) {
335 return false;
336 }
337
338 try {
339 if ( 'css' === $type ) {
340 // Passing the TARGET path makes matthiasmullie/minify rebase every
341 // relative url(...) / @import against the minified file's location.
342 // Without it, a stylesheet moved from e.g.
343 // .../font-awesome/css/all.css to cache/xspeed/min/<key>.css keeps
344 // its original url(../webfonts/…) — which then resolves against the
345 // cache dir and 404s (missing FontAwesome/eicons/WooCommerce fonts).
346 $minifier = new \MatthiasMullie\Minify\CSS( $source_path );
347 $minified = $minifier->minify( $target_path );
348 return '' !== $minified && file_exists( $target_path );
349 }
350
351 $minifier = new \MatthiasMullie\Minify\JS( $source_path );
352 $minified = $minifier->minify();
353
354 // Sanity check: paren/brace/bracket/backtick balance must be preserved.
355 // matthiasmullie/minify can silently truncate mid-template-literal on
356 // complex modern JS — bail rather than ship a broken file.
357 // 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.
358 $source = file_get_contents( $source_path );
359 if ( false === $source || ! self::balanced( $source, $minified ) ) {
360 return false;
361 }
362
363 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders.
364 $bytes = file_put_contents( $target_path, $minified );
365 return false !== $bytes && file_exists( $target_path );
366 } catch ( \Throwable $e ) {
367 return false;
368 }
369 }
370
371 /**
372 * Cheap structural sanity check between source + minified bodies.
373 *
374 * Counts paired-delimiter tokens (parens, braces, brackets, backticks)
375 * in each and bails when the counts disagree — matthiasmullie/minify
376 * has been observed to silently truncate inside template literals on
377 * complex modern JS (see commit history), shipping a body that LOOKS
378 * minified but is structurally broken and crashes the page at parse.
379 *
380 * Backticks are paired (open + close = same token), so the count
381 * itself must match exactly. Strings inside the source can contain
382 * literal `{` / `}` / `[` / `]` that throw off the count by the same
383 * amount in both bodies (since they survive minification as-is), so
384 * the equality check is robust to that noise.
385 */
386 /**
387 * Minify the body of one captured inline block, or return it untouched.
388 *
389 * Only `<style>` and JavaScript `<script>` bodies are eligible:
390 *
391 * - `<pre>` / `<textarea>` — whitespace is rendered, never touch it.
392 * - `<script>` with a non-JS `type` — `application/ld+json`,
393 * `text/template`, `text/x-handlebars` and anything unrecognised are
394 * data or markup, not code. Minifying JSON-LD would corrupt structured
395 * data; minifying a template would eat the markup it holds. An unknown
396 * type is treated as non-JS on purpose: guessing wrong breaks the page,
397 * while skipping only forgoes a few bytes.
398 * - `<script src="...">` — the body is empty; the file path already goes
399 * through minify_file().
400 *
401 * Every result is checked with balanced(), the same structural guard the
402 * file path uses, so a body the library truncates is shipped as-is rather
403 * than broken. (#2)
404 *
405 * @param string $block Full matched tag, opening tag through closing tag.
406 * @param string $tag Lowercased tag name.
407 * @return string Minified block, or $block unchanged.
408 */
409 private static function minify_inline_block( string $block, string $tag ): string {
410 if ( 'style' !== $tag && 'script' !== $tag ) {
411 return $block; // pre / textarea — significant whitespace.
412 }
413 if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
414 return $block;
415 }
416
417 // Split into opening tag / body / closing tag. Anything that doesn't
418 // match this shape isn't something we should be rewriting.
419 if ( ! preg_match( '#^(<' . $tag . '\b[^>]*>)(.*)(</' . $tag . '\s*>)$#is', $block, $parts ) ) {
420 return $block;
421 }
422 list( , $open, $body, $close ) = $parts;
423
424 if ( '' === trim( $body ) ) {
425 return $block;
426 }
427
428 // Refuse a body that is already structurally broken. balanced() only
429 // compares source against minified, so it passes when BOTH are equally
430 // unbalanced — `function x( {` minifies to `function x({`, same counts,
431 // guard satisfied, broken code reformatted. Rewriting a body we can't
432 // parse risks turning a page that happens to work into one that does
433 // not, for no gain. (#2 AC: a syntactically broken block is left
434 // untouched.)
435 if ( ! self::self_consistent( $body ) ) {
436 return $block;
437 }
438
439 if ( 'script' === $tag ) {
440 // An external script has no body worth minifying.
441 if ( preg_match( '#\bsrc\s*=#i', $open ) ) {
442 return $block;
443 }
444 // No type, or an explicitly JavaScript type, is code. Everything
445 // else is data/markup — see the docblock.
446 $js_types = array(
447 'text/javascript',
448 'application/javascript',
449 'application/ecmascript',
450 'text/ecmascript',
451 'module',
452 );
453 if ( preg_match( '#\btype\s*=\s*["\']?([^"\'\s>]+)#i', $open, $type_match ) ) {
454 if ( ! in_array( strtolower( trim( $type_match[1] ) ), $js_types, true ) ) {
455 return $block;
456 }
457 }
458 }
459
460 try {
461 $minifier = 'style' === $tag
462 ? new \MatthiasMullie\Minify\CSS()
463 : new \MatthiasMullie\Minify\JS();
464 $minifier->add( $body );
465 $minified = $minifier->minify();
466 } catch ( \Throwable $e ) {
467 return $block;
468 }
469
470 // A minifier that returns nothing for a non-empty body has failed, not
471 // succeeded — shipping '' would silently delete the rule set.
472 if ( ! is_string( $minified ) || '' === trim( $minified ) ) {
473 return $block;
474 }
475 if ( ! self::balanced( $body, $minified ) ) {
476 return $block;
477 }
478
479 return $open . $minified . $close;
480 }
481
482 /**
483 * Does a body's own paired delimiters balance?
484 *
485 * balanced() is a RELATIVE check — source against minified — so it cannot
486 * see input that was already broken: an unbalanced body minifies to an
487 * equally unbalanced one and the counts still agree. This is the absolute
488 * check, applied to the source alone before we touch it.
489 *
490 * Deliberately naive: it counts tokens without parsing, so a brace inside
491 * a string or comment skews it. That only ever makes it MORE conservative —
492 * a false negative skips minification, which costs bytes, while a false
493 * positive would ship broken code. (#2)
494 *
495 * @param string $body Inline block body.
496 */
497 private static function self_consistent( string $body ): bool {
498 $pairs = array(
499 '{' => '}',
500 '(' => ')',
501 '[' => ']',
502 );
503 foreach ( $pairs as $open => $close ) {
504 if ( substr_count( $body, $open ) !== substr_count( $body, $close ) ) {
505 return false;
506 }
507 }
508 // Backticks and quotes pair with themselves, so an odd count means an
509 // unterminated literal.
510 foreach ( array( '`' ) as $token ) {
511 if ( 0 !== substr_count( $body, $token ) % 2 ) {
512 return false;
513 }
514 }
515 return true;
516 }
517
518 private static function balanced( string $source, string $minified ): bool {
519 $pairs = array( '(', ')', '{', '}', '[', ']', '`' );
520 foreach ( $pairs as $token ) {
521 if ( substr_count( $source, $token ) !== substr_count( $minified, $token ) ) {
522 return false;
523 }
524 }
525 return true;
526 }
527
528 /**
529 * Resolve a local asset URL to a filesystem path using a strict allowlist
530 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
531 *
532 * We never assume `site_url()` maps to `ABSPATH` (the WordPress root can
533 * live above the document root in Bedrock-style installs, behind a proxy,
534 * or on multisite with mapped domains). Each branch resolves through a
535 * known WP API (plugins, themes, content, includes) and validates that
536 * `realpath()` of the result still lives under the expected base — so a
537 * crafted `..`-laden URL cannot escape into the filesystem.
538 *
539 * @param string $url Asset URL (may be protocol-relative or absolute).
540 * @return string|false Absolute filesystem path on success, false otherwise.
541 */
542 private static function url_to_path( $url ) {
543 if ( ! is_string( $url ) || '' === $url ) {
544 return false;
545 }
546
547 // Drop query string + fragment.
548 $clean = strtok( $url, '?#' );
549
550 // Normalise protocol-relative + scheme variants of the host so we
551 // match regardless of whether the asset URL came in over http/https.
552 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
553 if ( 0 === strpos( $clean, '//' ) ) {
554 $clean = 'https:' . $clean;
555 }
556 if ( $site_host ) {
557 $asset_host = wp_parse_url( $clean, PHP_URL_HOST );
558 if ( $asset_host && $asset_host !== $site_host ) {
559 return false; // External asset — never touch.
560 }
561 }
562
563 $candidates = array(
564 array( plugins_url(), WP_PLUGIN_DIR ),
565 array( get_stylesheet_directory_uri(), get_stylesheet_directory() ),
566 array( get_template_directory_uri(), get_template_directory() ),
567 array( content_url(), WP_CONTENT_DIR ),
568 array( includes_url(), ABSPATH . WPINC ),
569 );
570
571 foreach ( $candidates as $pair ) {
572 list( $url_base, $path_base ) = $pair;
573 if ( ! $url_base || ! $path_base ) {
574 continue;
575 }
576 $url_base = rtrim( $url_base, '/' );
577 if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
578 continue;
579 }
580
581 $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
582 $candidate = trailingslashit( $path_base ) . $relative;
583
584 $real_base = realpath( $path_base );
585 $real = realpath( $candidate );
586 if ( ! $real_base || ! $real ) {
587 return false;
588 }
589 // Guard against `..`-traversal: resolved path must stay inside
590 // the registered base.
591 if ( 0 !== strpos( $real, $real_base ) ) {
592 return false;
593 }
594 return $real;
595 }
596
597 return false;
598 }
599
600 private static function cache_path( $key, $type ) {
601 return self::min_dir() . '/' . $key . '.' . $type;
602 }
603
604 private static function ensure_dir( $dir ) {
605 if ( ! file_exists( $dir ) ) {
606 wp_mkdir_p( $dir );
607 Cache::write_silence( $dir );
608 }
609 }
610
611 public static function purge_minified() {
612 self::rmtree_files( self::min_dir() );
613 }
614
615 /**
616 * Recursively delete every file under $dir (and the emptied
617 * subdirectories), keeping $dir itself. The previous glob('$dir/*')
618 * was non-recursive and no-ops on directories, so combined assets in
619 * min/combined/ were never cleared — a purge left a stale
620 * combined-<hash>.css the regenerated page no longer referenced.
621 * (FBS-83114 / FBS-83116)
622 */
623 private static function rmtree_files( string $dir ): void {
624 if ( ! is_dir( $dir ) ) {
625 return;
626 }
627 foreach ( (array) glob( $dir . '/*' ) as $path ) {
628 if ( is_dir( $path ) ) {
629 self::rmtree_files( $path );
630 @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.
631 continue;
632 }
633 wp_delete_file( $path );
634 }
635 }
636 }
637