| 1 |
<?php |
| 2 |
/** |
| 3 |
* Uploads scanner — disk usage + destructive-delete support for |
| 4 |
* `wp-content/uploads/templately/`. |
| 5 |
* |
| 6 |
* A brand-new developer feature (no legacy counterpart). Three responsibilities, all |
| 7 |
* static so the REST controller stays a thin transport shell: |
| 8 |
* |
| 9 |
* - {@see get_usage()} — per-subdirectory sizing of the Templately uploads root, cached |
| 10 |
* in the `_templately_uploads_usage` transient for 5 minutes. |
| 11 |
* - {@see resolve_target()} — the ONE path-validation gate every destructive call goes |
| 12 |
* through (realpath + strict trailing-separator prefix check). |
| 13 |
* - {@see delete_tree()} — the recursive removal itself. |
| 14 |
* |
| 15 |
* TIME BUDGET (mirrors {@see \Templately\Utils\Helper::fsi_should_exit()}): a pathological |
| 16 |
* uploads tree must never wedge the request. The scan works in whole top-level |
| 17 |
* subdirectories and checks {@see should_exit()} after EACH one — so forward progress is |
| 18 |
* guaranteed (at least one subdirectory per call, never a zero-work loop) — then returns |
| 19 |
* `done => false` with the remaining work parked in `cursor`. The partial payload is stored |
| 20 |
* in the same transient, so the next call only needs `continue => true` (an opaque |
| 21 |
* continuation flag) rather than round-tripping a nested cursor structure through REST. |
| 22 |
* |
| 23 |
* SYMLINKS ARE NEVER FOLLOWED. `RecursiveDirectoryIterator::hasChildren()` defaults to |
| 24 |
* `$allow_links = false`, and every loop additionally skips `isLink()` entries before the |
| 25 |
* `isDir()` branch (a symlink to a directory answers `isDir() === true`). A symlink loop |
| 26 |
* therefore cannot hang a scan, and a delete unlinks the LINK, never its target. |
| 27 |
* |
| 28 |
* @package Templately |
| 29 |
*/ |
| 30 |
|
| 31 |
namespace Templately\Modules\Utilities\Cleanup; |
| 32 |
|
| 33 |
use FilesystemIterator; |
| 34 |
use RecursiveDirectoryIterator; |
| 35 |
use RecursiveIteratorIterator; |
| 36 |
use UnexpectedValueException; |
| 37 |
use WP_Error; |
| 38 |
|
| 39 |
class Scanner { |
| 40 |
|
| 41 |
/** |
| 42 |
* Transient key holding the last usage payload (complete OR partial). |
| 43 |
* |
| 44 |
* NOTE: passed to set_transient()/get_transient() verbatim — WordPress adds its own |
| 45 |
* `_transient_` option prefix on top. |
| 46 |
*/ |
| 47 |
const TRANSIENT_KEY = '_templately_uploads_usage'; |
| 48 |
|
| 49 |
/** Cache lifetime for a usage payload, in seconds (5 minutes). */ |
| 50 |
const CACHE_TTL = 300; |
| 51 |
|
| 52 |
/** Default soft wall-clock budget for one scan pass, in seconds. */ |
| 53 |
const DEFAULT_BUDGET = 5.0; |
| 54 |
|
| 55 |
/** Directory name under the uploads basedir that this module owns. */ |
| 56 |
const UPLOADS_SUBDIR = 'templately'; |
| 57 |
|
| 58 |
/** |
| 59 |
* Absolute path of the Templately uploads root (no trailing separator). |
| 60 |
* |
| 61 |
* `wp_upload_dir( null, false )` — the `$create_dir = false` argument matters: the |
| 62 |
* default would create the current month's directory as a side effect of merely |
| 63 |
* READING a usage figure. |
| 64 |
* |
| 65 |
* @return string |
| 66 |
*/ |
| 67 |
public static function get_base_dir() { |
| 68 |
$uploads = wp_upload_dir( null, false ); |
| 69 |
$basedir = isset( $uploads['basedir'] ) ? $uploads['basedir'] : ''; |
| 70 |
|
| 71 |
$dir = rtrim( $basedir, '/\\' ) . DIRECTORY_SEPARATOR . self::UPLOADS_SUBDIR; |
| 72 |
|
| 73 |
/** |
| 74 |
* Redirect the root every cleanup task resolves against. |
| 75 |
* |
| 76 |
* EVERY CLEANUP TEST MUST USE THIS. A test that runs against the real |
| 77 |
* uploads directory deletes the developer's own working data — see the |
| 78 |
* docblock on PatternSync::content_dir(), which records exactly that |
| 79 |
* happening to a live sandbox. |
| 80 |
* |
| 81 |
* @param string $dir Absolute path, no trailing separator. |
| 82 |
*/ |
| 83 |
return (string) apply_filters( 'templately_cleanup_base_dir', $dir ); |
| 84 |
} |
| 85 |
|
| 86 |
/** |
| 87 |
* Soft per-pass wall-clock budget in seconds. |
| 88 |
* |
| 89 |
* @return float |
| 90 |
*/ |
| 91 |
public static function get_budget() { |
| 92 |
/** |
| 93 |
* Filter the uploads-scanner per-pass time budget (seconds). |
| 94 |
* |
| 95 |
* @param float $budget Default 5.0. |
| 96 |
*/ |
| 97 |
return (float) apply_filters( 'templately_uploads_scanner_budget', self::DEFAULT_BUDGET ); |
| 98 |
} |
| 99 |
|
| 100 |
/** |
| 101 |
* Whether the current scan pass should stop and return a partial result. |
| 102 |
* |
| 103 |
* Two independent ceilings, mirroring Helper::fsi_should_exit(): |
| 104 |
* 1. the scanner's own soft budget (elapsed time in THIS pass), and |
| 105 |
* 2. the request-level `max_execution_time`, bailing once we are within |
| 106 |
* `max( 5, 20% of the limit )` seconds of it. |
| 107 |
* |
| 108 |
* Ceiling 2 shares Helper::fsi_should_exit()'s dependency on `TEMPLATELY_START_TIME`, |
| 109 |
* which only the import runners define — so outside an import it is simply inert and |
| 110 |
* the soft budget is the operative guard. That is deliberate: the budget alone already |
| 111 |
* bounds a pass, and the second ceiling adds protection when a scan happens to run |
| 112 |
* inside a long-lived import request. |
| 113 |
* |
| 114 |
* @param float $started_at microtime(true) when the pass started. |
| 115 |
* @param float|null $budget Soft budget override; null uses {@see get_budget()}. |
| 116 |
* @return bool |
| 117 |
*/ |
| 118 |
public static function should_exit( $started_at, $budget = null ) { |
| 119 |
if ( null === $budget ) { |
| 120 |
$budget = self::get_budget(); |
| 121 |
} |
| 122 |
|
| 123 |
$budget = (float) $budget; |
| 124 |
if ( $budget >= 0 && ( microtime( true ) - $started_at ) >= $budget ) { |
| 125 |
return true; |
| 126 |
} |
| 127 |
|
| 128 |
$max_time = (int) ini_get( 'max_execution_time' ); |
| 129 |
if ( defined( 'TEMPLATELY_START_TIME' ) && $max_time > 0 ) { |
| 130 |
$elapsed = microtime( true ) - TEMPLATELY_START_TIME; |
| 131 |
$delay = max( 5, $max_time * 20 / 100 ); |
| 132 |
|
| 133 |
if ( $max_time - $elapsed <= $delay ) { |
| 134 |
return true; |
| 135 |
} |
| 136 |
} |
| 137 |
|
| 138 |
return false; |
| 139 |
} |
| 140 |
|
| 141 |
/** |
| 142 |
* Usage payload for the Templately uploads root. |
| 143 |
* |
| 144 |
* Returned shape: |
| 145 |
* total_bytes int Sum of every measured file (subdirectories + loose root files). |
| 146 |
* total_files int File count behind total_bytes. |
| 147 |
* subdirs array [ { name, bytes, files } ], largest first. |
| 148 |
* loose_bytes int Bytes of files sitting directly in the root (not in `subdirs`). |
| 149 |
* loose_files int File count behind loose_bytes. |
| 150 |
* done bool false = the time budget cut the pass short; call again with |
| 151 |
* `continue => true` to resume. |
| 152 |
* cursor array|null Remaining work when `done` is false (internal). |
| 153 |
* cached_at int Unix timestamp the payload was produced. |
| 154 |
* cached bool true = served from the transient without touching the disk. |
| 155 |
* exists bool false = the uploads/templately directory is not there. |
| 156 |
* base string Absolute path that was measured. |
| 157 |
* |
| 158 |
* @param array $args { |
| 159 |
* @type bool $refresh Bypass + delete the cached payload and rescan. |
| 160 |
* @type bool $continue Resume a partial (`done => false`) cached payload. |
| 161 |
* @type float|null $budget Soft time budget override (seconds). Testing/internal. |
| 162 |
* @type string|null $base Scan this directory instead of the real uploads root. |
| 163 |
* Testing/internal — an explicit base BYPASSES the shared |
| 164 |
* transient entirely so fixtures never poison the cache. |
| 165 |
* } |
| 166 |
* @return array |
| 167 |
*/ |
| 168 |
public static function get_usage( $args = [] ) { |
| 169 |
$args = wp_parse_args( |
| 170 |
$args, |
| 171 |
[ |
| 172 |
'refresh' => false, |
| 173 |
'continue' => false, |
| 174 |
'budget' => null, |
| 175 |
'base' => null, |
| 176 |
] |
| 177 |
); |
| 178 |
|
| 179 |
$explicit_base = ! empty( $args['base'] ); |
| 180 |
$base = $explicit_base ? rtrim( $args['base'], '/\\' ) : self::get_base_dir(); |
| 181 |
$cacheable = ! $explicit_base; |
| 182 |
|
| 183 |
$cached = false; |
| 184 |
if ( $cacheable ) { |
| 185 |
if ( ! empty( $args['refresh'] ) ) { |
| 186 |
delete_transient( self::TRANSIENT_KEY ); |
| 187 |
} else { |
| 188 |
$cached = get_transient( self::TRANSIENT_KEY ); |
| 189 |
} |
| 190 |
} |
| 191 |
|
| 192 |
$resume = null; |
| 193 |
if ( is_array( $cached ) ) { |
| 194 |
$is_partial = empty( $cached['done'] ) && ! empty( $cached['cursor'] ); |
| 195 |
|
| 196 |
// A complete payload (or any payload the caller did not ask to continue) is |
| 197 |
// served straight from cache — this is the "second call is cached" path. |
| 198 |
if ( ! $is_partial || empty( $args['continue'] ) ) { |
| 199 |
$cached['cached'] = true; |
| 200 |
return $cached; |
| 201 |
} |
| 202 |
|
| 203 |
$resume = $cached['cursor']; |
| 204 |
} |
| 205 |
|
| 206 |
$payload = self::run_scan( $base, $resume, $args['budget'] ); |
| 207 |
|
| 208 |
if ( $cacheable ) { |
| 209 |
set_transient( self::TRANSIENT_KEY, $payload, self::CACHE_TTL ); |
| 210 |
} |
| 211 |
|
| 212 |
return $payload; |
| 213 |
} |
| 214 |
|
| 215 |
/** |
| 216 |
* Delete the cached usage payload. Called after every successful destructive action. |
| 217 |
* |
| 218 |
* @return void |
| 219 |
*/ |
| 220 |
public static function bust_cache() { |
| 221 |
delete_transient( self::TRANSIENT_KEY ); |
| 222 |
} |
| 223 |
|
| 224 |
/** |
| 225 |
* Resolve + VALIDATE a delete target against the Templately uploads root. |
| 226 |
* |
| 227 |
* This is the single security gate for every destructive call. It is deliberately |
| 228 |
* paranoid: |
| 229 |
* - the target is resolved with `realpath()`, so `..` segments AND symlinks are |
| 230 |
* collapsed BEFORE the containment test (a symlink inside the root pointing at |
| 231 |
* `/etc` resolves to `/etc` and is rejected); |
| 232 |
* - containment is a prefix test against `realpath( base ) . DIRECTORY_SEPARATOR`. |
| 233 |
* The trailing separator is load-bearing: without it the sibling |
| 234 |
* `/uploads/templately-evil` passes a naive `strpos( $real, $base ) === 0` test; |
| 235 |
* - an absolute path outside the root is rejected by the same test; |
| 236 |
* - a missing path or a non-directory is an ERROR, never a silent success. |
| 237 |
* |
| 238 |
* The root itself resolves successfully with `is_root => true` — the extra |
| 239 |
* `delete_root` confirmation is the CALLER's rail (see the REST controller), kept out |
| 240 |
* of here so this function stays a pure path question. |
| 241 |
* |
| 242 |
* @param string $target Relative path under the uploads root ('' / '.' / '/' = the |
| 243 |
* root itself). Absolute paths are allowed only if they |
| 244 |
* resolve inside the root. |
| 245 |
* @param string|null $base Base directory override (testing/internal); null = the real |
| 246 |
* uploads root. |
| 247 |
* @return array|WP_Error { path: string, relative: string, is_root: bool } |
| 248 |
*/ |
| 249 |
public static function resolve_target( $target, $base = null ) { |
| 250 |
$base = ( null === $base ) ? self::get_base_dir() : $base; |
| 251 |
|
| 252 |
$base_real = realpath( $base ); |
| 253 |
if ( false === $base_real || ! is_dir( $base_real ) ) { |
| 254 |
return new WP_Error( |
| 255 |
'uploads_base_missing', |
| 256 |
__( 'The Templately uploads directory does not exist.', 'templately' ), |
| 257 |
[ 'status' => 404 ] |
| 258 |
); |
| 259 |
} |
| 260 |
$base_real = rtrim( $base_real, '/\\' ); |
| 261 |
|
| 262 |
$target = trim( (string) $target ); |
| 263 |
|
| 264 |
// Root shorthands. |
| 265 |
if ( '' === $target || '.' === $target || '/' === $target || './' === $target ) { |
| 266 |
return [ |
| 267 |
'path' => $base_real, |
| 268 |
'relative' => '', |
| 269 |
'is_root' => true, |
| 270 |
]; |
| 271 |
} |
| 272 |
|
| 273 |
$candidate = self::is_absolute_path( $target ) |
| 274 |
? $target |
| 275 |
: $base_real . DIRECTORY_SEPARATOR . ltrim( $target, '/\\' ); |
| 276 |
|
| 277 |
$real = realpath( $candidate ); |
| 278 |
if ( false === $real ) { |
| 279 |
return new WP_Error( |
| 280 |
'target_not_found', |
| 281 |
__( 'That directory does not exist.', 'templately' ), |
| 282 |
[ 'status' => 404 ] |
| 283 |
); |
| 284 |
} |
| 285 |
$real = rtrim( $real, '/\\' ); |
| 286 |
|
| 287 |
if ( $real === $base_real ) { |
| 288 |
return [ |
| 289 |
'path' => $base_real, |
| 290 |
'relative' => '', |
| 291 |
'is_root' => true, |
| 292 |
]; |
| 293 |
} |
| 294 |
|
| 295 |
// STRICT containment: the trailing separator stops '/uploads/templately-evil' |
| 296 |
// from passing as a child of '/uploads/templately'. |
| 297 |
if ( 0 !== strpos( $real, $base_real . DIRECTORY_SEPARATOR ) ) { |
| 298 |
return new WP_Error( |
| 299 |
'target_outside_base', |
| 300 |
__( 'That path is outside the Templately uploads directory.', 'templately' ), |
| 301 |
[ 'status' => 400 ] |
| 302 |
); |
| 303 |
} |
| 304 |
|
| 305 |
if ( ! is_dir( $real ) ) { |
| 306 |
return new WP_Error( |
| 307 |
'target_not_a_directory', |
| 308 |
__( 'That path is not a directory.', 'templately' ), |
| 309 |
[ 'status' => 400 ] |
| 310 |
); |
| 311 |
} |
| 312 |
|
| 313 |
return [ |
| 314 |
'path' => $real, |
| 315 |
'relative' => ltrim( substr( $real, strlen( $base_real ) ), '/\\' ), |
| 316 |
'is_root' => false, |
| 317 |
]; |
| 318 |
} |
| 319 |
|
| 320 |
/** |
| 321 |
* NOTE: `delete_tree()` used to live here. It moved to |
| 322 |
* {@see \Templately\Modules\Utilities\Cleanup\Remover}, which is now the |
| 323 |
* ONLY public deletion surface in the plugin. |
| 324 |
* |
| 325 |
* This class measures and validates paths; it does not delete. Keeping a raw |
| 326 |
* recursive delete on the same class a task already uses for `resolve_target()` |
| 327 |
* and `measure_dir()` made bypassing the guard-file denylist and the live-file |
| 328 |
* exclusion a single autocomplete away. Now it is unreachable. |
| 329 |
*/ |
| 330 |
|
| 331 |
/** |
| 332 |
* Measure one directory recursively. |
| 333 |
* |
| 334 |
* @param string $dir Absolute directory path. |
| 335 |
* @return array { bytes: int, files: int } |
| 336 |
*/ |
| 337 |
public static function measure_dir( $dir ) { |
| 338 |
$measured = [ |
| 339 |
'bytes' => 0, |
| 340 |
'files' => 0, |
| 341 |
]; |
| 342 |
|
| 343 |
$iterator = self::make_recursive_iterator( $dir, RecursiveIteratorIterator::LEAVES_ONLY ); |
| 344 |
if ( null === $iterator ) { |
| 345 |
// Unreadable (or vanished) directory — tolerated, reported as empty. |
| 346 |
return $measured; |
| 347 |
} |
| 348 |
|
| 349 |
foreach ( $iterator as $item ) { |
| 350 |
if ( $item->isLink() || ! $item->isFile() ) { |
| 351 |
continue; |
| 352 |
} |
| 353 |
|
| 354 |
$measured['bytes'] += self::safe_size( $item ); |
| 355 |
$measured['files']++; |
| 356 |
} |
| 357 |
|
| 358 |
return $measured; |
| 359 |
} |
| 360 |
|
| 361 |
// ----------------------------------------------------------------- |
| 362 |
// Internals |
| 363 |
// ----------------------------------------------------------------- |
| 364 |
|
| 365 |
/** |
| 366 |
* Run one scan pass, optionally resuming a parked cursor. |
| 367 |
* |
| 368 |
* @param string $base Absolute directory to measure. |
| 369 |
* @param array|null $resume Cursor from a previous partial pass. |
| 370 |
* @param float|null $budget Soft time budget override (seconds). |
| 371 |
* @return array |
| 372 |
*/ |
| 373 |
private static function run_scan( $base, $resume, $budget ) { |
| 374 |
$started = microtime( true ); |
| 375 |
|
| 376 |
$payload = [ |
| 377 |
'base' => $base, |
| 378 |
'exists' => is_dir( $base ), |
| 379 |
'total_bytes' => 0, |
| 380 |
'total_files' => 0, |
| 381 |
'subdirs' => [], |
| 382 |
'loose_bytes' => 0, |
| 383 |
'loose_files' => 0, |
| 384 |
'done' => true, |
| 385 |
'cursor' => null, |
| 386 |
'cached_at' => time(), |
| 387 |
'cached' => false, |
| 388 |
]; |
| 389 |
|
| 390 |
if ( ! $payload['exists'] ) { |
| 391 |
return $payload; |
| 392 |
} |
| 393 |
|
| 394 |
if ( is_array( $resume ) && isset( $resume['pending'] ) ) { |
| 395 |
$pending = (array) $resume['pending']; |
| 396 |
$payload['subdirs'] = isset( $resume['subdirs'] ) ? (array) $resume['subdirs'] : []; |
| 397 |
$payload['loose_bytes'] = isset( $resume['loose_bytes'] ) ? (int) $resume['loose_bytes'] : 0; |
| 398 |
$payload['loose_files'] = isset( $resume['loose_files'] ) ? (int) $resume['loose_files'] : 0; |
| 399 |
} else { |
| 400 |
$top = self::read_top_level( $base ); |
| 401 |
$pending = $top['dirs']; |
| 402 |
$payload['loose_bytes'] = $top['loose_bytes']; |
| 403 |
$payload['loose_files'] = $top['loose_files']; |
| 404 |
} |
| 405 |
|
| 406 |
while ( ! empty( $pending ) ) { |
| 407 |
$name = array_shift( $pending ); |
| 408 |
$measured = self::measure_dir( $base . DIRECTORY_SEPARATOR . $name ); |
| 409 |
|
| 410 |
$payload['subdirs'][] = [ |
| 411 |
'name' => $name, |
| 412 |
'bytes' => $measured['bytes'], |
| 413 |
'files' => $measured['files'], |
| 414 |
]; |
| 415 |
|
| 416 |
// Budget is checked AFTER a whole subdirectory, so every call completes at |
| 417 |
// least one unit of work — a zero/negative budget yields a partial, never a |
| 418 |
// no-progress loop. |
| 419 |
if ( ! empty( $pending ) && self::should_exit( $started, $budget ) ) { |
| 420 |
$payload['done'] = false; |
| 421 |
$payload['cursor'] = [ |
| 422 |
'pending' => array_values( $pending ), |
| 423 |
'subdirs' => $payload['subdirs'], |
| 424 |
'loose_bytes' => $payload['loose_bytes'], |
| 425 |
'loose_files' => $payload['loose_files'], |
| 426 |
]; |
| 427 |
break; |
| 428 |
} |
| 429 |
} |
| 430 |
|
| 431 |
$payload['total_bytes'] = $payload['loose_bytes']; |
| 432 |
$payload['total_files'] = $payload['loose_files']; |
| 433 |
foreach ( $payload['subdirs'] as $entry ) { |
| 434 |
$payload['total_bytes'] += (int) $entry['bytes']; |
| 435 |
$payload['total_files'] += (int) $entry['files']; |
| 436 |
} |
| 437 |
|
| 438 |
// Largest first — the UI's proportional bars read top-down. |
| 439 |
usort( |
| 440 |
$payload['subdirs'], |
| 441 |
function ( $a, $b ) { |
| 442 |
if ( $a['bytes'] === $b['bytes'] ) { |
| 443 |
return strcmp( $a['name'], $b['name'] ); |
| 444 |
} |
| 445 |
return ( $a['bytes'] < $b['bytes'] ) ? 1 : -1; |
| 446 |
} |
| 447 |
); |
| 448 |
|
| 449 |
return $payload; |
| 450 |
} |
| 451 |
|
| 452 |
/** |
| 453 |
* List the base directory's immediate children. |
| 454 |
* |
| 455 |
* Directories become scan units; files are aggregated as "loose"; symlinks (to either) |
| 456 |
* are skipped entirely — they are not our bytes and must not be walked. |
| 457 |
* |
| 458 |
* @param string $base Absolute directory path. |
| 459 |
* @return array { dirs: string[], loose_bytes: int, loose_files: int } |
| 460 |
*/ |
| 461 |
private static function read_top_level( $base ) { |
| 462 |
$top = [ |
| 463 |
'dirs' => [], |
| 464 |
'loose_bytes' => 0, |
| 465 |
'loose_files' => 0, |
| 466 |
]; |
| 467 |
|
| 468 |
try { |
| 469 |
$iterator = new FilesystemIterator( |
| 470 |
$base, |
| 471 |
FilesystemIterator::SKIP_DOTS | FilesystemIterator::CURRENT_AS_FILEINFO |
| 472 |
); |
| 473 |
} catch ( UnexpectedValueException $e ) { |
| 474 |
return $top; // Unreadable base — tolerated. |
| 475 |
} |
| 476 |
|
| 477 |
foreach ( $iterator as $item ) { |
| 478 |
if ( $item->isLink() ) { |
| 479 |
continue; |
| 480 |
} |
| 481 |
|
| 482 |
if ( $item->isDir() ) { |
| 483 |
$top['dirs'][] = $item->getFilename(); |
| 484 |
continue; |
| 485 |
} |
| 486 |
|
| 487 |
if ( $item->isFile() ) { |
| 488 |
$top['loose_bytes'] += self::safe_size( $item ); |
| 489 |
$top['loose_files']++; |
| 490 |
} |
| 491 |
} |
| 492 |
|
| 493 |
sort( $top['dirs'] ); |
| 494 |
|
| 495 |
return $top; |
| 496 |
} |
| 497 |
|
| 498 |
/** |
| 499 |
* Build a symlink-safe, unreadable-tolerant recursive iterator. |
| 500 |
* |
| 501 |
* `SKIP_DOTS` keeps `.`/`..` out; `CATCH_GET_CHILD` swallows the |
| 502 |
* UnexpectedValueException an unreadable SUBdirectory throws mid-walk (an unreadable |
| 503 |
* TOP directory throws from the constructor, caught here → null). Symlinked |
| 504 |
* directories are not descended: `RecursiveDirectoryIterator::hasChildren()` defaults |
| 505 |
* to `$allow_links = false`. |
| 506 |
* |
| 507 |
* @param string $dir Absolute directory path. |
| 508 |
* @param int $mode RecursiveIteratorIterator mode constant. |
| 509 |
* @return RecursiveIteratorIterator|null |
| 510 |
*/ |
| 511 |
public static function make_recursive_iterator( $dir, $mode ) { |
| 512 |
try { |
| 513 |
$directory = new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ); |
| 514 |
|
| 515 |
return new RecursiveIteratorIterator( |
| 516 |
$directory, |
| 517 |
$mode, |
| 518 |
RecursiveIteratorIterator::CATCH_GET_CHILD |
| 519 |
); |
| 520 |
} catch ( UnexpectedValueException $e ) { |
| 521 |
return null; |
| 522 |
} |
| 523 |
} |
| 524 |
|
| 525 |
/** |
| 526 |
* File size that never throws (a file can vanish, or stat can fail, mid-walk). |
| 527 |
* |
| 528 |
* @param \SplFileInfo $item File info. |
| 529 |
* @return int |
| 530 |
*/ |
| 531 |
public static function safe_size( $item ) { |
| 532 |
try { |
| 533 |
return (int) $item->getSize(); |
| 534 |
} catch ( \RuntimeException $e ) { |
| 535 |
return 0; |
| 536 |
} |
| 537 |
} |
| 538 |
|
| 539 |
/** |
| 540 |
* Whether a path is absolute (POSIX `/…`, Windows `C:\…` or a UNC `\\…` prefix). |
| 541 |
* |
| 542 |
* @param string $path Path to test. |
| 543 |
* @return bool |
| 544 |
*/ |
| 545 |
private static function is_absolute_path( $path ) { |
| 546 |
if ( '' === $path ) { |
| 547 |
return false; |
| 548 |
} |
| 549 |
|
| 550 |
if ( '/' === $path[0] || '\\' === $path[0] ) { |
| 551 |
return true; |
| 552 |
} |
| 553 |
|
| 554 |
return (bool) preg_match( '#^[a-zA-Z]:[\\\\/]#', $path ); |
| 555 |
} |
| 556 |
} |
| 557 |
|