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 / FileCopier.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
FileCopier.php
475 lines
1 <?php
2
3 namespace WPStaging\Staging\Service;
4
5 use WPStaging\Framework\Adapter\Directory;
6 use WPStaging\Framework\Facades\Hooks;
7 use WPStaging\Framework\Job\Dto\StepsDto;
8 use WPStaging\Framework\Job\Exception\DiskNotWritableException;
9 use WPStaging\Framework\Filesystem\Filesystem;
10 use WPStaging\Framework\Filesystem\FilesystemScanner;
11 use WPStaging\Framework\Filesystem\Permissions;
12 use WPStaging\Framework\Queue\FinishedQueueException;
13 use WPStaging\Framework\Queue\SeekableQueueInterface;
14 use WPStaging\Framework\SiteInfo;
15 use WPStaging\Framework\Traits\EndOfLinePlaceholderTrait;
16 use WPStaging\Framework\Traits\ResourceTrait;
17 use WPStaging\Framework\Utils\Strings;
18 use WPStaging\Staging\Dto\Service\BigFileDto;
19 use WPStaging\Vendor\Psr\Log\LoggerInterface;
20
21 use function WPStaging\functions\debug_log;
22
23 /**
24 * This class is responsible for copying files from the current site to the staging site in batches.
25 */
26 class FileCopier
27 {
28 use ResourceTrait;
29 use EndOfLinePlaceholderTrait;
30
31 /**
32 * @var int 512KB
33 */
34 const BATCH_SIZE = 512 * 1024;
35
36 /**
37 * @var string
38 */
39 const FILTER_COPY_BATCH_SIZE = 'wpstg.clone.copy_batch_size';
40
41 /** @var Filesystem */
42 protected $filesystem;
43
44 /** @var Directory */
45 protected $directory;
46
47 /** @var SiteInfo */
48 protected $siteInfo;
49
50 /** @var Permissions */
51 protected $permissions;
52
53 /** @var Strings */
54 protected $strings;
55
56 /** @var SeekableQueueInterface */
57 protected $taskQueue;
58
59 /** @var LoggerInterface */
60 protected $logger;
61
62 /** @var StepsDto */
63 protected $stepsDto;
64
65 /** @var ?BigFileDto If a file couldn't be processed in a single request, this will be populated */
66 protected $bigFileDto = null;
67
68 /** @var bool */
69 protected $isWpContentOutsideAbspath = false;
70
71 /** @var string */
72 protected $fileIdentifier;
73
74 /** @var int */
75 protected $batchSize = 0;
76
77 /** @var string */
78 protected $stagingSitePath = '';
79
80 /** @var string */
81 protected $absPath = ABSPATH;
82
83 /** @var string */
84 protected $wpContentDir = WP_CONTENT_DIR;
85
86 /**
87 * @var bool
88 */
89 protected $isWpContent = false;
90
91 public function __construct(Filesystem $filesystem, Directory $directory, SiteInfo $siteInfo, Permissions $permissions, Strings $strings)
92 {
93 $this->filesystem = $filesystem;
94 $this->permissions = $permissions;
95 $this->strings = $strings;
96 $this->siteInfo = $siteInfo;
97 $this->directory = $directory;
98
99 $this->isWpContentOutsideAbspath = $this->siteInfo->isWpContentOutsideAbspath();
100 $this->absPath = $this->filesystem->normalizePath($this->directory->getAbsPath(), true);
101 $this->wpContentDir = $this->filesystem->normalizePath($this->directory->getWpContentDirectory(), true);
102 }
103
104 /**
105 * @param SeekableQueueInterface $taskQueue
106 * @param LoggerInterface $logger
107 * @param StepsDto $stepsDto
108 * @return void
109 */
110 public function inject(SeekableQueueInterface $taskQueue, LoggerInterface $logger, StepsDto $stepsDto)
111 {
112 $this->taskQueue = $taskQueue;
113 $this->logger = $logger;
114 $this->stepsDto = $stepsDto;
115 }
116
117 /**
118 * @param BigFileDto $bigFileDto
119 * @return void
120 */
121 public function setupBigFileBeingCopied(BigFileDto $bigFileDto)
122 {
123 if (empty($bigFileDto->getFilePath()) || $bigFileDto->getFileSize() <= 0) {
124 $this->bigFileDto = null;
125 return;
126 }
127
128 $this->bigFileDto = $bigFileDto;
129 }
130
131 /**
132 * @return ?BigFileDto
133 */
134 public function getBigFileDto()
135 {
136 return $this->bigFileDto;
137 }
138
139 /**
140 * @param string $stagingSitePath
141 * @param string $fileIdentifier
142 * @param bool $isWpContent
143 * @return void
144 */
145 public function setup(string $stagingSitePath, string $fileIdentifier, bool $isWpContent = false)
146 {
147 $this->stagingSitePath = $this->filesystem->normalizePath($stagingSitePath, true);
148 $this->fileIdentifier = $fileIdentifier;
149 $this->isWpContent = $isWpContent;
150
151 // Default batch size is 512KB
152 $this->batchSize = Hooks::applyFilters(self::FILTER_COPY_BATCH_SIZE, self::BATCH_SIZE);
153 }
154
155 /**
156 * @return void
157 */
158 public function execute()
159 {
160 while (!$this->isThreshold() && !$this->stepsDto->isFinished()) {
161 try {
162 $this->copy();
163 } catch (FinishedQueueException $exception) {
164 $this->stepsDto->finish();
165 $this->logger->info(sprintf('Copied %d/%d %s files', $this->stepsDto->getCurrent(), $this->stepsDto->getTotal(), $this->fileIdentifier));
166
167 return;
168 } catch (DiskNotWritableException $exception) {
169 // Probably disk full. Should be handled in Job\AbstractJob::prepareAndExecute(). Let's stop the code here if it did not happen!
170 throw new \Exception('Disk is probably full. Error message: ' . $exception->getMessage());
171 } catch (\Throwable $th) {
172 throw new \Exception('Fail to copy file. Error message: ' . $th->getMessage());
173 }
174 }
175
176 if ($this->bigFileDto instanceof BigFileDto) {
177 $relativePathForLogging = str_replace($this->filesystem->normalizePath(ABSPATH, true), '', $this->filesystem->normalizePath($this->bigFileDto->getFilePath(), true));
178 $percentProcessed = ceil(($this->bigFileDto->getWrittenBytesTotal() / $this->bigFileDto->getFileSize()) * 100);
179 $this->logger->info(sprintf('Copying big %s file: %s - %s/%s (%s%%)', $this->fileIdentifier, $relativePathForLogging, size_format($this->bigFileDto->getWrittenBytesTotal(), 2), size_format($this->bigFileDto->getFileSize(), 2), $percentProcessed));
180 } else {
181 $this->logger->info(sprintf('Copied %d/%d %s files', $this->stepsDto->getCurrent(), $this->stepsDto->getTotal(), $this->fileIdentifier));
182 }
183 }
184
185 /**
186 * @throws DiskNotWritableException
187 * @throws FinishedQueueException
188 * @return void
189 */
190 public function copy()
191 {
192 $path = $this->taskQueue->dequeue();
193 $path = $this->replacePlaceholdersWithEOLs($path);
194
195 if (is_null($path)) {
196 throw new FinishedQueueException();
197 }
198
199 if (empty($path)) {
200 return;
201 }
202
203 $indexPath = '';
204 if (strpos($path, FilesystemScanner::PATH_SEPARATOR) !== false) {
205 list($path, $indexPath) = explode(FilesystemScanner::PATH_SEPARATOR, $path);
206 }
207
208 // When wp-content is inside of ABSPATH, we need to prepend ABSPATH to the file path, as it was removed while scanning
209 $path = $this->maybePrependSitePath($path);
210
211 try {
212 // Directory entries come from the scanner's empty-directory enqueue path; mkdir them so
213 // empty folders are preserved on the staging site instead of being dropped.
214 if (is_dir($path)) {
215 $isFileWrittenCompletely = $this->processEmptyDirectory($path, $indexPath);
216 } else {
217 $isFileWrittenCompletely = $this->processFile($path, $indexPath);
218 }
219 } catch (\RuntimeException $e) {
220 $this->logger->warning($e->getMessage());
221 debug_log($e->getMessage());
222 $isFileWrittenCompletely = true;
223 } catch (\Throwable $th) {
224 throw $th;
225 }
226
227 // Done processing this file
228 if ($isFileWrittenCompletely === true) {
229 $this->stepsDto->incrementCurrentStep();
230 $this->bigFileDto = null;
231
232 return;
233 }
234
235 // Processing a file that could not be finished in this request
236 $this->taskQueue->retry(false);
237 }
238
239 protected function maybePrependSitePath(string $filePath): string
240 {
241 return $this->shouldPrependAbsPath() ? $this->absPath . $filePath : $filePath;
242 }
243
244 /**
245 * Queue entries are root-relative for wp-admin/wp-includes/wp-root files (scanned with rootPath = ABSPATH)
246 * and absolute for wp-content sub-trees on Flywheel-style layouts where wp-content lives outside ABSPATH —
247 * the scanner's str_replace(rootPath, '', $path) is a no-op on those paths and the original absolute path
248 * passes through. Skip prepending only in that one case.
249 */
250 protected function shouldPrependAbsPath(): bool
251 {
252 return !$this->isWpContentOutsideAbspath || !$this->isWpContent;
253 }
254
255 /**
256 * Create an empty directory on the staging site for a directory entry the scanner enqueued because no
257 * files were discovered beneath it. Without this, empty folders (e.g. a user-created placeholder dir
258 * inside wp-content) would be silently dropped during cloning — legacy did this via scanToCacheFile.
259 *
260 * @param string $sourcePath
261 * @param string $indexPath
262 * @return bool
263 */
264 protected function processEmptyDirectory(string $sourcePath, string $indexPath): bool
265 {
266 $staging = empty($indexPath) ? $sourcePath : $indexPath;
267 $staging = $this->filesystem->normalizePath($staging);
268
269 if ($this->isWpContentOutsideAbspath && $this->isWpContent) {
270 $relStagingPath = $this->strings->replaceStartWith($this->wpContentDir, '', $staging);
271 $destinationPath = $this->stagingSitePath . 'wp-content/' . $relStagingPath;
272 } else {
273 $relStagingPath = $this->strings->replaceStartWith($this->absPath, '', $staging);
274 $destinationPath = $this->stagingSitePath . $relStagingPath;
275 }
276
277 $destinationPath = $this->filesystem->normalizePath($destinationPath);
278
279 if (!is_dir($destinationPath) && !$this->filesystem->mkdir($destinationPath)) {
280 return false;
281 }
282
283 $this->chmod($destinationPath, $this->permissions->getDirectoryOctal());
284 return true;
285 }
286
287 protected function processFile(string $filePath, string $indexPath): bool
288 {
289 // Invalid file
290 if (!is_file($filePath)) {
291 throw new \RuntimeException("Invalid file. Could not copy file: $filePath");
292 }
293
294 // If file is unreadable, skip it as if succeeded
295 if (!$this->filesystem->isReadableFile($filePath)) {
296 throw new \RuntimeException("Can't read file {$filePath}");
297 }
298
299 $destinationPath = $this->getDestinationPath($filePath, $indexPath);
300
301 // Get file size
302 $fileSize = filesize($filePath);
303
304 $result = false;
305 // File is over batch size
306 if ($fileSize > $this->batchSize) {
307 $result = $this->copyBigFile($filePath, $destinationPath, $this->batchSize);
308 } else {
309 $result = $this->filesystem->copyFile($filePath, $destinationPath);
310 }
311
312 if (!$result) {
313 return false;
314 }
315
316 // Set file permissions
317 $this->chmod($destinationPath, $this->permissions->getFilePermission($destinationPath));
318
319 $this->setDirPermissions($destinationPath);
320
321 return true;
322 }
323
324 protected function copyBigFile(string $sourcePath, string $destinationPath, int $batchSize): bool
325 {
326 $this->setupBigFileCopy($sourcePath, $destinationPath);
327
328 if ($this->bigFileDto->isFinished()) {
329 return true;
330 }
331
332 $srcFile = fopen($sourcePath, 'rb');
333 $destFile = fopen($destinationPath, $this->getBigFileWriteMode($destinationPath));
334
335 if ($srcFile === false || $destFile === false) {
336 throw new \RuntimeException('Could not open file for reading or writing');
337 }
338
339 fseek($srcFile, $this->bigFileDto->getWrittenBytesTotal());
340
341 do {
342 $bytesWritten = fwrite($destFile, fread($srcFile, $batchSize));
343 if ($bytesWritten === false) {
344 throw new \RuntimeException('Could not write to file');
345 }
346
347 $this->bigFileDto->appendWrittenBytes($bytesWritten);
348 } while (!$this->isThreshold() && !$this->bigFileDto->isFinished());
349
350 fclose($srcFile);
351 fclose($destFile);
352 $srcFile = null;
353 $destFile = null;
354
355 return $this->bigFileDto->getWrittenBytesTotal() === $this->bigFileDto->getFileSize();
356 }
357
358 protected function setupBigFileCopy(string $sourcePath, string $destinationPath)
359 {
360 if ($this->bigFileDto instanceof BigFileDto && $this->isSameBigFileCopy($sourcePath, $destinationPath)) {
361 return;
362 }
363
364 $this->bigFileDto = new BigFileDto();
365 $this->bigFileDto->setFilePath($sourcePath);
366 $this->bigFileDto->setDestinationPath($destinationPath);
367 $this->bigFileDto->setFileSize(filesize($sourcePath));
368 $this->bigFileDto->setWrittenBytesTotal(0);
369 }
370
371 protected function isSameBigFileCopy(string $sourcePath, string $destinationPath): bool
372 {
373 return $this->bigFileDto->getFilePath() === wp_normalize_path($sourcePath)
374 && $this->bigFileDto->getDestinationPath() === wp_normalize_path($destinationPath)
375 && $this->bigFileDto->getFileSize() === filesize($sourcePath);
376 }
377
378 protected function getBigFileWriteMode(string $destinationPath): string
379 {
380 if ($this->bigFileDto->getWrittenBytesTotal() <= 0) {
381 return 'wb';
382 }
383
384 if (!is_file($destinationPath) || filesize($destinationPath) !== $this->bigFileDto->getWrittenBytesTotal()) {
385 $this->bigFileDto->setWrittenBytesTotal(0);
386 return 'wb';
387 }
388
389 return 'ab';
390 }
391
392 /**
393 * Gets destination file and checks if the directory exists, if it does not attempts to create it.
394 * If creating destination directory fails, it will throw exception.
395 * @param string $filePath
396 * @param string $indexPath
397 * @return string
398 */
399 protected function getDestinationPath(string $filePath, string $indexPath): string
400 {
401 if (empty($indexPath)) {
402 $stagingPath = $filePath;
403 } else {
404 $stagingPath = $indexPath;
405 }
406
407 $stagingPath = $this->filesystem->normalizePath($stagingPath);
408 if ($this->isWpContentOutsideAbspath && $this->isWpContent) {
409 $relStagingPath = $this->strings->replaceStartWith($this->wpContentDir, '', $stagingPath);
410 $destinationPath = $this->stagingSitePath . 'wp-content/' . $relStagingPath;
411 } else {
412 $relStagingPath = $this->strings->replaceStartWith($this->absPath, '', $stagingPath);
413 $destinationPath = $this->stagingSitePath . $relStagingPath;
414 }
415
416 $destinationDirectory = dirname($destinationPath);
417 // If directory already exists, return the destination path
418 if (is_dir($destinationDirectory)) {
419 return $this->filesystem->normalizePath($destinationPath);
420 }
421
422 // If directory does not exist, create it
423 if ($this->filesystem->mkdir($destinationDirectory)) {
424 return $this->filesystem->normalizePath($destinationPath);
425 }
426
427 // If directory still does not exist, throw an exception
428 if (!is_dir($destinationDirectory)) {
429 throw new \RuntimeException("Can not create directory {$destinationDirectory}." . $this->filesystem->getLogs()[0]);
430 }
431
432 return $this->filesystem->normalizePath($destinationPath);
433 }
434
435 private function setDirPermissions(string $file): bool
436 {
437 $dir = dirname($file);
438 if (is_dir($dir)) {
439 return $this->chmod($dir, $this->permissions->getDirectoryOctal());
440 }
441
442 return false;
443 }
444
445 /**
446 * @param string $path
447 * @param int $mode
448 * @return bool
449 */
450 protected function chmod(string $path, int $mode): bool
451 {
452 $lastWarning = null;
453 set_error_handler(function ($severity, $message, $file, $line) use (&$lastWarning) {
454 if (($severity & (E_WARNING | E_NOTICE)) === 0 || $file !== __FILE__ || strpos($message, 'chmod') !== 0) {
455 return false;
456 }
457
458 $lastWarning = $message;
459 return true;
460 });
461
462 try {
463 $result = \chmod($path, $mode);
464 } finally {
465 restore_error_handler();
466 }
467
468 if ($lastWarning !== null) {
469 debug_log(sprintf('chmod warning: %s', $lastWarning));
470 }
471
472 return $result !== false;
473 }
474 }
475