| @@ -8,10 +8,11 @@ | ||
| 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 cache key = md5(JSON({handle => [src, mtime]})). When the | |
| 13 | - * combined file already exists for that key, skip generation. | |
| 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. | |
| 14 | 15 | * 3. Otherwise: read each source body, resolve recursive @import |
| 15 | 16 | * statements (depth-limited), rewrite url(...) paths to absolute, |
| 16 | 17 | * concat with a small `/* xspeed: HANDLE */` header per chunk for |
| 17 | 18 | * debug-traceability, write to XSPEED_CACHE_DIR/min/combined/. |
| @@ -118,9 +119,12 @@ | ||
| 118 | 119 | * @param string $media The media attribute for this group ('all', 'print', …). |
| 119 | 120 | * @param array<string,array<mixed>> $bucket handle => info map. |
| 120 | 121 | */ |
| 121 | 122 | private static function combine_media_group( \WP_Styles $wp_styles, string $media, array $bucket ): void { |
| 122 | - $key = self::cache_key( $bucket ); | |
| 123 | + if ( ! Minifier::min_dir_writable() ) { | |
| 124 | + return; | |
| 125 | + } | |
| 126 | + $key = self::cache_key( $bucket, 'css' ); | |
| 123 | 127 | $dir = self::cache_dir(); |
| 124 | 128 | $out_file = $dir . '/combined-' . $key . '.css'; |
| 125 | 129 | $out_url = self::cache_url() . '/combined-' . $key . '.css'; |
| 126 | 130 | |
| @@ -158,12 +162,12 @@ | ||
| 158 | 162 | // was the artifact we generated, which then docked the site on |
| 159 | 163 | // xSpeed Scan's own A2 check while the UI reported minify as on. |
| 160 | 164 | $contents = self::minify_css_body( $contents ); |
| 161 | 165 | |
| 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 ); | |
| 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 | + } | |
| 166 | 170 | } else { |
| 167 | 171 | self::mark_in_use( $out_file ); |
| 168 | 172 | } |
| 169 | 173 | |
| @@ -293,9 +297,12 @@ | ||
| 293 | 297 | * @param int $group 0 = head, 1 = footer. |
| 294 | 298 | * @param array<string,array<mixed>> $bucket handle => info map for this group. |
| 295 | 299 | */ |
| 296 | 300 | private static function combine_script_group( \WP_Scripts $wp_scripts, int $group, array $bucket ): void { |
| 297 | - $key = self::cache_key( $bucket ); | |
| 301 | + if ( ! Minifier::min_dir_writable() ) { | |
| 302 | + return; | |
| 303 | + } | |
| 304 | + $key = self::cache_key( $bucket, 'js' ); | |
| 298 | 305 | $dir = self::cache_dir(); |
| 299 | 306 | // Group in the filename so a head and a footer bundle can never |
| 300 | 307 | // collide on one cache key. |
| 301 | 308 | $out_file = $dir . '/combined-g' . $group . '-' . $key . '.js'; |
| @@ -310,10 +317,11 @@ | ||
| 310 | 317 | continue; |
| 311 | 318 | } |
| 312 | 319 | $contents .= "/* xspeed: $handle */\n" . $body . "\n;\n"; |
| 313 | 320 | } |
| 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 ); | |
| 321 | + if ( ! Asset_Manifest::write_atomic( $out_file, $contents ) && ! file_exists( $out_file ) ) { | |
| 322 | + return; | |
| 323 | + } | |
| 316 | 324 | } else { |
| 317 | 325 | self::mark_in_use( $out_file ); |
| 318 | 326 | } |
| 319 | 327 | |
| @@ -725,29 +733,142 @@ | ||
| 725 | 733 | return $src; |
| 726 | 734 | } |
| 727 | 735 | |
| 728 | 736 | /** |
| 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. | |
| 737 | + * Map a URL on this site to the file it names, whether or not the file | |
| 738 | + * exists yet. | |
| 731 | 739 | * |
| 732 | - * @return array{url:string,path:string,mtime:int}|null | |
| 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. | |
| 733 | 758 | */ |
| 734 | - public static function local_info( string $url ): ?array { | |
| 759 | + public static function url_to_candidate_path( string $url ): ?string { | |
| 735 | 760 | if ( '' === $url ) { |
| 736 | 761 | return null; |
| 737 | 762 | } |
| 738 | - $home = home_url(); | |
| 739 | - if ( 0 !== strpos( $url, $home ) ) { | |
| 763 | + $clean = strtok( $url, '?#' ); | |
| 764 | + if ( ! is_string( $clean ) ) { | |
| 740 | 765 | return null; |
| 741 | 766 | } |
| 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 ) ) { | |
| 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 ) ) { | |
| 746 | 770 | return null; |
| 747 | 771 | } |
| 748 | - $path = ABSPATH . ltrim( str_replace( $home, '', $clean ), '/' ); | |
| 749 | - if ( ! file_exists( $path ) || ! is_readable( $path ) ) { | |
| 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 ) ) { | |
| 750 | 871 | return null; |
| 751 | 872 | } |
| 752 | 873 | return array( |
| 753 | 874 | 'url' => $url, |
| @@ -792,14 +913,93 @@ | ||
| 792 | 913 | |
| 793 | 914 | return $out; |
| 794 | 915 | } |
| 795 | 916 | |
| 796 | - private static function cache_key( array $bucket ): string { | |
| 797 | - $signature = array(); | |
| 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(); | |
| 798 | 925 | foreach ( $bucket as $handle => $info ) { |
| 799 | - $signature[ $handle ] = array( $info['src'] ?? '', $info['mtime'] ?? 0 ); | |
| 926 | + $parts[] = array( | |
| 927 | + 'id' => (string) $handle, | |
| 928 | + 'path' => (string) ( $info['path'] ?? '' ), | |
| 929 | + 'url' => (string) ( $info['url'] ?? '' ), | |
| 930 | + ); | |
| 800 | 931 | } |
| 801 | - return md5( wp_json_encode( $signature ) ); | |
| 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; | |
| 802 | 1002 | } |
| 803 | 1003 | |
| 804 | 1004 | private static function read_local_file( string $path ): string { |
| 805 | 1005 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem unavailable on frontend; we already validated existence + readability. |