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