| @@ -8,11 +8,10 @@ | ||
| 8 | 8 | * 1. wp_enqueue_scripts @ 999 — walk WP_Styles->queue, partition into |
| 9 | 9 | * local + external. External (full http(s):// to other origins, |
| 10 | 10 | * data: URIs, protocol-relative pointing elsewhere) stay enqueued |
| 11 | 11 | * as-is; local handles get pulled out of the queue. |
| 12 | - * 2. Build the cache key from the version, home_url() and each part's | |
| 13 | - * content key (combined_key()). When the combined file already | |
| 14 | - * exists for that key, skip generation. | |
| 12 | + * 2. Build cache key = md5(JSON({handle => [src, mtime]})). When the | |
| 13 | + * combined file already exists for that key, skip generation. | |
| 15 | 14 | * 3. Otherwise: read each source body, resolve recursive @import |
| 16 | 15 | * statements (depth-limited), rewrite url(...) paths to absolute, |
| 17 | 16 | * concat with a small `/* xspeed: HANDLE */` header per chunk for |
| 18 | 17 | * debug-traceability, write to XSPEED_CACHE_DIR/min/combined/. |
| @@ -119,12 +118,9 @@ | ||
| 119 | 118 | * @param string $media The media attribute for this group ('all', 'print', …). |
| 120 | 119 | * @param array<string,array<mixed>> $bucket handle => info map. |
| 121 | 120 | */ |
| 122 | 121 | private static function combine_media_group( \WP_Styles $wp_styles, string $media, array $bucket ): void { |
| 123 | - if ( ! Minifier::min_dir_writable() ) { | |
| 124 | - return; | |
| 125 | - } | |
| 126 | - $key = self::cache_key( $bucket, 'css' ); | |
| 122 | + $key = self::cache_key( $bucket ); | |
| 127 | 123 | $dir = self::cache_dir(); |
| 128 | 124 | $out_file = $dir . '/combined-' . $key . '.css'; |
| 129 | 125 | $out_url = self::cache_url() . '/combined-' . $key . '.css'; |
| 130 | 126 | |
| @@ -162,12 +158,12 @@ | ||
| 162 | 158 | // was the artifact we generated, which then docked the site on |
| 163 | 159 | // xSpeed Scan's own A2 check while the UI reported minify as on. |
| 164 | 160 | $contents = self::minify_css_body( $contents ); |
| 165 | 161 | |
| 166 | - // Atomic: a concurrent render sees the whole file or none of it. | |
| 167 | - if ( ! Asset_Manifest::write_atomic( $out_file, $contents ) && ! file_exists( $out_file ) ) { | |
| 168 | - return; | |
| 169 | - } | |
| 162 | + // Atomic write: file_put_contents with LOCK_EX so concurrent | |
| 163 | + // renders don't race. | |
| 164 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context, unavailable on frontend. | |
| 165 | + file_put_contents( $out_file, $contents, LOCK_EX ); | |
| 170 | 166 | } else { |
| 171 | 167 | self::mark_in_use( $out_file ); |
| 172 | 168 | } |
| 173 | 169 | |
| @@ -297,12 +293,9 @@ | ||
| 297 | 293 | * @param int $group 0 = head, 1 = footer. |
| 298 | 294 | * @param array<string,array<mixed>> $bucket handle => info map for this group. |
| 299 | 295 | */ |
| 300 | 296 | private static function combine_script_group( \WP_Scripts $wp_scripts, int $group, array $bucket ): void { |
| 301 | - if ( ! Minifier::min_dir_writable() ) { | |
| 302 | - return; | |
| 303 | - } | |
| 304 | - $key = self::cache_key( $bucket, 'js' ); | |
| 297 | + $key = self::cache_key( $bucket ); | |
| 305 | 298 | $dir = self::cache_dir(); |
| 306 | 299 | // Group in the filename so a head and a footer bundle can never |
| 307 | 300 | // collide on one cache key. |
| 308 | 301 | $out_file = $dir . '/combined-g' . $group . '-' . $key . '.js'; |
| @@ -317,11 +310,10 @@ | ||
| 317 | 310 | continue; |
| 318 | 311 | } |
| 319 | 312 | $contents .= "/* xspeed: $handle */\n" . $body . "\n;\n"; |
| 320 | 313 | } |
| 321 | - if ( ! Asset_Manifest::write_atomic( $out_file, $contents ) && ! file_exists( $out_file ) ) { | |
| 322 | - return; | |
| 323 | - } | |
| 314 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem unavailable on frontend. | |
| 315 | + file_put_contents( $out_file, $contents, LOCK_EX ); | |
| 324 | 316 | } else { |
| 325 | 317 | self::mark_in_use( $out_file ); |
| 326 | 318 | } |
| 327 | 319 | |
| @@ -733,142 +725,29 @@ | ||
| 733 | 725 | return $src; |
| 734 | 726 | } |
| 735 | 727 | |
| 736 | 728 | /** |
| 737 | - * Map a URL on this site to the file it names, whether or not the file | |
| 738 | - * exists yet. | |
| 729 | + * Resolve an absolute URL to a local filesystem path + mtime, or | |
| 730 | + * return null if the URL isn't on this site / outside web root. | |
| 739 | 731 | * |
| 740 | - * The plugin, theme, uploads, content and includes bases WordPress | |
| 741 | - * registers are checked first, and home_url() → ABSPATH only last. That | |
| 742 | - * fallback alone assumed wp-content sits under ABSPATH, which is false on | |
| 743 | - * Flywheel (/wordpress/ next to /www/wp-content), Bedrock (web/wp next to | |
| 744 | - * web/app) and any WP_CONTENT_DIR define. There every stylesheet mapped to | |
| 745 | - * a missing file, so Combine CSS combined nothing and said nothing. (#725) | |
| 746 | - * | |
| 747 | - * The URL must be on the site's host, and is then compared with each base | |
| 748 | - * without its scheme, as Minifier::url_to_path() does. Behind a | |
| 749 | - * TLS-terminating proxy, or after a move to https, the page, home_url() | |
| 750 | - * and content_url() don't always agree on http or https, and a | |
| 751 | - * subdirectory multisite's home_url() carries a path its content URLs | |
| 752 | - * don't. A prefix test against home_url() refused all of those. | |
| 753 | - * | |
| 754 | - * `.` and `..` segments are resolved first, as a browser would, so a href | |
| 755 | - * can't climb out of the base it appears to be under. | |
| 756 | - * | |
| 757 | - * @param string $url Absolute URL. | |
| 732 | + * @return array{url:string,path:string,mtime:int}|null | |
| 758 | 733 | */ |
| 759 | - public static function url_to_candidate_path( string $url ): ?string { | |
| 734 | + public static function local_info( string $url ): ?array { | |
| 760 | 735 | if ( '' === $url ) { |
| 761 | 736 | return null; |
| 762 | 737 | } |
| 763 | - $clean = strtok( $url, '?#' ); | |
| 764 | - if ( ! is_string( $clean ) ) { | |
| 738 | + $home = home_url(); | |
| 739 | + if ( 0 !== strpos( $url, $home ) ) { | |
| 765 | 740 | return null; |
| 766 | 741 | } |
| 767 | - $host = wp_parse_url( $clean, PHP_URL_HOST ); | |
| 768 | - $site_host = wp_parse_url( home_url(), PHP_URL_HOST ); | |
| 769 | - if ( ! is_string( $host ) || '' === $host || ! is_string( $site_host ) || strtolower( $host ) !== strtolower( $site_host ) ) { | |
| 742 | + // Strip query / fragment for filesystem lookup; keep them in | |
| 743 | + // the URL we hash against. | |
| 744 | + $clean = strtok( $url, '?' ); | |
| 745 | + if ( ! is_string( $clean ) ) { | |
| 770 | 746 | return null; |
| 771 | 747 | } |
| 772 | - $match = self::remove_dot_segments( self::strip_scheme( $clean ) ); | |
| 773 | - foreach ( self::path_bases() as $pair ) { | |
| 774 | - list( $base_url, $base_dir ) = $pair; | |
| 775 | - $prefix = self::strip_scheme( rtrim( (string) $base_url, '/' ) ); | |
| 776 | - if ( '' === $prefix || '' === (string) $base_dir || 0 !== strpos( $match, $prefix . '/' ) ) { | |
| 777 | - continue; | |
| 778 | - } | |
| 779 | - return rtrim( (string) $base_dir, '/' ) . substr( $match, strlen( $prefix ) ); | |
| 780 | - } | |
| 781 | - return null; | |
| 782 | - } | |
| 783 | - | |
| 784 | - /** | |
| 785 | - * The reverse of url_to_candidate_path(), for rebasing `@import` and | |
| 786 | - * `url()` references found in a file we read from disk. A path under no | |
| 787 | - * known base is returned as given. | |
| 788 | - * | |
| 789 | - * @param string $path Absolute filesystem path. | |
| 790 | - */ | |
| 791 | - public static function path_to_url( string $path ): string { | |
| 792 | - foreach ( self::path_bases() as $pair ) { | |
| 793 | - list( $base_url, $base_dir ) = $pair; | |
| 794 | - $dir = rtrim( (string) $base_dir, '/' ); | |
| 795 | - if ( '' === $dir || '' === (string) $base_url || 0 !== strpos( $path, $dir . '/' ) ) { | |
| 796 | - continue; | |
| 797 | - } | |
| 798 | - // On home_url()'s scheme, as the rebased URL always was before | |
| 799 | - // (#725). content_url() and plugins_url() follow is_ssl(), which | |
| 800 | - // reads false behind a TLS-terminating proxy, and an http url() | |
| 801 | - // in a combined file is blocked as mixed content on an https page. | |
| 802 | - $scheme = wp_parse_url( home_url(), PHP_URL_SCHEME ); | |
| 803 | - $scheme = is_string( $scheme ) && '' !== $scheme ? $scheme : 'https'; | |
| 804 | - return $scheme . ':' . self::strip_scheme( rtrim( (string) $base_url, '/' ) ) . substr( $path, strlen( $dir ) ); | |
| 805 | - } | |
| 806 | - return $path; | |
| 807 | - } | |
| 808 | - | |
| 809 | - /** | |
| 810 | - * URL base → directory pairs, most specific first. The last pair is the | |
| 811 | - * old home_url() → ABSPATH mapping, so an install with wp-content under | |
| 812 | - * ABSPATH resolves exactly as it did before. | |
| 813 | - * | |
| 814 | - * @return array<int,array{0:string,1:string}> | |
| 815 | - */ | |
| 816 | - private static function path_bases(): array { | |
| 817 | - $uploads = wp_get_upload_dir(); | |
| 818 | - return array( | |
| 819 | - array( plugins_url(), WP_PLUGIN_DIR ), | |
| 820 | - array( get_stylesheet_directory_uri(), get_stylesheet_directory() ), | |
| 821 | - array( get_template_directory_uri(), get_template_directory() ), | |
| 822 | - array( (string) ( $uploads['baseurl'] ?? '' ), (string) ( $uploads['basedir'] ?? '' ) ), | |
| 823 | - array( content_url(), WP_CONTENT_DIR ), | |
| 824 | - array( includes_url(), ABSPATH . WPINC ), | |
| 825 | - array( home_url(), ABSPATH ), | |
| 826 | - ); | |
| 827 | - } | |
| 828 | - | |
| 829 | - private static function strip_scheme( string $url ): string { | |
| 830 | - return (string) preg_replace( '#^https?://#i', '//', $url ); | |
| 831 | - } | |
| 832 | - | |
| 833 | - /** | |
| 834 | - * Resolve `.` and `..` in the path of a scheme-less `//host/path` URL. | |
| 835 | - * `..` never climbs above the host. | |
| 836 | - */ | |
| 837 | - private static function remove_dot_segments( string $url ): string { | |
| 838 | - $slash = strpos( $url, '/', 2 ); | |
| 839 | - if ( false === $slash ) { | |
| 840 | - return $url; | |
| 841 | - } | |
| 842 | - $out = array(); | |
| 843 | - foreach ( explode( '/', substr( $url, $slash + 1 ) ) as $seg ) { | |
| 844 | - if ( '.' === $seg ) { | |
| 845 | - continue; | |
| 846 | - } | |
| 847 | - if ( '..' === $seg ) { | |
| 848 | - array_pop( $out ); | |
| 849 | - continue; | |
| 850 | - } | |
| 851 | - $out[] = $seg; | |
| 852 | - } | |
| 853 | - return substr( $url, 0, $slash ) . '/' . implode( '/', $out ); | |
| 854 | - } | |
| 855 | - | |
| 856 | - /** | |
| 857 | - * Resolve an absolute URL to a local filesystem path + mtime, or | |
| 858 | - * return null if the URL isn't on this site / outside web root. | |
| 859 | - * | |
| 860 | - * @return array{url:string,path:string,mtime:int}|null | |
| 861 | - */ | |
| 862 | - public static function local_info( string $url ): ?array { | |
| 863 | - // Query / fragment are stripped for the filesystem lookup and kept in | |
| 864 | - // the URL we hash against. | |
| 865 | - $path = self::url_to_candidate_path( $url ); | |
| 866 | - // Only the asset types the combiners read. Every caller wants a | |
| 867 | - // stylesheet or a script, and the bytes end up in a public combined | |
| 868 | - // file, so a href that names any other file (wp-config.php, say) must | |
| 869 | - // not be read at all. | |
| 870 | - if ( null === $path || ! preg_match( '/\.(?:css|js|mjs)$/i', $path ) || ! file_exists( $path ) || ! is_readable( $path ) ) { | |
| 748 | + $path = ABSPATH . ltrim( str_replace( $home, '', $clean ), '/' ); | |
| 749 | + if ( ! file_exists( $path ) || ! is_readable( $path ) ) { | |
| 871 | 750 | return null; |
| 872 | 751 | } |
| 873 | 752 | return array( |
| 874 | 753 | 'url' => $url, |
| @@ -913,93 +792,14 @@ | ||
| 913 | 792 | |
| 914 | 793 | return $out; |
| 915 | 794 | } |
| 916 | 795 | |
| 917 | - /** | |
| 918 | - * Cache key for a queue-built bundle. See combined_key(). | |
| 919 | - * | |
| 920 | - * @param array<string,array<mixed>> $bucket handle => info map. | |
| 921 | - * @param string $kind 'css' or 'js'. | |
| 922 | - */ | |
| 923 | - private static function cache_key( array $bucket, string $kind ): string { | |
| 924 | - $parts = array(); | |
| 796 | + private static function cache_key( array $bucket ): string { | |
| 797 | + $signature = array(); | |
| 925 | 798 | foreach ( $bucket as $handle => $info ) { |
| 926 | - $parts[] = array( | |
| 927 | - 'id' => (string) $handle, | |
| 928 | - 'path' => (string) ( $info['path'] ?? '' ), | |
| 929 | - 'url' => (string) ( $info['url'] ?? '' ), | |
| 930 | - ); | |
| 799 | + $signature[ $handle ] = array( $info['src'] ?? '', $info['mtime'] ?? 0 ); | |
| 931 | 800 | } |
| 932 | - return self::combined_key( $parts, $kind ); | |
| 933 | - } | |
| 934 | - | |
| 935 | - /** | |
| 936 | - * Name for a combined file, derived from what goes into it. | |
| 937 | - * | |
| 938 | - * md5 of the plugin version, this site's home_url(), and per part its | |
| 939 | - * path and content key. The old key was each part's path and mtime, | |
| 940 | - * which had two faults: | |
| 941 | - * | |
| 942 | - * - It followed mtime, not bytes, so an in-place edit that kept the | |
| 943 | - * mtime (or an edit to an @import child) served stale CSS, and a | |
| 944 | - * touch with no change orphaned every cached page. | |
| 945 | - * - It left out the site. The combined body rewrites every url() to an | |
| 946 | - * ABSOLUTE URL built from home_url(), and min/combined/ is shared by | |
| 947 | - * every blog on a network, so the first subsite to render a set of | |
| 948 | - * sheets wrote its own origin into the file every other subsite then | |
| 949 | - * served. home_url() is taken whole, path included, because a | |
| 950 | - * subdirectory subsite's URLs carry its path. | |
| 951 | - * | |
| 952 | - * A part that is already one of our minified files is named by content, | |
| 953 | - * so its basename is its content key and costs no IO. Any other part gets | |
| 954 | - * its key from Asset_Manifest, which also follows the @import children | |
| 955 | - * the combiner inlines. | |
| 956 | - * | |
| 957 | - * @param array<int,array{id?:string,path:string,url?:string}> $parts In output order. | |
| 958 | - * @param string $kind 'css' or 'js'. | |
| 959 | - */ | |
| 960 | - public static function combined_key( array $parts, string $kind ): string { | |
| 961 | - $signature = array( | |
| 962 | - defined( 'XSPEED_VERSION' ) ? (string) XSPEED_VERSION : '', | |
| 963 | - Asset_Manifest::SCHEMA, | |
| 964 | - rtrim( (string) home_url(), '/' ), | |
| 965 | - $kind, | |
| 966 | - ); | |
| 967 | - foreach ( $parts as $part ) { | |
| 968 | - $path = (string) ( $part['path'] ?? '' ); | |
| 969 | - $signature[] = array( | |
| 970 | - (string) ( $part['id'] ?? '' ), | |
| 971 | - $path, | |
| 972 | - self::part_content_key( $path, (string) ( $part['url'] ?? '' ), $kind ), | |
| 973 | - ); | |
| 974 | - } | |
| 975 | - return md5( (string) wp_json_encode( $signature ) ); | |
| 976 | - } | |
| 977 | - | |
| 978 | - /** | |
| 979 | - * Content key for one part of a combined file. | |
| 980 | - * | |
| 981 | - * @param string $path Absolute path of the part. | |
| 982 | - * @param string $url URL the combiner resolves the part's imports against. | |
| 983 | - * @param string $kind 'css' or 'js'. | |
| 984 | - */ | |
| 985 | - private static function part_content_key( string $path, string $url, string $kind ): string { | |
| 986 | - if ( '' === $path ) { | |
| 987 | - return ''; | |
| 988 | - } | |
| 989 | - // Our own minified output, min/<content key>.<ext>: the name is the key. | |
| 990 | - $min_root = rtrim( Minifier::min_dir(), '/' ) . '/'; | |
| 991 | - if ( 0 === strpos( $path, $min_root ) && false === strpos( substr( $path, strlen( $min_root ) ), '/' ) ) { | |
| 992 | - return basename( $path ); | |
| 993 | - } | |
| 994 | - $key = Asset_Manifest::key_for( | |
| 995 | - 'part-' . $kind, | |
| 996 | - $path, | |
| 997 | - static function () use ( $kind, $url ): array { | |
| 998 | - return 'css' === $kind && '' !== $url ? Asset_Manifest::import_dependencies( $url ) : array(); | |
| 999 | - } | |
| 1000 | - ); | |
| 1001 | - return null === $key ? 'unreadable' : $key; | |
| 801 | + return md5( wp_json_encode( $signature ) ); | |
| 1002 | 802 | } |
| 1003 | 803 | |
| 1004 | 804 | private static function read_local_file( string $path ): string { |
| 1005 | 805 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem unavailable on frontend; we already validated existence + readability. |