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