| 1 |
<?php |
| 2 |
/** |
| 3 |
* Cache_Inventory — what is actually in the cache, and what it costs. |
| 4 |
* |
| 5 |
* The four stat cards on the dashboard answer "how much"; this answers |
| 6 |
* "which". A number like "412 cached pages" is only trustworthy if you |
| 7 |
* can open it and see the 412, and a size of "8.4 MB" is only actionable |
| 8 |
* if you can see what is taking the space. |
| 9 |
* |
| 10 |
* Two storage shapes have to be reconciled here: |
| 11 |
* |
| 12 |
* flat XSPEED_CACHE_DIR/<md5>.html written by the PHP path |
| 13 |
* static XSPEED_CACHE_STATIC_DIR/<host><uri>/index.html |
| 14 |
* served by the web server |
| 15 |
* |
| 16 |
* The static tree encodes the URL in its path, so those entries are exact. |
| 17 |
* The flat tree is keyed by md5( host . uri . device . … ), which cannot be |
| 18 |
* reversed — so for those we read the first HEAD_BYTES of the body and pull |
| 19 |
* the canonical/og:url out of the markup. That resolves the overwhelming |
| 20 |
* majority of real pages, and an entry we cannot name is reported with a |
| 21 |
* null URL rather than a guess. |
| 22 |
* |
| 23 |
* Cost control: scanning is capped (SCAN_CAP) and the result memoized for |
| 24 |
* CACHE_TTL, because this runs on an admin click, not on a page render. |
| 25 |
* |
| 26 |
* @package XSpeed |
| 27 |
*/ |
| 28 |
|
| 29 |
declare(strict_types=1); |
| 30 |
|
| 31 |
namespace XSpeed; |
| 32 |
|
| 33 |
defined( 'ABSPATH' ) || exit; |
| 34 |
|
| 35 |
final class Cache_Inventory { |
| 36 |
|
| 37 |
/** |
| 38 |
* Hard ceiling on entries examined in one pass. A site with 50k cached |
| 39 |
* pages must not turn one dashboard click into a 50k-file stat storm; |
| 40 |
* the response says `capped: true` so the UI can say so out loud |
| 41 |
* instead of quietly showing a partial list as if it were the whole. |
| 42 |
*/ |
| 43 |
public const SCAN_CAP = 750; |
| 44 |
|
| 45 |
/** Bytes of each flat entry read to look for a canonical URL. */ |
| 46 |
public const HEAD_BYTES = 16384; |
| 47 |
|
| 48 |
/** Memoization window for a full scan, in seconds. */ |
| 49 |
public const CACHE_TTL = 60; |
| 50 |
|
| 51 |
public const TRANSIENT_KEY = 'xspeed_cache_inventory'; |
| 52 |
|
| 53 |
/** Activity-log event types that represent a purge. */ |
| 54 |
private const PURGE_TYPES = array( 'cache_purged', 'cache_purge_url', 'cache_purged_url' ); |
| 55 |
|
| 56 |
/** |
| 57 |
* Resolve a static-tree file back to the URL that produced it. |
| 58 |
* |
| 59 |
* `<root>/example.com/blog/post/index.html` → `https://example.com/blog/post/`. |
| 60 |
* Returns null for anything that isn't shaped like a static entry, so a |
| 61 |
* stray file in the tree can't become a bogus row. |
| 62 |
* |
| 63 |
* Pure — no filesystem access, so the path logic is testable on its own. |
| 64 |
*/ |
| 65 |
public static function url_from_static_path( string $file, string $root, bool $https = true ): ?string { |
| 66 |
$root = rtrim( str_replace( '\\', '/', $root ), '/' ); |
| 67 |
$file = str_replace( '\\', '/', $file ); |
| 68 |
|
| 69 |
if ( '' === $root || 0 !== strpos( $file, $root . '/' ) ) { |
| 70 |
return null; |
| 71 |
} |
| 72 |
if ( substr( $file, -11 ) !== '/index.html' ) { |
| 73 |
return null; |
| 74 |
} |
| 75 |
|
| 76 |
$rel = substr( $file, strlen( $root ) + 1, -11 ); |
| 77 |
if ( '' === $rel ) { |
| 78 |
return null; |
| 79 |
} |
| 80 |
|
| 81 |
$segments = explode( '/', $rel ); |
| 82 |
$host = array_shift( $segments ); |
| 83 |
// Same allowlist store_static() writes with — anything else is not |
| 84 |
// ours and must not be presented as a cached page. |
| 85 |
if ( null === $host || '' === $host || preg_match( '/[^a-zA-Z0-9.\-]/', $host ) ) { |
| 86 |
return null; |
| 87 |
} |
| 88 |
|
| 89 |
$path = '' === implode( '/', $segments ) ? '/' : '/' . implode( '/', $segments ) . '/'; |
| 90 |
|
| 91 |
return ( $https ? 'https://' : 'http://' ) . $host . $path; |
| 92 |
} |
| 93 |
|
| 94 |
/** |
| 95 |
* Pull a page URL out of the top of a cached document. |
| 96 |
* |
| 97 |
* Prefers `<link rel="canonical">` — WordPress emits it for singular |
| 98 |
* views and it is what the site itself considers the page's address. |
| 99 |
* Falls back to `og:url`. Returns null rather than guessing from, say, |
| 100 |
* the first anchor in the body. |
| 101 |
* |
| 102 |
* Pure. |
| 103 |
*/ |
| 104 |
public static function extract_url_from_html( string $head ): ?string { |
| 105 |
if ( preg_match( '#<link[^>]+rel=["\']canonical["\'][^>]*>#i', $head, $tag ) ) { |
| 106 |
if ( preg_match( '#href=["\']([^"\']+)["\']#i', $tag[0], $href ) ) { |
| 107 |
$url = html_entity_decode( trim( $href[1] ), ENT_QUOTES ); |
| 108 |
if ( 0 === stripos( $url, 'http' ) ) { |
| 109 |
return $url; |
| 110 |
} |
| 111 |
} |
| 112 |
} |
| 113 |
if ( preg_match( '#<meta[^>]+property=["\']og:url["\'][^>]*>#i', $head, $tag ) ) { |
| 114 |
if ( preg_match( '#content=["\']([^"\']+)["\']#i', $tag[0], $content ) ) { |
| 115 |
$url = html_entity_decode( trim( $content[1] ), ENT_QUOTES ); |
| 116 |
if ( 0 === stripos( $url, 'http' ) ) { |
| 117 |
return $url; |
| 118 |
} |
| 119 |
} |
| 120 |
} |
| 121 |
return null; |
| 122 |
} |
| 123 |
|
| 124 |
/** |
| 125 |
* Merge the two storage shapes into one row per page. |
| 126 |
* |
| 127 |
* A page served by the web-server rewrite usually exists in BOTH trees. |
| 128 |
* Listing it twice would make the drill-down disagree with the stat card |
| 129 |
* it was opened from, so rows are keyed by URL when we know it: the |
| 130 |
* newest mtime wins for "age", disk bytes add up, and `stored_in` records |
| 131 |
* which copies exist. |
| 132 |
* |
| 133 |
* Entries whose URL is unknown can't be merged with anything — they stay |
| 134 |
* distinct, keyed by their own path. |
| 135 |
* |
| 136 |
* Pure: takes already-collected rows, returns merged rows. |
| 137 |
* |
| 138 |
* @param array<int,array<string,mixed>> $rows Raw rows from either tree. |
| 139 |
* @return array<int,array<string,mixed>> Merged, newest-first. |
| 140 |
*/ |
| 141 |
public static function merge_rows( array $rows ): array { |
| 142 |
$merged = array(); |
| 143 |
|
| 144 |
foreach ( $rows as $row ) { |
| 145 |
$url = isset( $row['url'] ) && is_string( $row['url'] ) ? $row['url'] : null; |
| 146 |
$key = null !== $url ? 'u:' . $url : 'p:' . (string) ( $row['path'] ?? '' ); |
| 147 |
|
| 148 |
if ( ! isset( $merged[ $key ] ) ) { |
| 149 |
$merged[ $key ] = $row; |
| 150 |
continue; |
| 151 |
} |
| 152 |
|
| 153 |
$existing = $merged[ $key ]; |
| 154 |
$existing['bytes'] = (int) $existing['bytes'] + (int) $row['bytes']; |
| 155 |
$existing['mtime'] = max( (int) $existing['mtime'], (int) $row['mtime'] ); |
| 156 |
$existing['stored_in'] = array_values( array_unique( array_merge( (array) $existing['stored_in'], (array) $row['stored_in'] ) ) ); |
| 157 |
$existing['compressed'] += (int) $row['compressed']; |
| 158 |
sort( $existing['stored_in'] ); |
| 159 |
$merged[ $key ] = $existing; |
| 160 |
} |
| 161 |
|
| 162 |
$out = array_values( $merged ); |
| 163 |
usort( |
| 164 |
$out, |
| 165 |
static function ( array $a, array $b ): int { |
| 166 |
return (int) $b['mtime'] <=> (int) $a['mtime']; |
| 167 |
} |
| 168 |
); |
| 169 |
|
| 170 |
return $out; |
| 171 |
} |
| 172 |
|
| 173 |
/** |
| 174 |
* The cached-pages drill-down. |
| 175 |
* |
| 176 |
* @param int $limit Rows returned. |
| 177 |
* @param int $offset Rows skipped. |
| 178 |
* @param bool $fresh Bypass the memoized scan. |
| 179 |
* @return array{entries:array<int,array<string,mixed>>,total:int,capped:bool,generated:int} |
| 180 |
*/ |
| 181 |
public static function entries( int $limit = 50, int $offset = 0, bool $fresh = false ): array { |
| 182 |
$rows = $fresh ? null : get_transient( self::TRANSIENT_KEY ); |
| 183 |
if ( ! is_array( $rows ) || ! isset( $rows['entries'] ) ) { |
| 184 |
$rows = self::scan(); |
| 185 |
set_transient( self::TRANSIENT_KEY, $rows, self::CACHE_TTL ); |
| 186 |
} |
| 187 |
|
| 188 |
$all = is_array( $rows['entries'] ) ? $rows['entries'] : array(); |
| 189 |
$limit = max( 1, min( 200, $limit ) ); |
| 190 |
$offset = max( 0, $offset ); |
| 191 |
$now = time(); |
| 192 |
|
| 193 |
$page = array_slice( $all, $offset, $limit ); |
| 194 |
foreach ( $page as $i => $entry ) { |
| 195 |
$page[ $i ]['age'] = max( 0, $now - (int) $entry['mtime'] ); |
| 196 |
} |
| 197 |
|
| 198 |
return array( |
| 199 |
'entries' => array_values( $page ), |
| 200 |
'total' => count( $all ), |
| 201 |
'capped' => (bool) ( $rows['capped'] ?? false ), |
| 202 |
'generated' => (int) ( $rows['generated'] ?? $now ), |
| 203 |
); |
| 204 |
} |
| 205 |
|
| 206 |
/** |
| 207 |
* Walk both trees. Separated from entries() so the memoization and the |
| 208 |
* pagination stay readable, and so a test can drive the walk directly. |
| 209 |
* |
| 210 |
* @return array{entries:array<int,array<string,mixed>>,capped:bool,generated:int} |
| 211 |
*/ |
| 212 |
public static function scan(): array { |
| 213 |
$rows = array(); |
| 214 |
$scanned = 0; |
| 215 |
$capped = false; |
| 216 |
|
| 217 |
// Flat tree — md5-keyed, URL recovered from the markup. |
| 218 |
// Flat entries live in per-site buckets since #6 |
| 219 |
// (XSPEED_CACHE_DIR/<host>/<md5>.html); the legacy top-level layout |
| 220 |
// is still globbed so pre-#6 entries remain visible until they age out. |
| 221 |
$flat = defined( 'XSPEED_CACHE_DIR' ) |
| 222 |
? array_merge( |
| 223 |
(array) glob( XSPEED_CACHE_DIR . '/*.html' ), |
| 224 |
(array) glob( XSPEED_CACHE_DIR . '/*/*.html' ) |
| 225 |
) |
| 226 |
: array(); |
| 227 |
$flat = array_values( |
| 228 |
array_filter( |
| 229 |
$flat, |
| 230 |
static function ( $f ) { |
| 231 |
// min/ and rest/ are separate buckets, not page entries. |
| 232 |
return ! in_array( basename( dirname( (string) $f ) ), array( 'min', 'rest', 'combined' ), true ); |
| 233 |
} |
| 234 |
) |
| 235 |
); |
| 236 |
foreach ( $flat as $file ) { |
| 237 |
if ( $scanned >= self::SCAN_CAP ) { |
| 238 |
$capped = true; |
| 239 |
break; |
| 240 |
} |
| 241 |
++$scanned; |
| 242 |
|
| 243 |
$size = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A file purged between glob() and stat() is expected, not exceptional. |
| 244 |
$rows[] = array( |
| 245 |
'url' => self::read_url( $file ), |
| 246 |
'path' => $file, |
| 247 |
'key' => basename( $file, '.html' ), |
| 248 |
'bytes' => $size, |
| 249 |
'compressed' => self::sibling_size( $file . '.br' ), |
| 250 |
'mtime' => (int) @filemtime( $file ), // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Same race as filesize() above. |
| 251 |
'stored_in' => array( 'flat' ), |
| 252 |
); |
| 253 |
} |
| 254 |
|
| 255 |
// Static tree — URL is the path, so no read is needed at all. |
| 256 |
if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) && is_dir( XSPEED_CACHE_STATIC_DIR ) ) { |
| 257 |
$https = ! function_exists( 'home_url' ) || 0 === stripos( (string) home_url(), 'https://' ); |
| 258 |
self::walk_static( XSPEED_CACHE_STATIC_DIR, XSPEED_CACHE_STATIC_DIR, $https, $rows, $scanned, $capped ); |
| 259 |
} |
| 260 |
|
| 261 |
return array( |
| 262 |
'entries' => self::merge_rows( $rows ), |
| 263 |
'capped' => $capped, |
| 264 |
'generated' => time(), |
| 265 |
); |
| 266 |
} |
| 267 |
|
| 268 |
/** |
| 269 |
* Recursive walk of the static tree, mirroring Cache::rmtree_html()'s |
| 270 |
* traversal so the two can't disagree about what counts as an entry. |
| 271 |
* |
| 272 |
* @param array<int,array<string,mixed>> $rows Collected rows, by reference. |
| 273 |
* @param int $scanned Files examined so far, by reference. |
| 274 |
* @param bool $capped Set when SCAN_CAP is hit, by reference. |
| 275 |
*/ |
| 276 |
private static function walk_static( string $dir, string $root, bool $https, array &$rows, int &$scanned, bool &$capped ): void { |
| 277 |
if ( $capped ) { |
| 278 |
return; |
| 279 |
} |
| 280 |
$entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- An unreadable subdir yields no rows; it must not fatal the drill-down. |
| 281 |
if ( false === $entries ) { |
| 282 |
return; |
| 283 |
} |
| 284 |
|
| 285 |
foreach ( $entries as $entry ) { |
| 286 |
if ( '.' === $entry || '..' === $entry ) { |
| 287 |
continue; |
| 288 |
} |
| 289 |
$path = $dir . '/' . $entry; |
| 290 |
|
| 291 |
if ( is_dir( $path ) ) { |
| 292 |
self::walk_static( $path, $root, $https, $rows, $scanned, $capped ); |
| 293 |
if ( $capped ) { |
| 294 |
return; |
| 295 |
} |
| 296 |
continue; |
| 297 |
} |
| 298 |
if ( 'index.html' !== $entry ) { |
| 299 |
continue; |
| 300 |
} |
| 301 |
if ( $scanned >= self::SCAN_CAP ) { |
| 302 |
$capped = true; |
| 303 |
return; |
| 304 |
} |
| 305 |
++$scanned; |
| 306 |
|
| 307 |
$rows[] = array( |
| 308 |
'url' => self::url_from_static_path( $path, $root, $https ), |
| 309 |
'path' => $path, |
| 310 |
'key' => '', |
| 311 |
'bytes' => (int) @filesize( $path ), // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan(). |
| 312 |
'compressed' => self::sibling_size( $path . '.br' ), |
| 313 |
'mtime' => (int) @filemtime( $path ), // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan(). |
| 314 |
'stored_in' => array( 'static' ), |
| 315 |
); |
| 316 |
} |
| 317 |
} |
| 318 |
|
| 319 |
/** Size of a precompressed sibling, or 0 when there isn't one. */ |
| 320 |
private static function sibling_size( string $path ): int { |
| 321 |
if ( ! is_readable( $path ) ) { |
| 322 |
return 0; |
| 323 |
} |
| 324 |
return (int) @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan(). |
| 325 |
} |
| 326 |
|
| 327 |
/** |
| 328 |
* Read just enough of a cached document to find its canonical URL. |
| 329 |
* |
| 330 |
* Deliberately a partial read: WP_Filesystem::get_contents() would pull |
| 331 |
* whole pages into memory, and at SCAN_CAP entries the difference is |
| 332 |
* megabytes of pointless I/O for data we discard immediately. |
| 333 |
*/ |
| 334 |
private static function read_url( string $file ): ?string { |
| 335 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- Partial read (16KB of head markup); WP_Filesystem has no offset/length API and would read entire pages. |
| 336 |
$head = @file_get_contents( $file, false, null, 0, self::HEAD_BYTES ); |
| 337 |
if ( ! is_string( $head ) || '' === $head ) { |
| 338 |
return null; |
| 339 |
} |
| 340 |
return self::extract_url_from_html( $head ); |
| 341 |
} |
| 342 |
|
| 343 |
/** |
| 344 |
* Where the cache's disk usage actually goes. |
| 345 |
* |
| 346 |
* Buckets are what a user can act on — pages, their precompressed |
| 347 |
* siblings, the REST response cache, minified assets — not what the |
| 348 |
* code happens to write. `compressed_bytes` is real measured bytes from |
| 349 |
* `.br` siblings, never an estimate: a made-up compression ratio on a |
| 350 |
* dashboard is worse than no number. |
| 351 |
* |
| 352 |
* @return array<string,mixed> |
| 353 |
*/ |
| 354 |
public static function size_breakdown(): array { |
| 355 |
$buckets = array( |
| 356 |
'pages_flat' => array( |
| 357 |
'label' => __( 'Cached pages (PHP)', 'xspeed' ), |
| 358 |
'bytes' => 0, |
| 359 |
'files' => 0, |
| 360 |
), |
| 361 |
'pages_static' => array( |
| 362 |
'label' => __( 'Cached pages (server-served)', 'xspeed' ), |
| 363 |
'bytes' => 0, |
| 364 |
'files' => 0, |
| 365 |
), |
| 366 |
'precompressed' => array( |
| 367 |
'label' => __( 'Precompressed copies', 'xspeed' ), |
| 368 |
'bytes' => 0, |
| 369 |
'files' => 0, |
| 370 |
), |
| 371 |
'metadata' => array( |
| 372 |
'label' => __( 'Per-entry metadata', 'xspeed' ), |
| 373 |
'bytes' => 0, |
| 374 |
'files' => 0, |
| 375 |
), |
| 376 |
'rest' => array( |
| 377 |
'label' => __( 'REST responses', 'xspeed' ), |
| 378 |
'bytes' => 0, |
| 379 |
'files' => 0, |
| 380 |
), |
| 381 |
'assets' => array( |
| 382 |
'label' => __( 'Minified CSS/JS', 'xspeed' ), |
| 383 |
'bytes' => 0, |
| 384 |
'files' => 0, |
| 385 |
), |
| 386 |
); |
| 387 |
|
| 388 |
if ( defined( 'XSPEED_CACHE_DIR' ) ) { |
| 389 |
// Both layouts: per-site buckets (#6) and pre-#6 top level. |
| 390 |
self::add_glob( $buckets['pages_flat'], XSPEED_CACHE_DIR . '/*.html' ); |
| 391 |
self::add_glob( $buckets['metadata'], XSPEED_CACHE_DIR . '/*.meta' ); |
| 392 |
self::add_glob( $buckets['precompressed'], XSPEED_CACHE_DIR . '/*.br' ); |
| 393 |
self::add_glob( $buckets['pages_flat'], XSPEED_CACHE_DIR . '/*/*.html' ); |
| 394 |
self::add_glob( $buckets['metadata'], XSPEED_CACHE_DIR . '/*/*.meta' ); |
| 395 |
self::add_glob( $buckets['precompressed'], XSPEED_CACHE_DIR . '/*/*.br' ); |
| 396 |
self::add_glob( $buckets['rest'], XSPEED_CACHE_DIR . '/rest/*.json' ); |
| 397 |
self::add_glob( $buckets['assets'], XSPEED_CACHE_DIR . '/min/*.css' ); |
| 398 |
self::add_glob( $buckets['assets'], XSPEED_CACHE_DIR . '/min/*.js' ); |
| 399 |
self::add_glob( $buckets['assets'], XSPEED_CACHE_DIR . '/min/combined/*.css' ); |
| 400 |
self::add_glob( $buckets['assets'], XSPEED_CACHE_DIR . '/min/combined/*.js' ); |
| 401 |
} |
| 402 |
|
| 403 |
if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) && is_dir( XSPEED_CACHE_STATIC_DIR ) ) { |
| 404 |
$static = self::measure_static( XSPEED_CACHE_STATIC_DIR ); |
| 405 |
$buckets['pages_static']['bytes'] = $static['html_bytes']; |
| 406 |
$buckets['pages_static']['files'] = $static['html_files']; |
| 407 |
$buckets['precompressed']['bytes'] += $static['br_bytes']; |
| 408 |
$buckets['precompressed']['files'] += $static['br_files']; |
| 409 |
} |
| 410 |
|
| 411 |
$total_bytes = 0; |
| 412 |
$total_files = 0; |
| 413 |
$out = array(); |
| 414 |
foreach ( $buckets as $key => $bucket ) { |
| 415 |
$total_bytes += $bucket['bytes']; |
| 416 |
$total_files += $bucket['files']; |
| 417 |
$out[] = array( |
| 418 |
'key' => $key, |
| 419 |
'label' => $bucket['label'], |
| 420 |
'bytes' => $bucket['bytes'], |
| 421 |
'files' => $bucket['files'], |
| 422 |
); |
| 423 |
} |
| 424 |
|
| 425 |
return array( |
| 426 |
'buckets' => $out, |
| 427 |
'total_bytes' => $total_bytes, |
| 428 |
'total_files' => $total_files, |
| 429 |
// What a visitor actually downloads for the pages that have a |
| 430 |
// precompressed copy. Pages without one are served compressed by |
| 431 |
// the web server at request time, which we cannot measure from |
| 432 |
// here — so this is a floor, and the UI labels it as one. |
| 433 |
'compressed_bytes' => $buckets['precompressed']['bytes'], |
| 434 |
'pages' => $buckets['pages_flat']['files'] + $buckets['pages_static']['files'], |
| 435 |
); |
| 436 |
} |
| 437 |
|
| 438 |
/** |
| 439 |
* Accumulate a glob into a bucket. |
| 440 |
* |
| 441 |
* @param array{label:string,bytes:int,files:int} $bucket By reference. |
| 442 |
*/ |
| 443 |
private static function add_glob( array &$bucket, string $pattern ): void { |
| 444 |
foreach ( (array) glob( $pattern ) as $file ) { |
| 445 |
if ( ! is_string( $file ) ) { |
| 446 |
continue; |
| 447 |
} |
| 448 |
$bucket['bytes'] += (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan(). |
| 449 |
++$bucket['files']; |
| 450 |
} |
| 451 |
} |
| 452 |
|
| 453 |
/** |
| 454 |
* Total the static tree without collecting per-file rows. |
| 455 |
* |
| 456 |
* @return array{html_bytes:int,html_files:int,br_bytes:int,br_files:int} |
| 457 |
*/ |
| 458 |
private static function measure_static( string $dir ): array { |
| 459 |
$totals = array( |
| 460 |
'html_bytes' => 0, |
| 461 |
'html_files' => 0, |
| 462 |
'br_bytes' => 0, |
| 463 |
'br_files' => 0, |
| 464 |
); |
| 465 |
$entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Unreadable subdir contributes nothing rather than fataling. |
| 466 |
if ( false === $entries ) { |
| 467 |
return $totals; |
| 468 |
} |
| 469 |
|
| 470 |
foreach ( $entries as $entry ) { |
| 471 |
if ( '.' === $entry || '..' === $entry ) { |
| 472 |
continue; |
| 473 |
} |
| 474 |
$path = $dir . '/' . $entry; |
| 475 |
if ( is_dir( $path ) ) { |
| 476 |
$sub = self::measure_static( $path ); |
| 477 |
foreach ( $totals as $k => $v ) { |
| 478 |
$totals[ $k ] = $v + $sub[ $k ]; |
| 479 |
} |
| 480 |
continue; |
| 481 |
} |
| 482 |
if ( 'index.html' === $entry ) { |
| 483 |
$totals['html_bytes'] += (int) @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan(). |
| 484 |
++$totals['html_files']; |
| 485 |
} elseif ( substr( $entry, -3 ) === '.br' ) { |
| 486 |
$totals['br_bytes'] += (int) @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan(). |
| 487 |
++$totals['br_files']; |
| 488 |
} |
| 489 |
} |
| 490 |
|
| 491 |
return $totals; |
| 492 |
} |
| 493 |
|
| 494 |
/** |
| 495 |
* Filter the activity log down to purge events. |
| 496 |
* |
| 497 |
* The log already carries the cause in its message ("post saved", |
| 498 |
* "settings change", "manual"), which is the whole point of the |
| 499 |
* drill-down: "Last purge — 4h ago" is trivia until you can see that it |
| 500 |
* was a post save rather than something clearing the cache every hour. |
| 501 |
* |
| 502 |
* Pure with respect to the entries passed in, so the type filter is |
| 503 |
* testable without a WordPress transient. |
| 504 |
* |
| 505 |
* @param array<int,array<string,mixed>> $entries Activity_Log::entries() output. |
| 506 |
* @return array<int,array<string,mixed>> |
| 507 |
*/ |
| 508 |
public static function filter_purges( array $entries, int $limit = 25 ): array { |
| 509 |
$out = array(); |
| 510 |
foreach ( $entries as $entry ) { |
| 511 |
$type = isset( $entry['type'] ) ? (string) $entry['type'] : ''; |
| 512 |
if ( ! in_array( $type, self::PURGE_TYPES, true ) ) { |
| 513 |
continue; |
| 514 |
} |
| 515 |
$out[] = array( |
| 516 |
'ts' => (int) ( $entry['ts'] ?? 0 ), |
| 517 |
'type' => $type, |
| 518 |
'message' => (string) ( $entry['message'] ?? '' ), |
| 519 |
'severity' => (string) ( $entry['severity'] ?? 'info' ), |
| 520 |
); |
| 521 |
if ( count( $out ) >= $limit ) { |
| 522 |
break; |
| 523 |
} |
| 524 |
} |
| 525 |
return $out; |
| 526 |
} |
| 527 |
|
| 528 |
/** |
| 529 |
* The last-purge drill-down. |
| 530 |
* |
| 531 |
* `last_gc` / `gc_removed` / `gc_removed_total` come from the daily |
| 532 |
* `xspeed_gc` sweep (Cache_GC) so the collector is verifiable from the |
| 533 |
* dashboard instead of over SSH. |
| 534 |
* |
| 535 |
* @return array{events:array<int,array<string,mixed>>,last_purge:int,last_gc:int,gc_removed:int,gc_removed_total:int,gc_next_run:int} |
| 536 |
*/ |
| 537 |
public static function purge_log( int $limit = 25 ): array { |
| 538 |
$limit = max( 1, min( 50, $limit ) ); |
| 539 |
$events = self::filter_purges( Activity_Log::entries(), $limit ); |
| 540 |
$stats = get_option( 'xspeed_stats', array() ); |
| 541 |
$stats = is_array( $stats ) ? $stats : array(); |
| 542 |
|
| 543 |
return array( |
| 544 |
'events' => $events, |
| 545 |
'last_purge' => (int) ( $stats['last_purge'] ?? 0 ), |
| 546 |
'last_gc' => (int) ( $stats['last_gc'] ?? 0 ), |
| 547 |
'gc_removed' => (int) ( $stats['gc_removed'] ?? 0 ), |
| 548 |
'gc_removed_total' => (int) ( $stats['gc_removed_total'] ?? 0 ), |
| 549 |
'gc_next_run' => (int) wp_next_scheduled( Cache_GC::CRON_HOOK ), |
| 550 |
); |
| 551 |
} |
| 552 |
|
| 553 |
/** Drop the memoized scan — called after a purge so the list can't lie. */ |
| 554 |
public static function invalidate(): void { |
| 555 |
delete_transient( self::TRANSIENT_KEY ); |
| 556 |
} |
| 557 |
} |
| 558 |
|