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