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 / Remover.php

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

264 lines 8.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The ONE path through which anything is ever deleted.
4 *
5 * @package Templately
6 */
7
8 namespace Templately\Modules\Utilities\Cleanup;
9
10 use RecursiveIteratorIterator;
11 use SplFileInfo;
12 use WP_Error;
13
14 /**
15 * No cleanup task holds a deletion capability of its own. Every removal — from
16 * every task, including ones written later by anyone — comes through here, so
17 * the safety rules have exactly one implementation and cannot be forgotten by a
18 * contributor who did not read the interface docs.
19 *
20 * `Scanner` deliberately no longer exposes `delete_tree()`. It measures and it
21 * validates paths; it does not delete. That is what makes "exactly one
22 * implementation of the safety rules" a structural fact rather than a
23 * convention — a task cannot reach a raw recursive delete even by accident.
24 *
25 * Four rules, enforced here for everyone:
26 *
27 * 1. CONTAINMENT — the resolved real path must sit inside the plugin's own
28 * uploads root. The check uses a trailing separator, so a sibling directory
29 * sharing a name prefix (`/uploads/templately-evil`) is refused rather than
30 * passing a naive `strpos()` test.
31 * 2. SYMLINKS — never followed. A link is unlinked as a link; its target is
32 * never traversed and never removed.
33 * 3. GUARD FILES — `.htaccess`, `index.php`, `index.html` and `web.config` are
34 * immortal, anywhere under the root. These are what keep a web-reachable
35 * uploads directory private, they are written once and therefore always the
36 * oldest thing present, and deleting them was a real shipped defect (see
37 * spec 025 FR-015). `web.config` is the IIS equivalent of the `.htaccess`
38 * deny rule, written by `Helper::protect_directory()`; it has to be on this
39 * list for the same reason the other three are, and it is easy to miss
40 * because Apache sites never see one.
41 * 4. LIVE FILES — a caller may declare paths that are being written to right
42 * now; they are never candidates.
43 */
44 final class Remover {
45
46 /**
47 * Filenames no task may ever remove.
48 *
49 * Not configurable. A contributor who thinks they need to delete one of
50 * these is wrong: they exist to keep the directory unreadable from the web.
51 */
52 const GUARD_FILES = [ '.htaccess', 'index.php', 'index.html', 'web.config' ];
53
54 /** @var string[] Absolute paths excluded for this request (live logs, etc.). */
55 private static $protected = [];
56
57 /**
58 * Protect paths that are in use right now — a log being appended to, the
59 * working directory of a running import.
60 *
61 * @param string[] $paths Absolute paths.
62 */
63 public static function protect( array $paths ): void {
64 foreach ( $paths as $path ) {
65 $real = realpath( $path );
66 if ( false !== $real ) {
67 self::$protected[ $real ] = true;
68 }
69 }
70 }
71
72 /**
73 * Test seam.
74 */
75 public static function reset_protected(): void {
76 self::$protected = [];
77 }
78
79 /**
80 * Remove a directory and everything inside it.
81 *
82 * @param string $target Absolute path, or one relative to the uploads root.
83 * @param Context $context Honours `dry_run` — nothing is touched when set.
84 * @param bool $keep_root Empty the directory but keep the directory itself.
85 * @return TaskResult|WP_Error Bytes/items, or a refusal explaining why.
86 */
87 public static function delete_directory( string $target, Context $context, bool $keep_root = false ) {
88 $resolved = Scanner::resolve_target( $target );
89 if ( is_wp_error( $resolved ) ) {
90 return $resolved;
91 }
92
93 // Refusing the root outright: emptying the whole uploads tree is never
94 // what an individual task means, and a task that computed its way to ''
95 // has a bug we should surface rather than act on.
96 if ( $resolved['is_root'] && ! $keep_root ) {
97 return new WP_Error(
98 'refuse_root_delete',
99 __( 'Refusing to remove the Templately uploads root.', 'templately' ),
100 [ 'status' => 400 ]
101 );
102 }
103
104 if ( isset( self::$protected[ $resolved['path'] ] ) ) {
105 return new WP_Error(
106 'target_in_use',
107 __( 'That directory is in use.', 'templately' ),
108 [ 'status' => 409 ]
109 );
110 }
111
112 if ( $context->is_dry_run() ) {
113 $measured = Scanner::measure_dir( $resolved['path'] );
114
115 return TaskResult::empty()->add( (int) $measured['files'], (int) $measured['bytes'] );
116 }
117
118 return self::delete_tree( $resolved['path'], $keep_root );
119 }
120
121 /**
122 * Remove a single file.
123 *
124 * @param string $path Absolute path, or one relative to the uploads root.
125 * @param Context $context Honours `dry_run`.
126 * @return TaskResult|WP_Error
127 */
128 public static function delete_file( string $path, Context $context ) {
129 $base = realpath( Scanner::get_base_dir() );
130 if ( false === $base ) {
131 return new WP_Error(
132 'uploads_base_missing',
133 __( 'The Templately uploads directory does not exist.', 'templately' ),
134 [ 'status' => 404 ]
135 );
136 }
137 $base = rtrim( $base, '/\\' );
138
139 $real = realpath( $path );
140 if ( false === $real ) {
141 // Already gone. Not an error — a task that raced another sweep, or a
142 // record whose file was removed by hand, should not report a failure.
143 return TaskResult::empty();
144 }
145
146 // Same strict containment as directories: the trailing separator is what
147 // stops `/uploads/templately-evil/x.log` passing as ours.
148 if ( 0 !== strpos( $real, $base . DIRECTORY_SEPARATOR ) ) {
149 return new WP_Error(
150 'target_outside_base',
151 __( 'That path is outside the Templately uploads directory.', 'templately' ),
152 [ 'status' => 400 ]
153 );
154 }
155
156 if ( self::is_guard_file( $real ) ) {
157 return new WP_Error(
158 'refuse_guard_file',
159 __( 'That file protects the uploads directory and cannot be removed.', 'templately' ),
160 [ 'status' => 400 ]
161 );
162 }
163
164 if ( isset( self::$protected[ $real ] ) ) {
165 return new WP_Error(
166 'target_in_use',
167 __( 'That file is in use.', 'templately' ),
168 [ 'status' => 409 ]
169 );
170 }
171
172 if ( is_dir( $real ) && ! is_link( $real ) ) {
173 return new WP_Error(
174 'target_is_a_directory',
175 __( 'That path is a directory.', 'templately' ),
176 [ 'status' => 400 ]
177 );
178 }
179
180 $size = (int) @filesize( $real ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
181
182 if ( $context->is_dry_run() ) {
183 return TaskResult::empty()->add( 1, $size );
184 }
185
186 if ( ! @unlink( $real ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
187 return TaskResult::empty()->fail( sprintf( 'Could not remove %s', basename( $real ) ) );
188 }
189
190 return TaskResult::empty()->add( 1, $size );
191 }
192
193 /**
194 * Whether a path is one of the directory's protective files.
195 */
196 public static function is_guard_file( string $path ): bool {
197 return in_array( basename( $path ), self::GUARD_FILES, true );
198 }
199
200 /**
201 * The recursive removal itself. PRIVATE — this is the capability the whole
202 * class exists to keep out of task authors' hands.
203 *
204 * Relocated verbatim from the developer-only uploads scanner, which is why
205 * its symlink and failure-tolerance behaviour is unchanged: children are
206 * visited CHILD_FIRST so directories are empty by the time they are removed,
207 * a symlink is unlinked as a link and never followed, and a file that cannot
208 * be removed is simply not counted rather than aborting a partial delete.
209 *
210 * Guard files are skipped here too, not only in `delete_file()` — a
211 * directory delete must never take the root's guards with it.
212 */
213 private static function delete_tree( string $path, bool $keep_root = false ): TaskResult {
214 $result = TaskResult::empty();
215
216 if ( ! is_dir( $path ) ) {
217 return $result;
218 }
219
220 $iterator = Scanner::make_recursive_iterator( $path, RecursiveIteratorIterator::CHILD_FIRST );
221 if ( null === $iterator ) {
222 return $result->fail( sprintf( 'Could not read %s', basename( $path ) ) );
223 }
224
225 foreach ( $iterator as $item ) {
226 /** @var SplFileInfo $item */
227 $item_path = $item->getPathname();
228
229 // isLink() FIRST: a symlink to a directory answers isDir() === true,
230 // and we must remove the LINK, never walk into its target.
231 if ( $item->isLink() ) {
232 @unlink( $item_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
233 continue;
234 }
235
236 if ( $item->isDir() ) {
237 if ( @rmdir( $item_path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
238 $result->add_dirs( 1 );
239 }
240 continue;
241 }
242
243 if ( $keep_root && self::is_guard_file( $item_path ) ) {
244 // Emptying a directory we are keeping must not strip the guards
245 // that keep it private.
246 continue;
247 }
248
249 $size = Scanner::safe_size( $item );
250 if ( @unlink( $item_path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
251 $result->add( 1, $size );
252 }
253 }
254
255 if ( ! $keep_root && @rmdir( $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
256 $result->add_dirs( 1 );
257 }
258
259 Scanner::bust_cache();
260
261 return $result;
262 }
263 }
264