| @@ -75,8 +75,15 @@ | ||
| 75 | 75 | add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 ); |
| 76 | 76 | } |
| 77 | 77 | if ( ! empty( $opts['minify_js'] ) ) { |
| 78 | 78 | add_filter( 'script_loader_src', array( __CLASS__, 'rewrite_script' ), 10, 2 ); |
| 79 | + // The src rewrite above runs before any plugin's own | |
| 80 | + // `script_loader_tag` filter can stamp data-no-minify / | |
| 81 | + // data-no-optimize onto the tag, so the marker arrives too late | |
| 82 | + // to prevent it. Priority 15: after third-party tag filters at | |
| 83 | + // the default 10 have printed their markers, before our defer | |
| 84 | + // (20) and delay (30) look at the tag. (#456) | |
| 85 | + add_filter( 'script_loader_tag', array( Minify_Filters::class, 'restore_marked_script_src' ), 15, 3 ); | |
| 79 | 86 | } |
| 80 | 87 | |
| 81 | 88 | // Phase 4.1a — filter-only "smarter minifier" features. Each is |
| 82 | 89 | // gated on its own toggle so users can enable any subset. |
| @@ -91,8 +98,17 @@ | ||
| 91 | 98 | // Delay applies a transform that's mutually exclusive with |
| 92 | 99 | // plain defer — when both are on, delay wins (the bootstrap |
| 93 | 100 | // will re-attach as a regular <script> on interaction). |
| 94 | 101 | add_filter( 'script_loader_tag', array( Minify_Filters::class, 'delay_script_tag' ), 30, 3 ); |
| 102 | + // Smart Delay: a delayed handle's before/after snippets park with | |
| 103 | + // it, or the inline consumer would run at parse time against a | |
| 104 | + // global that now arrives on first interaction. This filter fires | |
| 105 | + // for every inline script WordPress prints, so it also covers | |
| 106 | + // pages the cache buffer never filters. | |
| 107 | + add_filter( 'wp_inline_script_attributes', array( Minify_Filters::class, 'park_smart_inline' ), 30, 2 ); | |
| 108 | + // A snippet never stays parked behind a live script. Runs after | |
| 109 | + // the late opt-out revert, which can un-delay the tag. | |
| 110 | + add_filter( 'script_loader_tag', array( Minify_Filters::class, 'unpark_orphaned_smart_inline' ), Minify_Filters::late_opt_out_priority() + 1, 3 ); | |
| 95 | 111 | add_action( 'wp_footer', array( Minify_Filters::class, 'print_delay_bootstrap' ), 1000 ); |
| 96 | 112 | // script_loader_tag only fires for wp_enqueue_script()'d assets. |
| 97 | 113 | // Analytics / pixel / chat-widget tags printed straight into |
| 98 | 114 | // wp_head bypass it, and those are usually the heaviest scripts |
| @@ -100,11 +116,41 @@ | ||
| 100 | 116 | // minify_html (same filter, default priority) and is baked into |
| 101 | 117 | // the cache file, so it replays on static hits where PHP never |
| 102 | 118 | // boots. |
| 103 | 119 | add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_raw_script_tags' ), 20 ); |
| 120 | + // The vendors' own install snippets are INLINE (no src at all), | |
| 121 | + // so the src sweep above never sees them; this one parks an | |
| 122 | + // inline body that names a known third-party host. | |
| 123 | + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'delay_inline_snippets' ), 21 ); | |
| 104 | 124 | } |
| 125 | + | |
| 126 | + // Everything above honors data-no-optimize / data-no-minify, but | |
| 127 | + // only sees markers stamped before priority 30. Borlabs Cookie | |
| 128 | + // stamps at 100, so its consent config was minified AND delayed | |
| 129 | + // despite carrying both markers. Snapshot the tag before our | |
| 130 | + // transforms (9) and hand the original back if a marker turns up | |
| 131 | + // after them (1000, past Borlabs' own 100 and 999). Registered | |
| 132 | + // whenever any of the three is on, | |
| 133 | + // since each one is individually enough to damage a marked | |
| 134 | + // script. (#469) | |
| 135 | + if ( ! empty( $opts['minify_js'] ) || ! empty( $opts['defer_js'] ) || ! empty( $opts['delay_js'] ) ) { | |
| 136 | + add_filter( 'script_loader_tag', array( Minify_Filters::class, 'snapshot_tag' ), 9, 3 ); | |
| 137 | + add_filter( | |
| 138 | + 'script_loader_tag', | |
| 139 | + array( Minify_Filters::class, 'revert_late_marked_tag' ), | |
| 140 | + Minify_Filters::late_opt_out_priority(), | |
| 141 | + 3 | |
| 142 | + ); | |
| 143 | + } | |
| 105 | 144 | if ( ! empty( $opts['async_css'] ) ) { |
| 106 | 145 | add_filter( 'style_loader_tag', array( Minify_Filters::class, 'async_style_tag' ), 20, 2 ); |
| 146 | + // style_loader_tag only fires for wp_enqueue_style()'d sheets. | |
| 147 | + // Themes print Google/Bunny/Typekit font CSS as literal <link> | |
| 148 | + // markup in the head, so those sheets never reach the filter and | |
| 149 | + // stay render-blocking — sweep the finished buffer for the known | |
| 150 | + // font-CSS hosts. Baked into the cache file, so it replays on | |
| 151 | + // static hits where PHP never boots. | |
| 152 | + add_filter( 'xspeed_cache_final_html', array( Minify_Filters::class, 'async_raw_font_css_links' ), 22 ); | |
| 107 | 153 | } |
| 108 | 154 | |
| 109 | 155 | /* |
| 110 | 156 | * CSS combining runs on the FINISHED HTML, not the enqueue queue. |
| @@ -301,18 +347,37 @@ | ||
| 301 | 347 | if ( ! $path || ! is_readable( $path ) ) { |
| 302 | 348 | return $src; |
| 303 | 349 | } |
| 304 | 350 | |
| 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 ); | |
| 351 | + // Nowhere to write means nothing to serve. Without this gate an | |
| 352 | + // unwritable min/ cost a full minification on EVERY render, each one | |
| 353 | + // thrown away when the write failed. | |
| 354 | + if ( ! self::min_dir_writable() ) { | |
| 355 | + return $src; | |
| 356 | + } | |
| 309 | 357 | |
| 310 | - if ( ! file_exists( $cache ) ) { | |
| 311 | - $ok = self::minify_file( $path, $cache, $type ); | |
| 312 | - if ( ! $ok ) { | |
| 313 | - return $src; | |
| 358 | + // The file is named after its minified CONTENT, found through a | |
| 359 | + // per-source manifest that is validated by stat() alone on the hot | |
| 360 | + // path. See Asset_Manifest for the rules. The old name was | |
| 361 | + // md5(path|mtime): an in-place edit that kept the mtime served new | |
| 362 | + // bytes under a URL cached for a year, an @import child's edit | |
| 363 | + // changed nothing, and a touch with no change orphaned every cached | |
| 364 | + // page that linked the old name. | |
| 365 | + $key = Asset_Manifest::key_for( | |
| 366 | + $type, | |
| 367 | + $path, | |
| 368 | + static function ( string $source ) use ( $type ): array { | |
| 369 | + return 'css' === $type ? Asset_Manifest::css_dependencies( $source ) : array(); | |
| 370 | + }, | |
| 371 | + static function ( string $source ) use ( $type ) { | |
| 372 | + return self::build( $source, $type ); | |
| 373 | + }, | |
| 374 | + static function ( string $key ) use ( $type ): bool { | |
| 375 | + return file_exists( self::cache_path( $key, $type ) ); | |
| 314 | 376 | } |
| 377 | + ); | |
| 378 | + if ( null === $key ) { | |
| 379 | + return $src; | |
| 315 | 380 | } |
| 316 | 381 | |
| 317 | 382 | // Return a URL to the cached file. Built from known constants — never |
| 318 | 383 | // from str_replace on a filesystem path (which would assume the FS |
| @@ -319,53 +384,110 @@ | ||
| 319 | 384 | // layout mirrors the URL layout). |
| 320 | 385 | return self::min_url() . '/' . $key . '.' . $type; |
| 321 | 386 | } |
| 322 | 387 | |
| 323 | - private static function minify_file( $source_path, $target_path, $type ) { | |
| 324 | - if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) { | |
| 325 | - return false; | |
| 388 | + /** | |
| 389 | + * Whether min/ can be written, asked once per request. | |
| 390 | + * | |
| 391 | + * @var bool|null | |
| 392 | + */ | |
| 393 | + private static $writable = null; | |
| 394 | + | |
| 395 | + /** | |
| 396 | + * Minifications run by this process. | |
| 397 | + * | |
| 398 | + * @var int | |
| 399 | + */ | |
| 400 | + private static $builds = 0; | |
| 401 | + | |
| 402 | + /** Forget the per-request answers. Tests only. */ | |
| 403 | + public static function reset_request_state(): void { | |
| 404 | + self::$writable = null; | |
| 405 | + self::$builds = 0; | |
| 406 | + } | |
| 407 | + | |
| 408 | + /** How many sources this process has minified. Tests and diagnostics. */ | |
| 409 | + public static function builds(): int { | |
| 410 | + return self::$builds; | |
| 411 | + } | |
| 412 | + | |
| 413 | + /** Can minified files be written this request? */ | |
| 414 | + public static function min_dir_writable(): bool { | |
| 415 | + if ( null === self::$writable ) { | |
| 416 | + $dir = self::min_dir(); | |
| 417 | + self::ensure_dir( $dir ); | |
| 418 | + self::$writable = is_dir( $dir ) && wp_is_writable( $dir ); | |
| 326 | 419 | } |
| 420 | + return self::$writable; | |
| 421 | + } | |
| 327 | 422 | |
| 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; | |
| 423 | + /** | |
| 424 | + * Minify a source into min/<md5 of output>.<type> and return the key. | |
| 425 | + * | |
| 426 | + * The output is built in memory and published with an atomic rename, so a | |
| 427 | + * concurrent render never links a half-written file. When a file with the | |
| 428 | + * same content already exists nothing is written: same bytes, same name. | |
| 429 | + * | |
| 430 | + * @param string $source Absolute source path. | |
| 431 | + * @param string $type 'css' or 'js'. | |
| 432 | + * @return string|null|false Key; null when the source cannot be minified; | |
| 433 | + * false when the output could not be written. | |
| 434 | + */ | |
| 435 | + private static function build( string $source, string $type ) { | |
| 436 | + ++self::$builds; | |
| 437 | + $minified = self::minify_to_string( $source, $type ); | |
| 438 | + if ( null === $minified ) { | |
| 439 | + return null; | |
| 336 | 440 | } |
| 441 | + $key = md5( $minified ); | |
| 442 | + $target = self::cache_path( $key, $type ); | |
| 443 | + if ( file_exists( $target ) ) { | |
| 444 | + return $key; | |
| 445 | + } | |
| 446 | + return Asset_Manifest::write_atomic( $target, $minified ) ? $key : false; | |
| 447 | + } | |
| 337 | 448 | |
| 449 | + /** | |
| 450 | + * Minified bytes of a source, or null on failure. | |
| 451 | + * | |
| 452 | + * @param string $source_path Absolute source path. | |
| 453 | + * @param string $type 'css' or 'js'. | |
| 454 | + */ | |
| 455 | + private static function minify_to_string( string $source_path, string $type ): ?string { | |
| 456 | + if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) { | |
| 457 | + return null; | |
| 458 | + } | |
| 459 | + | |
| 338 | 460 | try { |
| 339 | 461 | 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). | |
| 462 | + // execute() with a path inside min/ rebases every relative | |
| 463 | + // url(...) / @import against the minified file's location, | |
| 464 | + // which is what minify( $target ) did before without writing. | |
| 465 | + // Every output lives in the same directory, so any name there | |
| 466 | + // rebases identically. Without the rebase a stylesheet moved | |
| 467 | + // from e.g. .../font-awesome/css/all.css to | |
| 468 | + // cache/xspeed/min/<key>.css keeps its original | |
| 469 | + // url(../webfonts/…), which then resolves against the cache | |
| 470 | + // dir and 404s (missing FontAwesome/eicons/WooCommerce fonts). | |
| 346 | 471 | $minifier = new \MatthiasMullie\Minify\CSS( $source_path ); |
| 347 | - $minified = $minifier->minify( $target_path ); | |
| 348 | - return '' !== $minified && file_exists( $target_path ); | |
| 472 | + $minified = (string) $minifier->execute( self::min_dir() . '/rebase.css' ); | |
| 473 | + return '' !== $minified ? $minified : null; | |
| 349 | 474 | } |
| 350 | 475 | |
| 351 | 476 | $minifier = new \MatthiasMullie\Minify\JS( $source_path ); |
| 352 | - $minified = $minifier->minify(); | |
| 477 | + $minified = (string) $minifier->minify(); | |
| 353 | 478 | |
| 354 | 479 | // Sanity check: paren/brace/bracket/backtick balance must be preserved. |
| 355 | 480 | // matthiasmullie/minify can silently truncate mid-template-literal on |
| 356 | 481 | // 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. | |
| 482 | + // 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 by the caller. | |
| 358 | 483 | $source = file_get_contents( $source_path ); |
| 359 | - if ( false === $source || ! self::balanced( $source, $minified ) ) { | |
| 360 | - return false; | |
| 484 | + if ( false === $source || '' === $minified || ! self::balanced( $source, $minified ) ) { | |
| 485 | + return null; | |
| 361 | 486 | } |
| 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 ); | |
| 487 | + return $minified; | |
| 366 | 488 | } catch ( \Throwable $e ) { |
| 367 | - return false; | |
| 489 | + return null; | |
| 368 | 490 | } |
| 369 | 491 | } |
| 370 | 492 | |
| 371 | 493 | /** |
| @@ -395,9 +517,9 @@ | ||
| 395 | 517 | * data; minifying a template would eat the markup it holds. An unknown |
| 396 | 518 | * type is treated as non-JS on purpose: guessing wrong breaks the page, |
| 397 | 519 | * while skipping only forgoes a few bytes. |
| 398 | 520 | * - `<script src="...">` — the body is empty; the file path already goes |
| 399 | - * through minify_file(). | |
| 521 | + * through rewrite_asset(). | |
| 400 | 522 | * |
| 401 | 523 | * Every result is checked with balanced(), the same structural guard the |
| 402 | 524 | * file path uses, so a body the library truncates is shipped as-is rather |
| 403 | 525 | * than broken. (#2) |
| @@ -424,8 +546,14 @@ | ||
| 424 | 546 | if ( '' === trim( $body ) ) { |
| 425 | 547 | return $block; |
| 426 | 548 | } |
| 427 | 549 | |
| 550 | + // The tag asked to be left alone (data-no-optimize / data-no-minify — | |
| 551 | + // the convention consent managers print on their config scripts). (#456) | |
| 552 | + if ( Minify_Filters::tag_opts_out( $open ) ) { | |
| 553 | + return $block; | |
| 554 | + } | |
| 555 | + | |
| 428 | 556 | // Refuse a body that is already structurally broken. balanced() only |
| 429 | 557 | // compares source against minified, so it passes when BOTH are equally |
| 430 | 558 | // unbalanced — `function x( {` minifies to `function x({`, same counts, |
| 431 | 559 | // guard satisfied, broken code reformatted. Rewriting a body we can't |
| @@ -515,15 +643,34 @@ | ||
| 515 | 643 | return true; |
| 516 | 644 | } |
| 517 | 645 | |
| 518 | 646 | 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; | |
| 647 | + unset( $source ); | |
| 648 | + | |
| 649 | + // Judge the OUTPUT, not the difference between input and output. | |
| 650 | + // | |
| 651 | + // This used to compare token counts across the pair, on the stated | |
| 652 | + // assumption that "literal braces inside strings survive minification | |
| 653 | + // unchanged, so they cancel out". Comments do not: stripping them is | |
| 654 | + // the minifier's whole job, and every brace, bracket and backtick | |
| 655 | + // inside one disappears with it. So any file whose comments contain a | |
| 656 | + // delimiter — a commented-out block, a URL in a docblock, an SVG in a | |
| 657 | + // note — failed the check and silently shipped unminified. | |
| 658 | + // | |
| 659 | + // It is not a rare shape. EmbedPress's front.js counts 372 braces | |
| 660 | + // against 368, 61 brackets against 58 and 110 backticks against 102 | |
| 661 | + // purely from comment removal, so 67 KB shipped raw where 46 KB was | |
| 662 | + // correct — and `node --check` confirms that rejected output parses | |
| 663 | + // fine. A guard that refuses valid work is not conservative, it is | |
| 664 | + // broken: it costs bytes on every request and reports nothing. | |
| 665 | + // | |
| 666 | + // What the guard is FOR still stands (#2): matthiasmullie/minify can | |
| 667 | + // truncate inside a template literal on complex modern JS and return a | |
| 668 | + // body that looks minified but is structurally broken. That failure is | |
| 669 | + // visible in the output alone — an unterminated literal leaves an odd | |
| 670 | + // backtick count and unmatched braces — which is exactly what | |
| 671 | + // self_consistent() measures, without the false positives. | |
| 672 | + return self::self_consistent( $minified ); | |
| 526 | 673 | } |
| 527 | 674 | |
| 528 | 675 | /** |
| 529 | 676 | * Resolve a local asset URL to a filesystem path using a strict allowlist |
| @@ -567,19 +714,40 @@ | ||
| 567 | 714 | array( content_url(), WP_CONTENT_DIR ), |
| 568 | 715 | array( includes_url(), ABSPATH . WPINC ), |
| 569 | 716 | ); |
| 570 | 717 | |
| 718 | + // The host check above normalised the HOST but not the SCHEME, and the | |
| 719 | + // prefix match below is a plain string compare — so an https asset URL | |
| 720 | + // never matched an http base and the file silently shipped unminified. | |
| 721 | + // That is not a corner case: WP_CONTENT_URL is derived from a stored | |
| 722 | + // option, `plugins_url()` from another, and a site moved to https | |
| 723 | + // without rewriting every row (or one behind a TLS-terminating proxy | |
| 724 | + // where `is_ssl()` reads false) serves https pages off http-rooted | |
| 725 | + // bases all day. Comparing scheme-less is the whole fix; the host | |
| 726 | + // equality test already did the security work of refusing anything | |
| 727 | + // off-site, and this runs after it. | |
| 728 | + $strip_scheme = static function ( string $value ): string { | |
| 729 | + return (string) preg_replace( '#^https?://#i', '//', $value ); | |
| 730 | + }; | |
| 731 | + $clean_match = $strip_scheme( $clean ); | |
| 732 | + | |
| 571 | 733 | foreach ( $candidates as $pair ) { |
| 572 | 734 | list( $url_base, $path_base ) = $pair; |
| 573 | 735 | if ( ! $url_base || ! $path_base ) { |
| 574 | 736 | continue; |
| 575 | 737 | } |
| 576 | - $url_base = rtrim( $url_base, '/' ); | |
| 577 | - if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) { | |
| 738 | + $url_base = rtrim( $url_base, '/' ); | |
| 739 | + $base_match = $strip_scheme( $url_base ); | |
| 740 | + if ( 0 !== strpos( $clean_match, $base_match . '/' ) && $clean_match !== $base_match ) { | |
| 578 | 741 | continue; |
| 579 | 742 | } |
| 580 | 743 | |
| 581 | - $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' ); | |
| 744 | + // Slice the scheme-less pair, not the original. `https://…` and | |
| 745 | + // `http://…` differ by one byte, so an offset taken from the base | |
| 746 | + // as written would cut one character short of (or past) the path | |
| 747 | + // when the two schemes disagree — which is the case this fix | |
| 748 | + // exists for. | |
| 749 | + $relative = ltrim( substr( $clean_match, strlen( $base_match ) ), '/' ); | |
| 582 | 750 | $candidate = trailingslashit( $path_base ) . $relative; |
| 583 | 751 | |
| 584 | 752 | $real_base = realpath( $path_base ); |
| 585 | 753 | $real = realpath( $candidate ); |
| @@ -607,13 +775,76 @@ | ||
| 607 | 775 | Cache::write_silence( $dir ); |
| 608 | 776 | } |
| 609 | 777 | } |
| 610 | 778 | |
| 779 | + /** | |
| 780 | + * Delete every minified and combined asset, network-wide. | |
| 781 | + * | |
| 782 | + * Output files are named by content, so an ordinary purge has no reason | |
| 783 | + * to delete them: a page rendered after it links the same names. Only a | |
| 784 | + * network purge, an update, an explicit assets purge and deactivation | |
| 785 | + * come here. Deleting is still a race against renders in flight, which | |
| 786 | + * may already hold the names of files about to vanish; the purge stamp | |
| 787 | + * bumped here lets the page cache refuse to store those renders. | |
| 788 | + * | |
| 789 | + * @return int Files removed. Most callers are `add_action` callbacks and | |
| 790 | + * ignore it; `wp xspeed purge` reports it as a line item. | |
| 791 | + */ | |
| 611 | 792 | public static function purge_minified() { |
| 612 | - self::rmtree_files( self::min_dir() ); | |
| 793 | + $removed = self::rmtree_files( self::min_dir() ); | |
| 794 | + if ( $removed > 0 ) { | |
| 795 | + self::bump_purge_stamp(); | |
| 796 | + } | |
| 797 | + return $removed; | |
| 613 | 798 | } |
| 614 | 799 | |
| 615 | 800 | /** |
| 801 | + * Delete one blog's manifests, leaving every output file in place. | |
| 802 | + * | |
| 803 | + * What a subsite's assets purge can safely do on a network whose blogs | |
| 804 | + * share min/: the next render of each source re-reads its bytes and | |
| 805 | + * rebuilds only if they changed, and no other blog's cached page loses a | |
| 806 | + * file it links. Outputs nothing links any more are left to Cache_GC. | |
| 807 | + * | |
| 808 | + * @param int $blog_id Blog whose manifests to drop. | |
| 809 | + * @return int Manifests removed. | |
| 810 | + */ | |
| 811 | + public static function purge_manifests( int $blog_id ): int { | |
| 812 | + $dir = Asset_Manifest::dir( $blog_id ); | |
| 813 | + $removed = self::rmtree_files( $dir ); | |
| 814 | + @rmdir( $dir ); // 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. | |
| 815 | + return $removed; | |
| 816 | + } | |
| 817 | + | |
| 818 | + /** File holding the purge stamp. Network-wide, like min/ itself. */ | |
| 819 | + public static function purge_stamp_path(): string { | |
| 820 | + return rtrim( (string) XSPEED_CACHE_DIR, '/' ) . '/min-purge.stamp'; | |
| 821 | + } | |
| 822 | + | |
| 823 | + /** | |
| 824 | + * Token that changes every time purge_minified() deletes something. | |
| 825 | + * | |
| 826 | + * The page cache reads it when a render starts and again before storing | |
| 827 | + * the page. A different value means files the page may link were deleted | |
| 828 | + * in between, so the page is served but not cached. | |
| 829 | + * | |
| 830 | + * @return string '' when no purge has ever deleted anything. | |
| 831 | + */ | |
| 832 | + public static function purge_stamp(): string { | |
| 833 | + $stamp = @file_get_contents( self::purge_stamp_path() ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- absent until the first purge; tiny cache sidecar read on the front end, where WP_Filesystem is unavailable. | |
| 834 | + return is_string( $stamp ) ? trim( $stamp ) : ''; | |
| 835 | + } | |
| 836 | + | |
| 837 | + /** Replace the purge stamp with a new random token. */ | |
| 838 | + private static function bump_purge_stamp(): void { | |
| 839 | + $path = self::purge_stamp_path(); | |
| 840 | + if ( ! is_dir( dirname( $path ) ) ) { | |
| 841 | + return; | |
| 842 | + } | |
| 843 | + Asset_Manifest::write_atomic( $path, bin2hex( random_bytes( 8 ) ) ); | |
| 844 | + } | |
| 845 | + | |
| 846 | + /** | |
| 616 | 847 | * Recursively delete every file under $dir (and the emptied |
| 617 | 848 | * subdirectories), keeping $dir itself. The previous glob('$dir/*') |
| 618 | 849 | * was non-recursive and no-ops on directories, so combined assets in |
| 619 | 850 | * min/combined/ were never cleared — a purge left a stale |
| @@ -619,18 +850,21 @@ | ||
| 619 | 850 | * min/combined/ were never cleared — a purge left a stale |
| 620 | 851 | * combined-<hash>.css the regenerated page no longer referenced. |
| 621 | 852 | * (FBS-83114 / FBS-83116) |
| 622 | 853 | */ |
| 623 | - private static function rmtree_files( string $dir ): void { | |
| 854 | + private static function rmtree_files( string $dir ): int { | |
| 624 | 855 | if ( ! is_dir( $dir ) ) { |
| 625 | - return; | |
| 856 | + return 0; | |
| 626 | 857 | } |
| 858 | + $removed = 0; | |
| 627 | 859 | foreach ( (array) glob( $dir . '/*' ) as $path ) { |
| 628 | 860 | if ( is_dir( $path ) ) { |
| 629 | - self::rmtree_files( $path ); | |
| 861 | + $removed += self::rmtree_files( $path ); | |
| 630 | 862 | @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 | 863 | continue; |
| 632 | 864 | } |
| 633 | 865 | wp_delete_file( $path ); |
| 866 | + ++$removed; | |
| 634 | 867 | } |
| 868 | + return $removed; | |
| 635 | 869 | } |
| 636 | 870 | } |