PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / trunk
WP STAGING – WordPress Backup, Restore, Migration & Clone vtrunk
4.9.1 4.9.0 4.8.1 trunk 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.10.0 3.2.0 3.3.1 3.3.2 3.3.3 3.4.1 3.4.3 3.5.0 3.6.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.8.6 3.8.7 3.9.0 3.9.1 3.9.2 3.9.3 3.9.4 4.0.0 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.2.0 4.2.1 4.3.0 4.3.1 4.3.2 4.4.0 4.5.0 4.6.0 4.7.0 4.7.1 4.7.2 4.7.3 4.8.0
wp-staging / Staging / Service / DirectoryScanner.php
wp-staging / Staging / Service Last commit date
Database 1 week ago AbstractStagingSetup.php 1 week ago DirectoryScanner.php 1 week ago FileCopier.php 1 week ago LegacyOptionsCache.php 1 week ago StagingEngine.php 1 week ago StagingSetup.php 1 week ago TableScanner.php 1 week ago
DirectoryScanner.php
549 lines
1 <?php
2
3 namespace WPStaging\Staging\Service;
4
5 use DirectoryIterator;
6 use Throwable;
7 use UnexpectedValueException;
8 use WPStaging\Framework\Adapter\Directory;
9 use WPStaging\Framework\Assets\Assets;
10 use WPStaging\Framework\Exceptions\WPStagingException;
11 use WPStaging\Framework\Filesystem\Filters\ExcludeFilter;
12 use WPStaging\Framework\Filesystem\PathChecker;
13 use WPStaging\Framework\Filesystem\PathIdentifier;
14 use WPStaging\Framework\SiteInfo;
15 use WPStaging\Framework\TemplateEngine\TemplateEngine;
16 use WPStaging\Framework\Utils\Strings;
17 use WPStaging\Core\WPStaging;
18 use WPStaging\Staging\Dto\DirectoryNodeDto;
19 use WPStaging\Staging\Sites;
20
21 /**
22 * Scans filesystem roots and prepares directory data for the staging selection UI.
23 */
24 class DirectoryScanner
25 {
26 /**
27 * CSS class name to use for WordPress core directories like wp-content, wp-admin, wp-includes
28 * This doesn't contain class selector prefix
29 *
30 * @var string
31 */
32 const WP_CORE_DIR = "wpstg-wp-core-dir";
33
34 /**
35 * CSS class name to use for WordPress non core directories
36 * This doesn't contain class selector prefix
37 *
38 * @var string
39 */
40 const WP_NON_CORE_DIR = "wpstg-wp-non-core-dir";
41
42 /**
43 * @var TemplateEngine
44 */
45 protected $templateEngine;
46
47 /**
48 * @var Directory
49 */
50 protected $directory;
51
52 /**
53 * @var Strings
54 */
55 protected $strUtils;
56
57 /**
58 * @var PathChecker
59 */
60 protected $pathChecker;
61
62 /**
63 * @var SiteInfo
64 */
65 protected $siteInfo;
66
67 /**
68 * @var AbstractStagingSetup
69 */
70 protected $stagingSetup;
71
72 /**
73 * Normalized absolute paths of existing staging sites, lazily loaded.
74 *
75 * @var array|null
76 */
77 private $stagingSiteDirectories = null;
78
79 /**
80 * @var string
81 */
82 protected $loaderIcon = '';
83
84 /**
85 * @var string
86 */
87 protected $infoIcon = '';
88
89 /**
90 * @var bool
91 */
92 protected $isAllowVfsPath = false;
93
94 /**
95 * @var bool
96 */
97 protected $scanSubWpContentByDefault = false;
98
99 /**
100 * @var array
101 */
102 protected $excludedDirectories = [];
103
104 /**
105 * @var array
106 */
107 protected $extraDirectories = [];
108
109 /** @var string */
110 protected $absPath = ABSPATH;
111
112 /** @var string */
113 protected $wpContentPath = WP_CONTENT_DIR;
114
115 /** @var bool */
116 protected $useDefaultSelection = false;
117
118 /**
119 * @var bool
120 */
121 protected $showFileDestination = true;
122
123 public function __construct(TemplateEngine $templateEngine, Assets $assets, Directory $directory, Strings $strUtils, PathChecker $pathChecker, SiteInfo $siteInfo)
124 {
125 $this->templateEngine = $templateEngine;
126 $this->directory = $directory;
127 $this->strUtils = $strUtils;
128 $this->pathChecker = $pathChecker;
129 $this->siteInfo = $siteInfo;
130 $this->loaderIcon = $assets->getAssetsUrl('img/spinner.gif');
131 }
132
133 /**
134 * @param bool $isAllowVfsPath
135 * @return void
136 */
137 public function setIsAllowVfsPath(bool $isAllowVfsPath)
138 {
139 $this->isAllowVfsPath = $isAllowVfsPath;
140 }
141
142 /**
143 * @return void
144 */
145 public function setStagingSetup(AbstractStagingSetup $stagingSetup)
146 {
147 $this->stagingSetup = $stagingSetup;
148 }
149
150 /**
151 * @param bool $showFileDestination
152 * @return void
153 */
154 public function setShowFileDestination(bool $showFileDestination)
155 {
156 $this->showFileDestination = $showFileDestination;
157 }
158
159 public function isUpdateOrResetJob(): bool
160 {
161 return $this->stagingSetup->isUpdateOrResetJob();
162 }
163
164 public function renderFilesSelection()
165 {
166 $directories = $this->scanDirectory($this->absPath, $this->absPath, PathIdentifier::IDENTIFIER_ABSPATH);
167
168 // If wp-content is outside ABSPATH, then scan it too
169 if ($this->isWpContentOutsideAbspath()) {
170 $wpContentDirectories = $this->scanDirectory(dirname($this->wpContentPath), $this->wpContentPath, PathIdentifier::IDENTIFIER_WP_CONTENT);
171 $directories = array_merge($directories, $wpContentDirectories);
172 }
173
174 /** Value of parent checked will be ignored instead the default selection will be used */
175 $this->useDefaultSelection = true;
176
177 $result = $this->templateEngine->render('staging/_partials/files-selection.php', [
178 'scanner' => $this,
179 'stagingSetup' => $this->stagingSetup,
180 'stagingSiteDto' => $this->stagingSetup->getStagingSiteDto(),
181 'directories' => $directories,
182 'excludeFilters' => new ExcludeFilter(),
183 'showFileDestination' => $this->showFileDestination,
184 ]);
185
186 echo $result; // phpcs:ignore
187 }
188
189 /**
190 * @param string $dirToScan
191 * @param string $basePath
192 * @param string $identifier
193 * @return DirectoryNodeDto[]
194 */
195 public function scanDirectory(string $dirToScan, string $basePath, string $identifier): array
196 {
197 if (!is_dir($dirToScan)) {
198 throw new WPStagingException("The directory at path '{$dirToScan}' does not exist.");
199 }
200
201 try {
202 $iterator = new DirectoryIterator($dirToScan);
203 } catch (Throwable $ex) {
204 $errorMessage = $ex->getMessage();
205 if ($ex->getCode() === 5) {
206 $errorMessage = esc_html__('Access Denied: No read permission to scan the root directory for cloning. Alternatively you can try the WP STAGING backup feature!', 'wp-staging');
207 }
208
209 throw new WPStagingException($errorMessage);
210 }
211
212 $directories = [];
213 foreach ($iterator as $directory) {
214 if ($directory->isDot() || $directory->isFile()) {
215 continue;
216 }
217
218 $directoryPath = $directory->getPathname();
219 try {
220 $path = $this->getPath($directory, $basePath, $identifier);
221 } catch (UnexpectedValueException $e) {
222 continue;
223 }
224
225 $directoryNode = new DirectoryNodeDto();
226 $directoryNode->setName($directory->getFilename());
227
228 if (strpos($directoryPath, 'wp-content') !== false && is_link($directoryPath)) {
229 $directoryNode->setPath(realpath($directory->getPathname()));
230 } else {
231 $directoryNode->setPath(trailingslashit($basePath) . ltrim($path, '/'));
232 }
233
234 $directoryNode->setIdentifier($identifier);
235 $directoryNode->setBasePath($basePath);
236
237 $directories[$directory->getFilename()] = $directoryNode;
238 }
239
240 return $directories;
241 }
242
243 /**
244 * @param DirectoryNodeDto[] $directories
245 * @param bool $parentChecked
246 * @param bool $preserveSelection
247 * @return string
248 */
249 public function directoryListing(array $directories, bool $parentChecked = true, bool $preserveSelection = false): string
250 {
251 uksort($directories, 'strcasecmp');
252
253 $output = '';
254 foreach ($directories as $dirName => $directory) {
255 // Not a directory, possibly a symlink, therefore we will skip it
256 if (basename($dirName) === "\\") {
257 continue;
258 }
259
260 $output .= $this->renderDirectoryNode($directory, $parentChecked, $preserveSelection);
261 }
262
263 return $output;
264 }
265
266 /**
267 * @param DirectoryIterator $directory
268 * @param string $basePath
269 * @param string $identifier
270 * @return string
271 */
272 protected function getPath(DirectoryIterator $directory, string $basePath, string $identifier): string
273 {
274 $realPath = $this->isAllowVfsPath && strpos($directory->getPathname(), 'vfs://') === 0 ? $directory->getPathname() : $directory->getRealPath();
275 $realPath = wp_normalize_path($realPath);
276
277 /**
278 * Do not follow root path like src/web/..
279 * This must be done before \SplFileInfo->isDir() is used!
280 * Prevents open base dir restriction fatal errors
281 */
282 if (strpos($realPath, $basePath) !== 0) {
283 throw new UnexpectedValueException("The directory at path '{$realPath}' is not within the base path '{$basePath}'.");
284 }
285
286 $path = str_replace($basePath, '', $realPath);
287 // Using strpos() for symbolic links as they could create nasty stuff in nix stuff for directory structures
288 if (!$directory->isDir() || (strlen($path) < 1 && $identifier !== PathIdentifier::IDENTIFIER_WP_CONTENT)) {
289 throw new UnexpectedValueException("The path '{$path}' is not a valid directory.");
290 }
291
292 return $path;
293 }
294
295 /**
296 * @param DirectoryNodeDto $directory
297 * @param bool $parentChecked
298 * @param bool $preserveSelection
299 * @return string
300 */
301 protected function renderDirectoryNode(DirectoryNodeDto $directory, bool $parentChecked = true, bool $preserveSelection = false): string
302 {
303 $path = wp_normalize_path($directory->getPath());
304 $relPath = str_replace($directory->getBasePath(), '', $path);
305 $relPath = ltrim($relPath, '/');
306
307 // Check if directory name or directory path is not WP core folder
308 $isNotWPCoreDir = $this->isNonWpCoreDirectory($directory->getName(), $path);
309
310 $class = $isNotWPCoreDir ? self::WP_NON_CORE_DIR : self::WP_CORE_DIR;
311 $dirType = $this->getDirectoryType($path);
312 $isScanned = 'false';
313 $normalizedPath = trailingslashit($path);
314 if (
315 $normalizedPath === $this->directory->getWpContentDirectory()
316 || $normalizedPath === $this->directory->getPluginsDirectory()
317 || $normalizedPath === $this->directory->getActiveThemeParentDirectory()
318 ) {
319 $isScanned = 'true';
320 }
321
322 $showChildByDefault = false;
323 if ($this->scanSubWpContentByDefault && ($normalizedPath === $this->wpContentPath . 'plugins/' || $normalizedPath === $this->wpContentPath . 'themes/' || $normalizedPath === $this->wpContentPath . 'uploads/')) {
324 $isScanned = 'true';
325 $showChildByDefault = true;
326 }
327
328 // Make wp-includes and wp-admin directory items not expandable. The base
329 // path keeps ABSPATH's trailing slash, so strip it before composing the
330 // core paths — otherwise the double slash never matches and both folders
331 // stay wrongly expandable.
332 $normalizedBasePath = untrailingslashit($directory->getBasePath());
333 $isNavigatable = 'true';
334 if ($this->strUtils->startsWith($path, $normalizedBasePath . "/wp-admin") !== false || $this->strUtils->startsWith($path, $normalizedBasePath . "/wp-includes") !== false) {
335 $isNavigatable = 'false';
336 }
337
338 // Decide if item checkbox is active or not
339 $shouldBeChecked = $this->useDefaultSelection ? !$isNotWPCoreDir : $parentChecked;
340 if (!$preserveSelection && $this->isUpdateOrResetJob() && (!$this->isPathInDirectories($path, $this->excludedDirectories, $directory->getBasePath()))) {
341 $shouldBeChecked = true;
342 } elseif (!$preserveSelection && $this->isUpdateOrResetJob()) {
343 $shouldBeChecked = false;
344 }
345
346 if (!$preserveSelection && $this->isUpdateOrResetJob() && $class === self::WP_NON_CORE_DIR && !$this->isPathInDirectories($path, $this->extraDirectories)) {
347 $shouldBeChecked = false;
348 }
349
350 $shouldBeChecked = $this->getShouldBeChecked($shouldBeChecked, $directory);
351 $isDisabledDir = $directory->getName() === 'wp-admin' || $directory->getName() === 'wp-includes';
352
353 $isDisabled = false;
354 if (strpos($directory->getPath(), 'wp-content/' . Directory::STAGING_SITE_DIRECTORY) !== false) {
355 $isDisabled = true;
356 $shouldBeChecked = false;
357 }
358
359 $isLink = false;
360 if (strpos(trailingslashit($directory->getBasePath()) . $directory->getName(), 'wp-content') !== false && is_link(trailingslashit($directory->getBasePath()) . $directory->getName())) {
361 $isDisabled = true;
362 $isNavigatable = 'false';
363 $shouldBeChecked = true;
364 $isLink = true;
365 $relPath = 'wp-content';
366 }
367
368 // A still-navigatable folder with no subdirectories is a leaf: there is
369 // nothing to fetch, so mark it non-navigatable and let the view render
370 // it as non-expandable instead of firing a request that returns nothing.
371 $isLeaf = false;
372 if ($isNavigatable === 'true' && !$this->directoryHasSubdirectories($path)) {
373 $isLeaf = true;
374 $isNavigatable = 'false';
375 }
376
377 // Flag folders that are themselves an existing staging site so the tree
378 // can explain why they are excluded by default.
379 $isStagingSite = in_array(rtrim($path, '/\\'), $this->getStagingSiteDirectories(), true);
380
381 return $this->templateEngine->render('staging/_partials/directory-navigation.php', [
382 'scanner' => $this,
383 'prefix' => $directory->getIdentifier(),
384 'relPath' => $relPath,
385 'class' => $class,
386 'dirType' => $dirType,
387 'isScanned' => $isScanned,
388 'isNavigatable' => $isNavigatable,
389 'isLeaf' => $isLeaf,
390 'isStagingSite' => $isStagingSite,
391 'shouldBeChecked' => $shouldBeChecked,
392 'parentChecked' => $parentChecked,
393 'directoryDisabled' => $isNotWPCoreDir || $isDisabledDir,
394 'isDisabled' => $isDisabled,
395 'dirName' => $directory->getName(),
396 'gifLoaderPath' => $this->loaderIcon,
397 'infoIconPath' => $this->infoIcon,
398 'isDebugMode' => false,
399 'dataPath' => $directory->getPath(),
400 'basePath' => $directory->getBasePath(),
401 'forceDefault' => $preserveSelection,
402 'dirPath' => $path,
403 'isLink' => $isLink,
404 'showChild' => $showChildByDefault,
405 ]);
406 }
407
408 /**
409 * Cheaply checks whether a directory contains at least one subdirectory.
410 * Returns on the first one found so it stays light even on large folders.
411 *
412 * @param string $path
413 * @return bool
414 */
415 private function directoryHasSubdirectories(string $path): bool
416 {
417 if (!is_dir($path)) {
418 return false;
419 }
420
421 try {
422 $iterator = new DirectoryIterator($path);
423 foreach ($iterator as $item) {
424 if ($item->isDot()) {
425 continue;
426 }
427
428 if ($item->isDir()) {
429 return true;
430 }
431 }
432 } catch (\Exception $e) {
433 // On any error assume it may have children so a legitimately
434 // expandable folder is never hidden.
435 return true;
436 }
437
438 return false;
439 }
440
441 /**
442 * Returns the normalized absolute paths of existing staging sites so their
443 * folders can be flagged in the tree.
444 *
445 * @return array
446 */
447 private function getStagingSiteDirectories(): array
448 {
449 if ($this->stagingSiteDirectories !== null) {
450 return $this->stagingSiteDirectories;
451 }
452
453 $this->stagingSiteDirectories = [];
454 try {
455 foreach (WPStaging::make(Sites::class)->getStagingDirectories() as $directory) {
456 $this->stagingSiteDirectories[] = wp_normalize_path(rtrim((string)$directory, '/\\'));
457 }
458 } catch (\Throwable $e) {
459 $this->stagingSiteDirectories = [];
460 }
461
462 return $this->stagingSiteDirectories;
463 }
464
465 /**
466 * Check if directory name or directory path is not WP core folder
467 *
468 * @param string $dirname
469 * @param string $path
470 * @return bool
471 */
472 protected function isNonWpCoreDirectory(string $dirname, string $path): bool
473 {
474 $coreDirectories = [
475 'wp-admin',
476 'wp-content',
477 'wp-includes',
478 ];
479
480 if (in_array($dirname, $coreDirectories)) {
481 return false;
482 }
483
484 $wpDirectories = [
485 $this->directory->getWpContentDirectory(),
486 $this->directory->getPluginsDirectory(),
487 $this->directory->getActiveThemeParentDirectory(),
488 $this->directory->getUploadsDirectory(),
489 $this->directory->getMuPluginsDirectory(),
490 ];
491
492 foreach ($wpDirectories as $wpDirectory) {
493 if (strpos(trailingslashit($path), $wpDirectory) !== false) {
494 return false;
495 }
496 }
497
498 return true;
499 }
500
501 /**
502 * Is the path present is given list of directories
503 * @param string $path
504 * @param array $directories List of directories relative to ABSPATH with leading slash
505 * @param ?string $basePath
506 *
507 * @return bool
508 */
509 protected function isPathInDirectories(string $path, array $directories, $basePath = null): bool
510 {
511 return $this->pathChecker->isPathInPathsList($path, $directories, true, $basePath);
512 }
513
514 protected function isWpContentOutsideAbspath(): bool
515 {
516 return $this->siteInfo->isWpContentOutsideAbspath();
517 }
518
519 protected function isCheckDirectorySize(): bool
520 {
521 return false;
522 }
523
524 /**
525 * Used during push
526 */
527 protected function getShouldBeChecked(bool $shouldBeChecked, DirectoryNodeDto $directory): bool
528 {
529 return $shouldBeChecked;
530 }
531
532 /**
533 * Overriden during push
534 */
535 protected function getDirectoryType(string $path): string
536 {
537 $dirType = 'other';
538 if ($this->strUtils->startsWith($path, $this->directory->getPluginsDirectory()) !== false) {
539 $pluginPath = $this->strUtils->strReplaceFirst($this->directory->getPluginsDirectory(), '', $path);
540 $dirType = strpos($pluginPath, '/') === false ? 'plugin' : 'other';
541 } elseif ($this->strUtils->startsWith($path, $this->directory->getActiveThemeParentDirectory()) !== false) {
542 $themePath = $this->strUtils->strReplaceFirst($this->directory->getActiveThemeParentDirectory(), '', $path);
543 $dirType = strpos($themePath, '/') === false ? 'theme' : 'other';
544 }
545
546 return $dirType;
547 }
548 }
549