PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.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 All 35 releases
xspeed / includes / class-asset-manifest.php

class-asset-manifest.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.0, at includes/class-asset-manifest.php

589 lines 21.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Asset_Manifest: content keys for source files, validated by stat.
4 *
5 * Minified and combined files used to be named after the source's path and
6 * mtime. That name says nothing about the bytes:
7 *
8 * - An edit that keeps the mtime (rsync -t, a restore, some deploy tools)
9 * keeps the old name, so a regenerated file serves new bytes under a URL
10 * browsers and CDNs have cached for a year.
11 * - An edit to a file the source pulls in (an @import child, a small image
12 * the minifier embeds as a data: URI) changes nothing in the name at all.
13 * - A touch with no change mints a new name, so every cached page linking
14 * the old one has to go.
15 *
16 * So output is named after its content instead, and this class answers
17 * "what is the content key of this source?" without hashing on every render.
18 * Per source it keeps a small manifest:
19 *
20 * {
21 * v: XSPEED_VERSION + schema,
22 * kind: what the key is for ('css', 'js', 'part-css', 'part-js'),
23 * src: the source path,
24 * sig: { path: [mtime, ctime, size, ino] | null } for the source and
25 * every dependency, null for a candidate that does not exist yet,
26 * src_md5: md5 over the source and dependency bytes,
27 * key: the content key (md5 of the minified output, or src_md5), or
28 * '' when these bytes could not be minified,
29 * at: when the signature was taken,
30 * }
31 *
32 * On render the signature is re-taken with stat() alone. When it matches and
33 * is older than RACY_SECONDS, the stored key is trusted. Otherwise the bytes
34 * are hashed, and the caller rebuilds only when the hash differs. That is the
35 * git index rule, including its "racy" clause: a file changed within the same
36 * clock tick as the signature can keep its mtime and size, so a signature
37 * taken that close to a write is never trusted on its own.
38 *
39 * Manifests live per blog under `min/manifests/<blog_id>/`, so one subsite
40 * can drop its own without touching another's. Outputs stay shared: the same
41 * bytes have the same name whichever blog produced them.
42 *
43 * @package XSpeed
44 */
45
46 declare(strict_types=1);
47
48 namespace XSpeed;
49
50 defined( 'ABSPATH' ) || exit;
51
52 final class Asset_Manifest {
53
54 /** Bump when the manifest shape or the key derivation changes. */
55 public const SCHEMA = 1;
56
57 /** A signature this close to a file's own timestamps is not trusted. */
58 public const RACY_SECONDS = 2;
59
60 /** Subdirectory of min/ that holds the manifests. */
61 public const SUBDIR = 'manifests';
62
63 /**
64 * Extensions matthiasmullie/minify embeds as data: URIs (CSS.php
65 * `$importExtensions`). Mirrored rather than read from the library, which
66 * keeps it protected.
67 */
68 private const EMBEDDABLE = array(
69 'gif',
70 'png',
71 'jpe',
72 'jpg',
73 'jpeg',
74 'svg',
75 'woff',
76 'woff2',
77 'avif',
78 'apng',
79 'webp',
80 'tif',
81 'tiff',
82 'xbm',
83 );
84
85 /** Deepest @import chain the dependency walk follows. */
86 private const MAX_WALK_DEPTH = 16;
87
88 /**
89 * Fixed "now", set only by tests to step past the racy window without
90 * sleeping.
91 *
92 * @var int|null
93 */
94 private static $clock = null;
95
96 /**
97 * Freeze the clock at $now, or unfreeze it with null. Tests only.
98 *
99 * @param int|null $now Timestamp.
100 */
101 public static function freeze_clock( ?int $now ): void {
102 self::$clock = $now;
103 }
104
105 /** Current time, honouring a frozen clock. */
106 private static function now(): int {
107 return null === self::$clock ? time() : self::$clock;
108 }
109
110 /** Version stamp every manifest carries. */
111 public static function version(): string {
112 return ( defined( 'XSPEED_VERSION' ) ? (string) XSPEED_VERSION : '0' ) . '+' . self::SCHEMA;
113 }
114
115 /** Blog whose manifests this request reads and writes. */
116 public static function blog_id(): int {
117 if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_current_blog_id' ) ) {
118 return max( 1, (int) get_current_blog_id() );
119 }
120 return 1;
121 }
122
123 /** Root of every blog's manifests. */
124 public static function root(): string {
125 return rtrim( (string) XSPEED_CACHE_DIR, '/' ) . '/min/' . self::SUBDIR;
126 }
127
128 /**
129 * Manifest directory for one blog.
130 *
131 * @param int|null $blog_id Blog, or the current one.
132 */
133 public static function dir( ?int $blog_id = null ): string {
134 return self::root() . '/' . ( null === $blog_id ? self::blog_id() : max( 1, $blog_id ) );
135 }
136
137 /**
138 * Manifest file for one source.
139 *
140 * @param string $kind What the key is for ('css', 'js', 'part-css', 'part-js').
141 * @param string $source Absolute source path.
142 */
143 public static function file( string $kind, string $source ): string {
144 return self::dir() . '/' . md5( $kind . '|' . $source ) . '.json';
145 }
146
147 /**
148 * Content key for a source, rebuilding only when its bytes changed.
149 *
150 * @param string $kind Manifest kind ('css', 'js', 'part-css', 'part-js').
151 * @param string $source Absolute source path.
152 * @param callable $find_deps fn( string $source ): string[], the dependency paths.
153 * @param callable|null $build fn( string $source ): string|null|false. Builds
154 * the output and returns its key; null when the
155 * bytes cannot be built, false when the output
156 * could not be written. Null here means the key
157 * IS the source hash and there is nothing to build.
158 * @param callable|null $has_output fn( string $key ): bool. Whether the output for
159 * a key is still on disk.
160 * @return string|null Key, or null when the source is unreadable or the build failed.
161 */
162 public static function key_for( string $kind, string $source, callable $find_deps, ?callable $build = null, ?callable $has_output = null ): ?string {
163 $file = self::file( $kind, $source );
164 $stored = self::read( $file );
165 $now = self::now();
166
167 $output_ok = static function ( string $key ) use ( $has_output ): bool {
168 return null === $has_output || (bool) $has_output( $key );
169 };
170
171 // Fast path: stat only. A recorded failure is an answer too: the same
172 // bytes would fail the same way, so they are not rebuilt every render.
173 if ( null !== $stored && self::is_fresh( $stored, $now ) ) {
174 if ( '' === (string) $stored['key'] ) {
175 return null;
176 }
177 if ( $output_ok( (string) $stored['key'] ) ) {
178 return (string) $stored['key'];
179 }
180 }
181
182 // Slow path. Stat BEFORE reading: a write that lands between the two
183 // leaves a signature older than the bytes, so the next render sees a
184 // mismatch and hashes again. The other order could pin new bytes
185 // under an old signature.
186 $paths = array_merge( array( $source ), self::unique_paths( (array) $find_deps( $source ), $source ) );
187 $sig = self::signature( $paths );
188 if ( null === $sig[ $source ] ) {
189 return null;
190 }
191 $src_md5 = self::content_hash( $paths );
192 if ( null === $src_md5 ) {
193 return null;
194 }
195
196 $same_bytes = null !== $stored
197 && self::version() === ( $stored['v'] ?? '' )
198 && ( $stored['src_md5'] ?? '' ) === $src_md5;
199 $stored_key = null === $stored ? '' : (string) ( $stored['key'] ?? '' );
200
201 if ( $same_bytes && '' !== $stored_key && $output_ok( $stored_key ) ) {
202 $key = $stored_key;
203 } elseif ( $same_bytes && '' === $stored_key ) {
204 // These bytes failed before; they still would.
205 $key = '';
206 } elseif ( null === $build ) {
207 $key = $src_md5;
208 } else {
209 $built = $build( $source );
210 // false: the output could not be WRITTEN. That can clear up (a
211 // full disk, a permission fixed), so nothing is recorded.
212 if ( false === $built ) {
213 return null;
214 }
215 // null: these bytes cannot be minified. Recorded as an empty key,
216 // so the fast path answers "no" without minifying again until the
217 // bytes change.
218 $key = is_string( $built ) ? $built : '';
219 }
220
221 $manifest = array(
222 'v' => self::version(),
223 'kind' => $kind,
224 'src' => $source,
225 'sig' => $sig,
226 'src_md5' => $src_md5,
227 'key' => $key,
228 'at' => $now,
229 );
230
231 // Unchanged and still inside the racy window: rewriting would only
232 // store another racy signature, once per render, until the window
233 // passes. Leave it; the first render after the window writes it.
234 $unchanged = null !== $stored
235 && ( $stored['sig'] ?? null ) === $sig
236 && ( $stored['src_md5'] ?? '' ) === $src_md5
237 && ( $stored['key'] ?? '' ) === $key
238 && self::version() === ( $stored['v'] ?? '' );
239 if ( ! ( $unchanged && self::is_racy( $sig, $now ) ) ) {
240 self::write( $file, $manifest );
241 }
242
243 return '' === $key ? null : $key;
244 }
245
246 /**
247 * Whether a stored manifest can be trusted without reading any bytes.
248 *
249 * @param array<string,mixed> $manifest Stored manifest.
250 * @param int $now Current time.
251 */
252 public static function is_fresh( array $manifest, int $now ): bool {
253 if ( self::version() !== ( $manifest['v'] ?? '' ) || ! isset( $manifest['key'] ) || ! is_string( $manifest['key'] ) || ! is_array( $manifest['sig'] ?? null ) ) {
254 return false;
255 }
256 // A signature from the future means the clock moved back since it
257 // was taken; nothing about it can be trusted.
258 $at = (int) ( $manifest['at'] ?? 0 );
259 if ( $at > $now ) {
260 return false;
261 }
262 $stored = $manifest['sig'];
263 if ( self::signature( array_keys( $stored ) ) !== $stored ) {
264 return false;
265 }
266 // Racy is judged against when the signature was TAKEN, not now: a
267 // file written in the same tick as the stat may have changed after it
268 // without moving mtime or size.
269 return ! self::is_racy( $stored, $at );
270 }
271
272 /**
273 * Is any timestamp in the signature within RACY_SECONDS of $at?
274 *
275 * @param array<string,mixed> $sig Signature.
276 * @param int $at When it was taken.
277 */
278 public static function is_racy( array $sig, int $at ): bool {
279 foreach ( $sig as $entry ) {
280 if ( ! is_array( $entry ) ) {
281 continue;
282 }
283 $newest = max( (int) ( $entry[0] ?? 0 ), (int) ( $entry[1] ?? 0 ) );
284 if ( $at - $newest < self::RACY_SECONDS ) {
285 return true;
286 }
287 }
288 return false;
289 }
290
291 /**
292 * [mtime, ctime, size, ino] per path, null for a path that is not a file.
293 *
294 * @param string[] $paths Paths.
295 * @return array<string,array<int,int>|null>
296 */
297 public static function signature( array $paths ): array {
298 $sig = array();
299 foreach ( $paths as $path ) {
300 $path = (string) $path;
301 clearstatcache( true, $path );
302 $stat = @stat( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a missing dependency is an expected answer (null), not an error.
303 if ( false === $stat || ! is_file( $path ) ) {
304 $sig[ $path ] = null;
305 continue;
306 }
307 $sig[ $path ] = array( (int) $stat['mtime'], (int) $stat['ctime'], (int) $stat['size'], (int) $stat['ino'] );
308 }
309 return $sig;
310 }
311
312 /**
313 * md5 over the bytes of every path, in order. A missing dependency counts
314 * as a fixed marker, so it appearing later changes the hash.
315 *
316 * @param string[] $paths Source first, then dependencies.
317 * @return string|null Null when the source itself cannot be read.
318 */
319 public static function content_hash( array $paths ): ?string {
320 $ctx = hash_init( 'md5' );
321 $first = true;
322 foreach ( $paths as $path ) {
323 $path = (string) $path;
324 hash_update( $ctx, "\0" . $path . "\0" );
325 $ok = is_file( $path ) && is_readable( $path ) && @hash_update_file( $ctx, $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable dependency is folded into the hash as missing.
326 if ( ! $ok ) {
327 if ( $first ) {
328 return null;
329 }
330 hash_update( $ctx, "\0missing\0" );
331 }
332 $first = false;
333 }
334 return hash_final( $ctx );
335 }
336
337 /**
338 * Every file a minified stylesheet's bytes depend on, recursively.
339 *
340 * Mirrors matthiasmullie/minify's CSS::combineImports() and importFiles():
341 *
342 * - `@import` (url() or quoted) whose path is not data:, http(s): or
343 * root-relative and carries no query. The library inlines those.
344 * Their own imports and embeds are followed in turn.
345 * - `url()` with an embeddable extension (or none), of ANY size: the
346 * library embeds only files up to 5 KB, and a file that grows past
347 * that, or shrinks under it, changes the output too.
348 *
349 * Candidates that do not exist yet are kept. The library skips them, but
350 * creating one later changes the output, so its absence is part of the
351 * signature.
352 *
353 * @param string $source Absolute stylesheet path.
354 * @return string[] Dependency paths, source excluded.
355 */
356 public static function css_dependencies( string $source ): array {
357 $found = array();
358 $visited = array( $source => true );
359 self::walk_css( $source, $found, $visited, 0 );
360 unset( $found[ $source ] );
361 return array_keys( $found );
362 }
363
364 /**
365 * @param string $source Stylesheet being scanned.
366 * @param array<string,true> $found Accumulated dependencies.
367 * @param array<string,true> $visited Stylesheets already scanned.
368 * @param int $depth Import depth.
369 */
370 private static function walk_css( string $source, array &$found, array &$visited, int $depth ): void {
371 if ( $depth > self::MAX_WALK_DEPTH ) {
372 return;
373 }
374 $css = @file_get_contents( $source ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- unreadable file has no dependencies; reading a local stylesheet during page render; WP_Filesystem needs admin context.
375 if ( ! is_string( $css ) || '' === $css ) {
376 return;
377 }
378 $dir = dirname( $source );
379
380 // Same two patterns as CSS::combineImports().
381 $import_patterns = array(
382 '/@import\s+url\((?P<quotes>["\']?)(?P<path>.+?)(?P=quotes)\)\s*(?P<media>[^;]*)\s*;?/ix',
383 '/@import\s+(?P<quotes>["\'])(?P<path>.+?)(?P=quotes)\s*(?P<media>[^;]*)\s*;?/ix',
384 );
385 foreach ( $import_patterns as $pattern ) {
386 if ( ! preg_match_all( $pattern, $css, $matches, PREG_SET_ORDER ) ) {
387 continue;
388 }
389 foreach ( $matches as $match ) {
390 $rel = (string) $match['path'];
391 // Same test as the library's CSS::canImportByPath.
392 if ( 1 === preg_match( '/^(data:|https?:|\\/)/', $rel ) ) {
393 continue;
394 }
395 $candidate = self::candidate( $dir, $rel );
396 if ( null === $candidate ) {
397 continue;
398 }
399 $found[ $candidate ] = true;
400 if ( ! isset( $visited[ $candidate ] ) && is_file( $candidate ) ) {
401 $visited[ $candidate ] = true;
402 self::walk_css( $candidate, $found, $visited, $depth + 1 );
403 }
404 }
405 }
406
407 // Same pattern and extension rule as CSS::importFiles().
408 if ( preg_match_all( '/url\((["\']?)(.+?)\\1\)/i', $css, $urls, PREG_SET_ORDER ) ) {
409 foreach ( $urls as $match ) {
410 $rel = (string) $match[2];
411 if ( 0 === stripos( $rel, 'data:' ) ) {
412 continue;
413 }
414 $dot = strrchr( $rel, '.' );
415 $extension = false === $dot ? '' : substr( $dot, 1 );
416 if ( '' !== $extension && ! in_array( $extension, self::EMBEDDABLE, true ) ) {
417 continue;
418 }
419 $candidate = self::candidate( $dir, $rel );
420 if ( null !== $candidate ) {
421 $found[ $candidate ] = true;
422 }
423 }
424 }
425 }
426
427 /**
428 * The path the library would try for a reference, or null when it would
429 * never read one (remote, or carrying a query). Same construction as the
430 * library: dirname(source) . '/' . reference, unnormalised.
431 *
432 * @param string $dir Directory of the referencing file.
433 * @param string $rel Reference as written.
434 */
435 private static function candidate( string $dir, string $rel ): ?string {
436 $path = $dir . '/' . $rel;
437 if ( strlen( $path ) >= PHP_MAXPATHLEN ) {
438 return null;
439 }
440 $parsed = wp_parse_url( $path );
441 if ( ! is_array( $parsed ) || isset( $parsed['host'] ) || isset( $parsed['query'] ) ) {
442 return null;
443 }
444 return $path;
445 }
446
447 /**
448 * Files the combiner inlines into a part through `@import`, to the same
449 * depth it follows them (Asset_Combiner::resolve_imports()).
450 *
451 * Resolution mirrors the combiner: each reference is resolved against the
452 * part's URL, and a URL under home_url() maps to ABSPATH. Candidates that
453 * do not exist yet are kept, for the same reason as css_dependencies().
454 *
455 * @param string $url Absolute URL of the part.
456 * @return string[] Dependency paths.
457 */
458 public static function import_dependencies( string $url ): array {
459 $found = array();
460 self::walk_imports( $url, $found, 0 );
461 return array_keys( $found );
462 }
463
464 /**
465 * @param string $url URL whose file is scanned.
466 * @param array<string,true> $found Accumulated dependencies.
467 * @param int $depth Same counter resolve_imports() uses.
468 */
469 private static function walk_imports( string $url, array &$found, int $depth ): void {
470 if ( $depth > Asset_Combiner::MAX_IMPORT_DEPTH ) {
471 return;
472 }
473 $path = self::url_to_candidate( $url );
474 if ( null === $path || ! is_file( $path ) ) {
475 return;
476 }
477 $css = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- unreadable file has no dependencies; reading a local stylesheet during page render; WP_Filesystem needs admin context.
478 if ( ! is_string( $css ) || false === stripos( $css, '@import' ) ) {
479 return;
480 }
481 if ( ! preg_match_all( '#@import\s+(?:url\s*\(\s*)?["\']?([^"\')]+)["\']?\s*\)?\s*([^;]*);#i', $css, $matches, PREG_SET_ORDER ) ) {
482 return;
483 }
484 foreach ( $matches as $match ) {
485 $child_url = Asset_Combiner::resolve_relative( trim( (string) $match[1] ), $url );
486 $child = self::url_to_candidate( $child_url );
487 if ( null === $child || isset( $found[ $child ] ) ) {
488 continue;
489 }
490 $found[ $child ] = true;
491 self::walk_imports( $child_url, $found, $depth + 1 );
492 }
493 }
494
495 /**
496 * Asset_Combiner::local_info()'s mapping without its existence check.
497 *
498 * @param string $url Absolute URL.
499 */
500 private static function url_to_candidate( string $url ): ?string {
501 $home = home_url();
502 if ( '' === $url || 0 !== strpos( $url, $home ) ) {
503 return null;
504 }
505 $clean = strtok( $url, '?' );
506 if ( ! is_string( $clean ) ) {
507 return null;
508 }
509 return ABSPATH . ltrim( str_replace( $home, '', $clean ), '/' );
510 }
511
512 /**
513 * Read a manifest, or null when absent or unreadable.
514 *
515 * @param string $file Manifest path.
516 * @return array<string,mixed>|null
517 */
518 public static function read( string $file ): ?array {
519 $raw = @file_get_contents( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a missing manifest is the normal first-render answer; our own cache sidecar, read on the front end where WP_Filesystem is unavailable.
520 if ( ! is_string( $raw ) || '' === $raw ) {
521 return null;
522 }
523 $decoded = json_decode( $raw, true );
524 return is_array( $decoded ) ? $decoded : null;
525 }
526
527 /**
528 * Write a manifest atomically.
529 *
530 * @param string $file Manifest path.
531 * @param array<string,mixed> $manifest Contents.
532 */
533 public static function write( string $file, array $manifest ): bool {
534 $dir = dirname( $file );
535 if ( ! is_dir( $dir ) ) {
536 wp_mkdir_p( $dir );
537 }
538 $json = wp_json_encode( $manifest );
539 return is_string( $json ) && self::write_atomic( $file, $json );
540 }
541
542 /**
543 * Write bytes so a reader sees either nothing or the whole file.
544 *
545 * Writes a temp file in the SAME directory (rename() is only atomic
546 * within one filesystem), checks every byte landed, then renames it over
547 * the target. A full disk or a quota truncates the temp file, which is
548 * then discarded rather than published.
549 *
550 * @param string $file Target path.
551 * @param string $bytes Contents.
552 */
553 public static function write_atomic( string $file, string $bytes ): bool {
554 $dir = dirname( $file );
555 if ( ! is_dir( $dir ) ) {
556 return false;
557 }
558 $tmp = $file . '.' . bin2hex( random_bytes( 6 ) ) . '.tmp';
559 $written = @file_put_contents( $tmp, $bytes ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- failure is reported through the return value; WP_Filesystem requires admin context; this runs on front-end renders.
560 if ( strlen( $bytes ) !== $written ) {
561 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- discarding our own partial temp file.
562 return false;
563 }
564 if ( ! @rename( $tmp, $file ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- failure is reported through the return value; atomic publish of our own cache file; WP_Filesystem::move() is not atomic and needs admin context.
565 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- discarding our own temp file.
566 return false;
567 }
568 return true;
569 }
570
571 /**
572 * Dependencies in first-seen order, without the source or duplicates.
573 *
574 * @param array<int,mixed> $deps Dependency paths.
575 * @param string $source Source path.
576 * @return string[]
577 */
578 private static function unique_paths( array $deps, string $source ): array {
579 $out = array();
580 foreach ( $deps as $dep ) {
581 $dep = (string) $dep;
582 if ( '' !== $dep && $dep !== $source ) {
583 $out[ $dep ] = true;
584 }
585 }
586 return array_keys( $out );
587 }
588 }
589