PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 4.9.2
WP STAGING – WordPress Backup, Restore, Migration & Clone v4.9.2
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 / Backup / Service / Archiver.php
wp-staging / Backup / Service Last commit date
Compression 4 months ago Database 2 weeks ago AbstractBackupsFinder.php 1 year ago AbstractExtractor.php 1 day ago AbstractServiceProvider.php 2 years ago Archiver.php 2 weeks ago BackupAssets.php 1 month ago BackupContent.php 1 year ago BackupMetadataEditor.php 1 year ago BackupMetadataReader.php 5 months ago BackupSigner.php 7 months ago BackupsDirectoryResolver.php 2 weeks ago BackupsFinder.php 2 weeks ago Extractor.php 2 weeks ago FileBackupService.php 1 day ago FileBackupServiceProvider.php 2 years ago ServiceInterface.php 2 years ago TmpBackupCleaner.php 2 weeks ago ZlibCompressor.php 11 months ago
Archiver.php
870 lines
1 <?php
2
3 // TODO PHP7.1; constant visibility
4
5 namespace WPStaging\Backup\Service;
6
7 use Exception;
8 use LogicException;
9 use RuntimeException;
10 use WPStaging\Backup\BackupFileIndex;
11 use WPStaging\Backup\BackupHeader;
12 use WPStaging\Backup\Dto\Job\JobBackupDataDto;
13 use WPStaging\Backup\Dto\Service\ArchiverDto;
14 use WPStaging\Backup\Entity\BackupMetadata;
15 use WPStaging\Backup\Exceptions\BackupSkipItemException;
16 use WPStaging\Backup\FileHeader;
17 use WPStaging\Core\WPStaging;
18 use WPStaging\Framework\Adapter\Directory;
19 use WPStaging\Framework\Adapter\PhpAdapter;
20 use WPStaging\Framework\Facades\Hooks;
21 use WPStaging\Framework\Filesystem\DiskWriteCheck;
22 use WPStaging\Framework\Filesystem\Filesystem;
23 use WPStaging\Framework\Filesystem\PartIdentifier;
24 use WPStaging\Framework\Filesystem\PathIdentifier;
25 use WPStaging\Framework\Job\Dto\JobDataDto;
26 use WPStaging\Framework\Job\Exception\DiskNotWritableException;
27 use WPStaging\Framework\Job\Exception\NotFinishedException;
28 use WPStaging\Framework\Job\Exception\ThresholdException;
29 use WPStaging\Framework\Traits\EndOfLinePlaceholderTrait;
30 use WPStaging\Framework\Utils\Cache\BufferedCache;
31 use WPStaging\Vendor\lucatume\DI52\NotFoundException;
32
33 use function WPStaging\functions\debug_log;
34
35 /**
36 * This class is responsible for archiving files and creating backups.
37 */
38 class Archiver
39 {
40 use EndOfLinePlaceholderTrait;
41
42 /**
43 * @var string
44 */
45 const BACKUP_EXTENSION = 'wpstg';
46
47 /**
48 * Used during push and pull jobs
49 * @var string
50 */
51 const TMP_BACKUP_EXTENSION = 'wpstgtmp';
52
53 /**
54 * After this number of failed requests, we will extend the execution time by 5s for file append.
55 * @var int
56 */
57 const MAX_RETRIES_BEFORE_EXTENDING_TIME_LIMIT = 1;
58
59 /**
60 * The maximum execution time limit allowed (in seconds), even if PHP is configured
61 * * with a higher value or unlimited (0 or -1).
62 */
63 const MAX_ALLOWED_PHP_TIME_LIMIT = 60;
64
65 /**
66 * The minimum execution time limit (in seconds) enforced when the server configuration
67 * * sets a very low value for max_execution_time below this threshold.
68 */
69 const MIN_ALLOWED_PHP_TIME_LIMIT = 10;
70
71 /**
72 * The fraction (percentage) of the allowed PHP time limit to use.
73 * This ensures some buffer time remains before reaching the actual limit.
74 * @var float
75 */
76 const PHP_TIME_LIMIT_IN_FRACTION = 0.8;
77
78 /** @var string */
79 const BACKUP_DIR_NAME = 'backups';
80
81 /** @var bool */
82 const CREATE_BINARY_HEADER = true;
83
84 /** @var BufferedCache */
85 protected $tempBackupIndex;
86
87 /** @var BufferedCache */
88 protected $tempBackup;
89
90 /** @var ArchiverDto */
91 protected $archiverDto;
92
93 /** @var PathIdentifier */
94 protected $pathIdentifier;
95
96 /** @var int */
97 protected $archivedFileSize = 0;
98
99 /** @var JobDataDto */
100 protected $jobDataDto;
101
102 /** @var PhpAdapter */
103 protected $phpAdapter;
104
105 /** @var bool */
106 protected $isLocalBackup = false;
107
108 /** @var int */
109 protected $bytesWrittenInThisRequest = 0;
110
111 /** @var FileHeader */
112 protected $fileHeader;
113
114 /** @var BackupHeader */
115 protected $backupHeader;
116
117 /** @var BackupFileIndex */
118 protected $backupFileIndex;
119
120 /** @var Filesystem */
121 protected $filesystem;
122
123 /** @var bool */
124 protected $isTempBackup = false;
125
126 public function __construct(
127 BufferedCache $cacheIndex,
128 BufferedCache $tempBackup,
129 PathIdentifier $pathIdentifier,
130 JobDataDto $jobDataDto,
131 ArchiverDto $archiverDto,
132 PhpAdapter $phpAdapter,
133 BackupFileIndex $backupFileIndex,
134 FileHeader $fileHeader,
135 BackupHeader $backupHeader,
136 Filesystem $filesystem
137 ) {
138 $this->jobDataDto = $jobDataDto;
139 $this->archiverDto = $archiverDto;
140 $this->tempBackupIndex = $cacheIndex;
141 $this->tempBackup = $tempBackup;
142 $this->pathIdentifier = $pathIdentifier;
143 $this->phpAdapter = $phpAdapter;
144 $this->backupFileIndex = $backupFileIndex;
145 $this->fileHeader = $fileHeader;
146 $this->backupHeader = $backupHeader;
147 $this->filesystem = $filesystem;
148 }
149
150 /**
151 * @param int $fileAppendTimeLimit
152 * @return void
153 */
154 public function setFileAppendTimeLimit(int $fileAppendTimeLimit)
155 {
156 $this->tempBackup->setFileAppendTimeLimit($fileAppendTimeLimit);
157 $this->tempBackupIndex->setFileAppendTimeLimit($fileAppendTimeLimit);
158 }
159
160 /**
161 * @param bool $isTempBackup
162 * @return void
163 */
164 public function setIsTempBackup(bool $isTempBackup)
165 {
166 $this->isTempBackup = $isTempBackup;
167 }
168
169 /**
170 * @param bool $isCreateBinaryHeader
171 * @return void
172 */
173 public function createArchiveFile(bool $isCreateBinaryHeader = false)
174 {
175 $this->setupTmpBackupFile();
176
177 if ($isCreateBinaryHeader && !$this->tempBackup->isValid()) {
178 // Create temp file with binary header
179 $this->tempBackup->save($this->isBackupFormatV1() ? $this->backupHeader->getV1FormatHeader() : $this->backupHeader->getHeader() . "\n");
180 }
181 }
182
183 /**
184 * Setup temp backup file and temp files index file for the given job id,
185 * @return void
186 */
187 public function setupTmpBackupFile()
188 {
189 $this->tempBackup->setFilename('temp_wpstg_backup_' . $this->jobDataDto->getId());
190 $this->tempBackup->setLifetime(DAY_IN_SECONDS);
191
192 $tempBackupIndexFilePrefix = 'temp_backup_index_';
193 $this->tempBackupIndex->setFilename($tempBackupIndexFilePrefix . $this->jobDataDto->getId());
194 $this->tempBackupIndex->setLifetime(DAY_IN_SECONDS);
195 }
196
197 /**
198 * @var bool $isLocalBackup
199 */
200 public function setIsLocalBackup(bool $isLocalBackup)
201 {
202 $this->isLocalBackup = $isLocalBackup;
203 }
204
205 /**
206 * @return ArchiverDto
207 */
208 public function getDto(): ArchiverDto
209 {
210 return $this->archiverDto;
211 }
212
213 /**
214 * @return int
215 */
216 public function getBytesWrittenInThisRequest(): int
217 {
218 return $this->bytesWrittenInThisRequest;
219 }
220
221 /**
222 * @return BufferedCache
223 */
224 public function getTempBackupIndex(): BufferedCache
225 {
226 return $this->tempBackupIndex;
227 }
228
229 /**
230 * @return BufferedCache
231 */
232 public function getTempBackup(): BufferedCache
233 {
234 return $this->tempBackup;
235 }
236
237 /**
238 * @param string $fullFilePath
239 * @param string $indexPath
240 *
241 * `true` -> finished
242 * `false` -> not finished
243 *
244 * @throws DiskNotWritableException
245 * @throws RuntimeException
246 * @throws BackupSkipItemException Skip this file don't do anything
247 * @throws ThresholdException
248 *
249 * @return bool
250 */
251 public function appendFileToBackup(string $fullFilePath, string $indexPath = ''): bool
252 {
253 // We can use evil '@' as we don't check is_file || file_exists to speed things up.
254 // Since in this case speed > anything else
255 // However if @ is not used, depending on if file exists or not this can throw E_WARNING.
256 $resource = @fopen($fullFilePath, 'rb');
257 if (!$resource) {
258 debug_log("appendFileToBackup(): Can't open file {$fullFilePath} for reading");
259 throw new BackupSkipItemException();
260 }
261
262 if (empty($indexPath)) {
263 $indexPath = $fullFilePath;
264 }
265
266 $indexPath = $this->replaceEOLsWithPlaceholders($indexPath);
267 $fileStats = fstat($resource);
268 $this->initiateDtoByFilePath($fullFilePath, $fileStats);
269 $this->archiverDto->setIndexPath($indexPath);
270 $fileHeaderSizeInBytes = 0;
271 if (!$this->isBackupFormatV1() && !$this->archiverDto->isFileHeaderWritten()) {
272 $fileHeaderSizeInBytes = $this->writeFileHeader($fullFilePath, $indexPath);
273 $this->archiverDto->setFileHeaderSizeInBytes($fileHeaderSizeInBytes);
274 } elseif (!$this->isBackupFormatV1()) {
275 $identifiablePath = $this->pathIdentifier->transformPathToIdentifiable($this->filesystem->maybeNormalizePath($indexPath));
276 $this->fileHeader->readFile($fullFilePath, $identifiablePath);
277 }
278
279 $writtenBytesBefore = $this->archiverDto->getWrittenBytesTotal();
280 try {
281 $writtenBytesTotal = $this->appendToArchiveFile($resource, $fullFilePath);
282 } catch (ThresholdException $ex) {
283 // Let close the file resource before re-throwing the exception
284 fclose($resource);
285 $resource = null;
286 $this->maybeIncrementFileAppendTimeLimit();
287
288 throw $ex;
289 }
290
291 $newBytesWritten = $writtenBytesTotal + $fileHeaderSizeInBytes - $writtenBytesBefore;
292 $writtenBytesIncludingFileHeader = $writtenBytesTotal + $this->archiverDto->getFileHeaderSizeInBytes();
293
294 if (!$this->isBackupFormatV1() && empty($this->fileHeader->getFilePath())) {
295 $identifiablePath = $this->pathIdentifier->transformPathToIdentifiable($this->filesystem->maybeNormalizePath($indexPath));
296 $this->fileHeader->readFile($fullFilePath, $identifiablePath);
297 }
298
299 $retries = 0;
300
301 if (!$this->isBackupFormatV1() && empty($this->fileHeader->getFilePath())) {
302 $identifiablePath = $this->pathIdentifier->transformPathToIdentifiable($this->filesystem->maybeNormalizePath($indexPath));
303 $this->fileHeader->readFile($fullFilePath, $identifiablePath);
304 }
305
306 do {
307 if ($retries > 0) {
308 usleep((int)$this->getDelayForRetry($retries));
309 }
310
311 $bytesAddedForIndex = $this->addIndex($writtenBytesIncludingFileHeader, $newBytesWritten);
312 $retries++;
313 } while ($bytesAddedForIndex === 0 && $retries < 3);
314
315 $this->archiverDto->setWrittenBytesTotal($writtenBytesTotal);
316
317 $this->bytesWrittenInThisRequest += $newBytesWritten;
318
319 $isFinished = $this->archiverDto->isFinished();
320 if ($isFinished) {
321 $this->resetFileAppendTimeLimitAndRetries();
322 }
323
324 $this->archiverDto->resetIfFinished();
325
326 return $isFinished;
327 }
328
329 /**
330 * @param string $filePath
331 * @param array $fileStats
332 * @param bool
333 */
334 public function initiateDtoByFilePath(string $filePath, array $fileStats = []): bool
335 {
336 if (empty($filePath) || ($filePath === $this->archiverDto->getFilePath() && $fileStats['size'] === $this->archiverDto->getFileSize())) {
337 return false;
338 }
339
340 $this->archiverDto->setFilePath($filePath);
341 $this->archiverDto->setFileSize($fileStats['size']);
342 return true;
343 }
344
345 /**
346 * Combines index and archive file, renames / moves it to destination
347 *
348 * This function is called only once, so performance improvements has no impact here.
349 *
350 * @param int $backupSizeBeforeAddingIndex
351 * @param string $finalFileNameOnRename
352 *
353 * @return string
354 */
355 public function generateBackupMetadata(int $backupSizeBeforeAddingIndex = 0, string $finalFileNameOnRename = ''): string
356 {
357 clearstatcache();
358 $backupSizeAfterAddingIndex = filesize($this->tempBackup->getFilePath());
359
360 $backupMetadata = $this->archiverDto->getBackupMetadata();
361 $backupMetadata->setHeaderStart($backupSizeBeforeAddingIndex);
362 $backupMetadata->setHeaderEnd($backupSizeAfterAddingIndex);
363
364 if ($this->jobDataDto instanceof JobBackupDataDto) {
365 /** @var JobBackupDataDto */
366 $jobDataDto = $this->jobDataDto;
367 $this->setBackupMetadataCategoryInfo($backupMetadata, $jobDataDto);
368 }
369
370 $this->tempBackup->append(json_encode($backupMetadata));
371 if (!$this->isBackupFormatV1()) {
372 $this->backupHeader->readFromPath($this->tempBackup->getFilePath());
373 $this->backupHeader->setMetadataStartOffset($backupSizeAfterAddingIndex);
374 $this->backupHeader->setMetadataEndOffset($backupSizeAfterAddingIndex);
375 $this->backupHeader->updateHeader($this->tempBackup->getFilePath());
376 }
377
378 return $this->renameBackup($finalFileNameOnRename);
379 }
380
381 /** @return int */
382 public function addFileIndex(): int
383 {
384 clearstatcache();
385 $indexResource = fopen($this->tempBackupIndex->getFilePath(), 'rb');
386
387 if (!$indexResource) {
388 debug_log('[Add File Index] Nothing to backup, no index resource! File Index: ' . $this->tempBackupIndex->getFilePath());
389 throw new NotFoundException('Nothing to backup, no index resource found!');
390 }
391
392 static $isFirstInsert = false;
393 $insertSeparator = '';
394 if ($isFirstInsert === false) {
395 $lastLine = $this->tempBackup->readLastLine();
396 if (!empty($lastLine) && preg_match('@^INSERT\sINTO\s@', $lastLine)) {
397 $isFirstInsert = true;
398 $insertSeparator = "\n--\n-- SQL DATA END\n--\n";
399 $this->tempBackup->append($insertSeparator);
400 $this->tempBackup->deleteBottomBytes(strlen(PHP_EOL));
401 }
402 }
403
404 $indexStats = fstat($indexResource);
405 $this->initiateDtoByFilePath($this->tempBackupIndex->getFilePath(), $indexStats);
406
407 $lastLine = $this->tempBackup->readLastLine();
408 $writtenBytes = $this->archiverDto->getWrittenBytesTotal();
409 if ($lastLine !== PHP_EOL && $writtenBytes === 0) {
410 $this->tempBackup->append(''); // ensure that file index start from new line. See https://github.com/wp-staging/wp-staging-pro/issues/2861
411 }
412
413 clearstatcache();
414 $backupSizeBeforeAddingIndex = filesize($this->tempBackup->getFilePath());
415 $backupIndexFileSize = filesize($this->tempBackupIndex->getFilePath());
416
417 // Write the index to the backup file, regardless of resource limits threshold
418 // @throws Exception
419 $writtenBytes = $this->appendToArchiveFile($indexResource, $this->tempBackupIndex->getFilePath());
420 $this->archiverDto->setWrittenBytesTotal($writtenBytes);
421
422 if ($writtenBytes === 0) {
423 $this->jobDataDto->setRetries($this->jobDataDto->getRetries() + 1);
424 } else {
425 $this->jobDataDto->setRetries(0);
426 }
427
428 // close the index file handle to make it deletable for Windows where PHP < 7.3
429 fclose($indexResource);
430
431 if ($this->jobDataDto->getRetries() > 3) {
432 $indexSize = $backupIndexFileSize === false ? 0 : size_format($backupIndexFileSize, 3);
433 debug_log(sprintf('[Add File Index] Failed to write files-index to backup file! Tmp Size: %s. Index Size: %s', size_format($backupSizeBeforeAddingIndex, 3), $indexSize));
434 throw new Exception(sprintf('Failed to write files-index to backup file! Tmp Size: %s. Index Size: %s', size_format($backupSizeBeforeAddingIndex, 3), $indexSize));
435 } elseif ($writtenBytes === 0) {
436 debug_log('[Add File Index] Failed to write any byte to files-index! Retrying...');
437 }
438
439 if (!$this->archiverDto->isFinished()) {
440 throw new NotFinishedException('File backup is not finished yet!');
441 }
442
443 $this->tempBackupIndex->delete();
444 $this->archiverDto->reset();
445
446 $backupSizeAfterAddingIndex = filesize($this->tempBackup->getFilePath());
447 if (!$this->isBackupFormatV1()) {
448 $this->backupHeader->setFilesIndexStartOffset($backupSizeBeforeAddingIndex);
449 $this->backupHeader->setFilesIndexEndOffset($backupSizeAfterAddingIndex);
450 $this->backupHeader->updateHeader($this->tempBackup->getFilePath());
451 }
452
453 $this->tempBackup->append(PHP_EOL);
454
455 return $backupSizeBeforeAddingIndex;
456 }
457
458 /**
459 * @return string
460 */
461 public function getDestinationPath(): string
462 {
463 return sprintf(
464 '%s_%s_%s.%s',
465 parse_url(get_home_url())['host'],
466 current_time('Ymd-His'),
467 $this->jobDataDto->getId(),
468 self::BACKUP_EXTENSION
469 );
470 }
471
472 /**
473 * @param string $renameFileTo
474 * @param bool $isLocalBackup
475 * @return string
476 */
477 public function getFinalPath(string $renameFileTo = '', bool $isLocalBackup = true): string
478 {
479 $backupsDirectory = $this->getFinalBackupParentDirectory($isLocalBackup);
480 if ($renameFileTo === '') {
481 $renameFileTo = $this->getDestinationPath();
482 }
483
484 return $backupsDirectory . $renameFileTo;
485 }
486
487 public function getFinalBackupParentDirectory(bool $isLocalBackup = true): string
488 {
489 if ($isLocalBackup) {
490 return WPStaging::make(BackupsFinder::class)->getBackupsDirectory();
491 }
492
493 return WPStaging::make(Directory::class)->getCacheDirectory();
494 }
495
496 /**
497 * @param string $filePath
498 * @param string $indexPath
499 * @return int
500 */
501 protected function writeFileHeader(string $filePath, string $indexPath): int
502 {
503 $identifiablePath = $this->pathIdentifier->transformPathToIdentifiable($this->filesystem->maybeNormalizePath($indexPath));
504 $this->fileHeader->readFile($filePath, $identifiablePath);
505
506 return $this->tempBackup->append($this->fileHeader->getFileHeader());
507 }
508
509 /**
510 * Get delay in milliseconds for retry according to retry number
511 *
512 * @param int $retry
513 * @return float
514 */
515 protected function getDelayForRetry(int $retry): float
516 {
517 $delay = 0.1;
518 for ($i = 0; $i < $retry; $i++) {
519 $delay *= 2;
520 }
521
522 return $delay * 1000;
523 }
524
525 /**
526 * @param BackupMetadata $backupMetadata
527 * @param JobBackupDataDto $jobBackupDataDto
528 * @return void
529 */
530 protected function setBackupMetadataCategoryInfo(BackupMetadata $backupMetadata, JobBackupDataDto $jobBackupDataDto)
531 {
532 $backupMetadata->setIndexPartSize($jobBackupDataDto->getCategorySizes());
533 }
534
535 /**
536 * @param JobBackupDataDto $jobBackupDataDto
537 * @return void
538 */
539 protected function incrementFilesCount(JobBackupDataDto $jobBackupDataDto)
540 {
541 $jobBackupDataDto->setTotalFiles($jobBackupDataDto->getTotalFiles() + 1);
542 }
543
544 /**
545 * @return void
546 */
547 protected function setIndexPositionCreated()
548 {
549 $this->archiverDto->setIndexPositionCreated(true);
550 }
551
552 /**
553 * @return bool
554 */
555 protected function isIndexPositionCreated(): bool
556 {
557 return $this->archiverDto->isIndexPositionCreated();
558 }
559
560 /**
561 * @return void
562 * @throws RuntimeException
563 */
564 protected function maybeIncrementFileAppendTimeLimit()
565 {
566 $this->jobDataDto->incrementNumberOfRetries();
567 if ($this->jobDataDto->getNumberOfRetries() > self::MAX_RETRIES_BEFORE_EXTENDING_TIME_LIMIT) {
568 return;
569 }
570
571 /** @var JobBackupDataDto */
572 $jobDataDto = $this->jobDataDto;
573 $jobDataDto->incrementFileAppendTimeLimit();
574 if ($jobDataDto->getFileAppendTimeLimit() > $this->getMaxPhpTimeLimitAllowed()) {
575 throw new RuntimeException('Maximum file append time limit exceeded. Please increase your PHP max execution time to proceed.');
576 }
577 }
578
579 protected function getMaxPhpTimeLimitAllowed(): int
580 {
581 $maxAllowedPhpTimeLimit = (int)ini_get('max_execution_time');
582 if ($maxAllowedPhpTimeLimit === 0 || $maxAllowedPhpTimeLimit === -1) {
583 $maxAllowedPhpTimeLimit = self::MAX_ALLOWED_PHP_TIME_LIMIT * self::PHP_TIME_LIMIT_IN_FRACTION;
584 return (int)Hooks::applyFilters(JobDataDto::FILTER_RESOURCES_EXECUTION_TIME_LIMIT, $maxAllowedPhpTimeLimit);
585 }
586
587 $maxAllowedPhpTimeLimit = max(self::MIN_ALLOWED_PHP_TIME_LIMIT, $maxAllowedPhpTimeLimit);
588 $maxAllowedPhpTimeLimit = min(self::MAX_ALLOWED_PHP_TIME_LIMIT, $maxAllowedPhpTimeLimit);
589 $maxAllowedPhpTimeLimit = $maxAllowedPhpTimeLimit * self::PHP_TIME_LIMIT_IN_FRACTION;
590
591 return (int)Hooks::applyFilters(JobDataDto::FILTER_RESOURCES_EXECUTION_TIME_LIMIT, $maxAllowedPhpTimeLimit);
592 }
593
594 /**
595 * @return void
596 */
597 protected function resetFileAppendTimeLimitAndRetries()
598 {
599 /** @var JobBackupDataDto */
600 $jobDataDto = $this->jobDataDto;
601
602 $jobDataDto->resetFileAppendTimeLimit();
603 $jobDataDto->resetNumberOfRetries();
604 }
605
606 protected function addNewFileHeaderToIndex(int $writtenBytes, int $startOffset): int
607 {
608 if ($this->isIndexPositionCreated()) {
609 return 0;
610 }
611
612 $this->fileHeader->setStartOffset($startOffset);
613 return $this->tempBackupIndex->append($this->fileHeader->getIndexHeader());
614 }
615
616 /**
617 * @param resource $resource
618 * @param string $filePath
619 *
620 * @return int Bytes written
621 * @throws DiskNotWritableException
622 * @throws RuntimeException
623 * @throws ThresholdException
624 */
625 protected function appendToArchiveFile($resource, string $filePath): int
626 {
627 try {
628 return $this->tempBackup->appendFile(
629 $resource,
630 $this->archiverDto->getWrittenBytesTotal()
631 );
632 } catch (DiskNotWritableException $e) {
633 debug_log('Failed to write to file: ' . $filePath);
634 throw $this->reclassifyDiskFailure($e);
635 }
636 }
637
638 /**
639 * Upgrade a generic write-failure exception to the disk-full variant when
640 * disk_free_space() can confirm the cause. Falls back to the original
641 * exception when the check is disabled, errors, or reports enough space —
642 * so unreliable disk_free_space() results never produce a false disk-full
643 * message.
644 */
645 protected function reclassifyDiskFailure(DiskNotWritableException $original): DiskNotWritableException
646 {
647 try {
648 $remainingBytes = max(
649 0,
650 $this->archiverDto->getFileSize() - $this->archiverDto->getWrittenBytesTotal()
651 );
652 WPStaging::make(DiskWriteCheck::class)->checkPathCanStoreEnoughBytes(
653 dirname($this->tempBackup->getFilePath()),
654 $remainingBytes
655 );
656 } catch (DiskNotWritableException $diskFull) {
657 return $diskFull;
658 } catch (RuntimeException $cannotDetermine) {
659 // Disk space could not be determined — keep original error.
660 }
661
662 return $original;
663 }
664
665 /**
666 * @throws RuntimeException
667 *
668 * @param string $renameFileTo
669 * @return string
670 */
671 private function renameBackup(string $renameFileTo = ''): string
672 {
673 if ($renameFileTo === '') {
674 $renameFileTo = $this->getDestinationPath();
675 }
676
677 $destination = trailingslashit(dirname($this->tempBackup->getFilePath())) . $renameFileTo;
678 if ($this->isLocalBackup) {
679 $destination = $this->getFinalPath($renameFileTo);
680 }
681
682 if (!rename($this->tempBackup->getFilePath(), $destination)) {
683 throw new RuntimeException('Failed to generate destination');
684 }
685
686 return $destination;
687 }
688
689 /**
690 * @param int $writtenBytesTotal
691 * @param int $newBytesAdded
692 * @return int
693 * @throws \WPStaging\Framework\Exceptions\IOException
694 * @throws LogicException
695 * @throws RuntimeException
696 */
697 private function addIndex(int $writtenBytesTotal, int $newBytesAdded = 0): int
698 {
699 clearstatcache();
700 if (file_exists($this->tempBackup->getFilePath())) {
701 $this->archivedFileSize = filesize($this->tempBackup->getFilePath());
702 }
703
704 $start = max($this->archivedFileSize - $writtenBytesTotal, 0);
705
706 $identifiablePath = $this->pathIdentifier->transformPathToIdentifiable($this->archiverDto->getIndexPath());
707
708 // Old backup format
709 if ($this->isBackupFormatV1() && $this->isIndexPositionCreated()) {
710 return $this->updateIndexInformationForAlreadyAddedIndex($writtenBytesTotal);
711 }
712
713 if ($this->isBackupFormatV1()) {
714 $identifiablePath = $this->pathIdentifier->transformPathToIdentifiable($this->archiverDto->getIndexPath());
715 $backupFileIndex = $this->backupFileIndex->createIndex($identifiablePath, $start, $writtenBytesTotal, false);
716 $bytesWritten = $this->tempBackupIndex->append($backupFileIndex->getIndex());
717 }
718
719 if (!$this->isBackupFormatV1()) {
720 $bytesWritten = $this->addNewFileHeaderToIndex($newBytesAdded, $start);
721 if ($this->isIndexPositionCreated()) {
722 $this->addIndexPartSize($identifiablePath, $newBytesAdded);
723 return $newBytesAdded;
724 }
725 }
726
727 $this->archiverDto->setIndexPositionCreated(true);
728
729 $this->addIndexPartSize($identifiablePath, $writtenBytesTotal);
730
731 /**
732 * We require JobDataDto in the constructor because it is wired in the DI container
733 * to the current job DTO instance. However, here we need to make sure this DTO
734 * is the jobBackupDataDto.
735 */
736 if (!$this->phpAdapter->isCallable([$this->jobDataDto, 'setTotalFiles']) || !$this->phpAdapter->isCallable([$this->jobDataDto, 'getTotalFiles'])) {
737 debug_log('This method can only be called from the context of Backup');
738 throw new LogicException('This method can only be called from the context of Backup');
739 }
740
741 /** @var JobBackupDataDto $jobBackupDataDto */
742 $jobBackupDataDto = $this->jobDataDto;
743 if ($this->archiverDto->getFileSize() >= 2 * GB_IN_BYTES) {
744 $jobBackupDataDto->setIsContaining2GBFile(true);
745 }
746
747 $this->incrementFilesCount($jobBackupDataDto);
748
749 return $bytesWritten;
750 }
751
752 /**
753 * @param string $identifiablePath
754 * @param int $newBytesWritten
755 *
756 * @return void
757 */
758 protected function addIndexPartSize(string $identifiablePath, int $newBytesWritten)
759 {
760 // Early bail if jobDataDto is not instance of jobBackupDataDto
761 if (!$this->jobDataDto instanceof JobBackupDataDto) {
762 return;
763 }
764
765 /** @var JobBackupDataDto $jobDataDto */
766 $jobDataDto = $this->jobDataDto;
767
768 $collectPartSize = $jobDataDto->getCategorySizes();
769
770 $partName = '';
771 switch ($identifiablePath) {
772 case ($this->pathIdentifier::IDENTIFIER_WP_CONTENT === substr($identifiablePath, 0, strlen($this->pathIdentifier::IDENTIFIER_WP_CONTENT))):
773 $partName = PartIdentifier::WP_CONTENT_PART_SIZE_IDENTIFIER;
774
775 if ($this->pathIdentifier->hasDropinsFile($identifiablePath)) {
776 $dropinsPartName = PartIdentifier::DROPIN_PART_SIZE_IDENTIFIER;
777 if (!isset($collectPartSize[$dropinsPartName])) {
778 $collectPartSize[$dropinsPartName] = 0;
779 }
780
781 $collectPartSize[$dropinsPartName] += $newBytesWritten;
782 }
783
784 break;
785 case ($this->pathIdentifier::IDENTIFIER_PLUGINS === substr($identifiablePath, 0, strlen($this->pathIdentifier::IDENTIFIER_PLUGINS))):
786 $partName = PartIdentifier::PLUGIN_PART_SIZE_IDENTIFIER;
787 break;
788 case ($this->pathIdentifier::IDENTIFIER_THEMES === substr($identifiablePath, 0, strlen($this->pathIdentifier::IDENTIFIER_THEMES))):
789 $partName = PartIdentifier::THEME_PART_SIZE_IDENTIFIER;
790 break;
791 case ($this->pathIdentifier::IDENTIFIER_MUPLUGINS === substr($identifiablePath, 0, strlen($this->pathIdentifier::IDENTIFIER_MUPLUGINS))):
792 $partName = PartIdentifier::MU_PLUGIN_PART_SIZE_IDENTIFIER;
793 break;
794 case ($this->pathIdentifier::IDENTIFIER_UPLOADS === substr($identifiablePath, 0, strlen($this->pathIdentifier::IDENTIFIER_UPLOADS))):
795 $partName = PartIdentifier::UPLOAD_PART_SIZE_IDENTIFIER;
796 if (substr($identifiablePath, -4) === '.sql') {
797 $partName = PartIdentifier::DATABASE_PART_SIZE_IDENTIFIER;
798 }
799
800 break;
801 case ($this->pathIdentifier::IDENTIFIER_LANG === substr($identifiablePath, 0, strlen($this->pathIdentifier::IDENTIFIER_LANG))):
802 $partName = PartIdentifier::LANGUAGE_PART_SIZE_IDENTIFIER;
803 break;
804 case ($this->pathIdentifier::IDENTIFIER_ABSPATH === substr($identifiablePath, 0, strlen($this->pathIdentifier::IDENTIFIER_ABSPATH))):
805 $partName = PartIdentifier::WP_ROOT_PART_SIZE_IDENTIFIER;
806 break;
807 }
808
809 if (empty($partName)) {
810 return;
811 }
812
813 // If identifier not in array yet
814 if (!isset($collectPartSize[$partName])) {
815 $collectPartSize[$partName] = 0;
816 }
817
818 $collectPartSize[$partName] += $newBytesWritten;
819 $jobDataDto->setCategorySizes($collectPartSize);
820 }
821
822 /**
823 * Used in v1 Backup Format
824 * At the moment this is used when processing adding of big file which is not done in a single request
825 * @param int $writtenBytesTotal
826 * @return int
827 * @throws RuntimeException
828 */
829 private function updateIndexInformationForAlreadyAddedIndex(int $writtenBytesTotal): int
830 {
831 $lastLine = $this->tempBackupIndex->readLines(1, null, BufferedCache::POSITION_BOTTOM);
832 if (!is_array($lastLine)) {
833 debug_log('Failed to read backup metadata file index information. Error: The last line is no array. Last line: ' . $lastLine);
834 throw new RuntimeException('Failed to read backup metadata file index information. Error: The last line is no array.');
835 }
836
837 $lastLine = array_filter($lastLine, [$this->backupFileIndex, 'isIndexLine']);
838
839 if (count($lastLine) !== 1) {
840 debug_log('Failed to read backup metadata file index information. Error: The last line is not an array or element with countable interface. Last line: ' . print_r($lastLine, true));
841 throw new RuntimeException('Failed to read backup metadata file index information. Error: The last line is not an array or element with countable interface.');
842 }
843
844 $lastLine = array_shift($lastLine);
845
846 $backupFileIndex = $this->backupFileIndex->readIndex($lastLine);
847 $writtenPreviously = $backupFileIndex->bytesEnd;
848
849 $this->tempBackupIndex->deleteBottomBytes(strlen($lastLine));
850
851 $identifiablePath = $this->pathIdentifier->transformPathToIdentifiable($this->archiverDto->getIndexPath());
852 $backupFileIndex = $this->backupFileIndex->createIndex($identifiablePath, $backupFileIndex->bytesStart, $writtenBytesTotal, false);
853 $bytesWritten = $this->tempBackupIndex->append($backupFileIndex->getIndex());
854
855 $this->setIndexPositionCreated();
856
857 // We only need to increment newly added bytes
858 $this->addIndexPartSize($identifiablePath, $writtenBytesTotal - (int)$writtenPreviously);
859
860 return $bytesWritten;
861 }
862
863 private function isBackupFormatV1(): bool
864 {
865 /** @var JobBackupDataDto */
866 $jobDataDto = $this->jobDataDto;
867 return $jobDataDto->getIsBackupFormatV1();
868 }
869 }
870