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

275 lines 8.8 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 $opts = Settings::get();
53
54 if ( ! empty( $opts['minify_css'] ) ) {
55 add_filter( 'style_loader_src', array( __CLASS__, 'rewrite_style' ), 10, 2 );
56 }
57 if ( ! empty( $opts['minify_js'] ) ) {
58 add_filter( 'script_loader_src', array( __CLASS__, 'rewrite_script' ), 10, 2 );
59 }
60 }
61
62 public static function minify_html( $html ) {
63 $debug_skip = defined( 'WP_DEBUG' ) && WP_DEBUG;
64 if ( apply_filters( 'xspeed_skip_minify', $debug_skip ) ) {
65 return $html;
66 }
67
68 $placeholders = array();
69 $pattern = '#<(pre|textarea|script|style)\b[^>]*>.*?</\1>#is';
70 $html = preg_replace_callback(
71 $pattern,
72 function ( $m ) use ( &$placeholders ) {
73 $key = '__XSPEED_PH_' . count( $placeholders ) . '__';
74 $placeholders[ $key ] = $m[0];
75 return $key;
76 },
77 $html
78 );
79
80 $html = preg_replace( '/<!--(?!\[if).*?-->/s', '', $html );
81 $html = preg_replace( '/\s+/', ' ', $html );
82 $html = preg_replace( '/>\s+</', '><', $html );
83 $html = trim( $html );
84
85 foreach ( $placeholders as $key => $original ) {
86 $html = str_replace( $key, $original, $html );
87 }
88
89 return $html;
90 }
91
92 public static function rewrite_style( $src, $handle ) {
93 unset( $handle );
94 return self::rewrite_asset( $src, 'css' );
95 }
96
97 public static function rewrite_script( $src, $handle ) {
98 unset( $handle );
99 return self::rewrite_asset( $src, 'js' );
100 }
101
102 /**
103 * Replace a local CSS/JS URL with a cached, minified equivalent.
104 *
105 * @param string $src Original asset URL.
106 * @param string $type 'css' or 'js'.
107 * @return string Possibly rewritten URL.
108 */
109 private static function rewrite_asset( $src, $type ) {
110 if ( ! is_string( $src ) || '' === $src ) {
111 return $src;
112 }
113
114 // Skip already-minified files.
115 if ( false !== strpos( $src, '.min.' ) ) {
116 return $src;
117 }
118
119 // Resolve to a local path; bail if external or unresolvable.
120 $path = self::url_to_path( $src );
121 if ( ! $path || ! is_readable( $path ) ) {
122 return $src;
123 }
124
125 // Build a cache filename keyed on path + mtime so edits invalidate.
126 $mtime = filemtime( $path );
127 $key = md5( $path . '|' . $mtime );
128 $cache = self::cache_path( $key, $type );
129
130 if ( ! file_exists( $cache ) ) {
131 $ok = self::minify_file( $path, $cache, $type );
132 if ( ! $ok ) {
133 return $src;
134 }
135 }
136
137 // Return a URL to the cached file. Built from known constants — never
138 // from str_replace on a filesystem path (which would assume the FS
139 // layout mirrors the URL layout).
140 return self::min_url() . '/' . $key . '.' . $type;
141 }
142
143 private static function minify_file( $source_path, $target_path, $type ) {
144 if ( ! class_exists( '\\MatthiasMullie\\Minify\\CSS' ) ) {
145 return false;
146 }
147
148 // Path-traversal guard: refuse to write anywhere outside our cache
149 // dir, even if a malicious filter ever produced a poisoned key.
150 $cache_root = self::min_dir();
151 self::ensure_dir( $cache_root );
152 $real_root = realpath( $cache_root );
153 $real_dir = realpath( dirname( $target_path ) );
154 if ( ! $real_root || ! $real_dir || 0 !== strpos( $real_dir, $real_root ) ) {
155 return false;
156 }
157
158 try {
159 $minifier = ( 'css' === $type )
160 ? new \MatthiasMullie\Minify\CSS( $source_path )
161 : new \MatthiasMullie\Minify\JS( $source_path );
162
163 $minified = $minifier->minify();
164
165 // Sanity check: paren/brace/bracket/backtick balance must be preserved.
166 // matthiasmullie/minify can silently truncate mid-template-literal on
167 // complex modern JS — bail rather than ship a broken file.
168 // 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.
169 $source = file_get_contents( $source_path );
170 if ( false === $source || ! self::balanced( $source, $minified ) ) {
171 return false;
172 }
173
174 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context; minification runs on frontend page renders.
175 $bytes = file_put_contents( $target_path, $minified );
176 return false !== $bytes && file_exists( $target_path );
177 } catch ( \Throwable $e ) {
178 return false;
179 }
180 }
181
182 /**
183 * Resolve a local asset URL to a filesystem path using a strict allowlist
184 * of "URL prefix → filesystem prefix" pairs registered with WordPress.
185 *
186 * We never assume `site_url()` maps to `ABSPATH` (the WordPress root can
187 * live above the document root in Bedrock-style installs, behind a proxy,
188 * or on multisite with mapped domains). Each branch resolves through a
189 * known WP API (plugins, themes, content, includes) and validates that
190 * `realpath()` of the result still lives under the expected base — so a
191 * crafted `..`-laden URL cannot escape into the filesystem.
192 *
193 * @param string $url Asset URL (may be protocol-relative or absolute).
194 * @return string|false Absolute filesystem path on success, false otherwise.
195 */
196 private static function url_to_path( $url ) {
197 if ( ! is_string( $url ) || '' === $url ) {
198 return false;
199 }
200
201 // Drop query string + fragment.
202 $clean = strtok( $url, '?#' );
203
204 // Normalise protocol-relative + scheme variants of the host so we
205 // match regardless of whether the asset URL came in over http/https.
206 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
207 if ( 0 === strpos( $clean, '//' ) ) {
208 $clean = 'https:' . $clean;
209 }
210 if ( $site_host ) {
211 $asset_host = wp_parse_url( $clean, PHP_URL_HOST );
212 if ( $asset_host && $asset_host !== $site_host ) {
213 return false; // External asset — never touch.
214 }
215 }
216
217 $candidates = array(
218 array( plugins_url(), WP_PLUGIN_DIR ),
219 array( get_stylesheet_directory_uri(), get_stylesheet_directory() ),
220 array( get_template_directory_uri(), get_template_directory() ),
221 array( content_url(), WP_CONTENT_DIR ),
222 array( includes_url(), ABSPATH . WPINC ),
223 );
224
225 foreach ( $candidates as $pair ) {
226 list( $url_base, $path_base ) = $pair;
227 if ( ! $url_base || ! $path_base ) {
228 continue;
229 }
230 $url_base = rtrim( $url_base, '/' );
231 if ( 0 !== strpos( $clean, $url_base . '/' ) && $clean !== $url_base ) {
232 continue;
233 }
234
235 $relative = ltrim( substr( $clean, strlen( $url_base ) ), '/' );
236 $candidate = trailingslashit( $path_base ) . $relative;
237
238 $real_base = realpath( $path_base );
239 $real = realpath( $candidate );
240 if ( ! $real_base || ! $real ) {
241 return false;
242 }
243 // Guard against `..`-traversal: resolved path must stay inside
244 // the registered base.
245 if ( 0 !== strpos( $real, $real_base ) ) {
246 return false;
247 }
248 return $real;
249 }
250
251 return false;
252 }
253
254 private static function cache_path( $key, $type ) {
255 return self::min_dir() . '/' . $key . '.' . $type;
256 }
257
258 private static function ensure_dir( $dir ) {
259 if ( ! file_exists( $dir ) ) {
260 wp_mkdir_p( $dir );
261 Cache::write_silence( $dir );
262 }
263 }
264
265 public static function purge_minified() {
266 $dir = self::min_dir();
267 if ( ! is_dir( $dir ) ) {
268 return;
269 }
270 foreach ( glob( $dir . '/*' ) as $file ) {
271 wp_delete_file( $file );
272 }
273 }
274 }
275