PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / utilities / Cleanup / Scanner.php

Scanner.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/utilities/Cleanup/Scanner.php

557 lines 17.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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