PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 3.1.3
WP STAGING – WordPress Backup, Restore, Migration & Clone v3.1.3
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 / Compressor.php
wp-staging / Backup / Service Last commit date
Database 2 years ago Multipart 3 years ago BackupAssets.php 3 years ago BackupMetadataEditor.php 3 years ago BackupsFinder.php 3 years ago Compressor.php 2 years ago Extractor.php 2 years ago
Compressor.php
608 lines
1 <?php
2
3 // TODO PHP7.x; declare(strict_types=1);
4 // TODO PHP7.x; return types && type-hints
5 // TODO PHP7.1; constant visibility
6
7 namespace WPStaging\Backup\Service;
8
9 use Exception;
10 use LogicException;
11 use RuntimeException;
12 use WPStaging\Backup\Dto\Job\JobBackupDataDto;
13 use WPStaging\Backup\Dto\JobDataDto;
14 use WPStaging\Backup\Dto\Service\CompressorDto;
15 use WPStaging\Backup\Exceptions\DiskNotWritableException;
16 use WPStaging\Backup\Service\Multipart\MultipartSplitInterface;
17 use WPStaging\Core\WPStaging;
18 use WPStaging\Framework\Adapter\Directory;
19 use WPStaging\Framework\Adapter\PhpAdapter;
20 use WPStaging\Framework\Filesystem\PathIdentifier;
21 use WPStaging\Framework\Utils\Cache\BufferedCache;
22 use WPStaging\Vendor\lucatume\DI52\NotFoundException;
23
24 use function WPStaging\functions\debug_log;
25
26 class Compressor
27 {
28 const BACKUP_DIR_NAME = 'backups';
29
30 /** @var BufferedCache */
31 private $tempBackupIndex;
32
33 /** @var BufferedCache */
34 private $tempBackup;
35
36 /** @var CompressorDto */
37 private $compressorDto;
38
39 /** @var PathIdentifier */
40 private $pathIdentifier;
41
42 /** @var int */
43 private $compressedFileSize = 0;
44
45 /** @var JobDataDto */
46 private $jobDataDto;
47
48 /** @var PhpAdapter */
49 private $phpAdapter;
50
51 /** @var MultipartSplitInterface */
52 private $multipartSplit;
53
54 /**
55 * Category can be: empty string|null|false, plugins, mu-plugins, themes, uploads, other, database
56 * Where empty string|null|false is used for single file backup,
57 * And other is for files from wp-content not including plugins, mu-plugins, themes, uploads
58 * @var string
59 */
60 private $category = '';
61
62 /**
63 * The current index of category in which appending files
64 * Not used in single file backup
65 * @var int
66 */
67 private $categoryIndex = 0;
68
69 /** @var bool */
70 private $isLocalBackup = false;
71
72 /** @var int */
73 protected $bytesWrittenInThisRequest = 0;
74
75 // TODO telescoped
76 public function __construct(BufferedCache $cacheIndex, BufferedCache $tempBackup, PathIdentifier $pathIdentifier, JobDataDto $jobDataDto, CompressorDto $compressorDto, PhpAdapter $phpAdapter, MultipartSplitInterface $multipartSplit)
77 {
78 $this->jobDataDto = $jobDataDto;
79 $this->compressorDto = $compressorDto;
80 $this->tempBackupIndex = $cacheIndex;
81 $this->tempBackup = $tempBackup;
82 $this->pathIdentifier = $pathIdentifier;
83 $this->phpAdapter = $phpAdapter;
84 $this->multipartSplit = $multipartSplit;
85
86 $this->setCategory('');
87 }
88
89 /**
90 * @param int $index
91 * @param bool $isCreateBinaryHeader
92 */
93 public function setCategoryIndex($index, $isCreateBinaryHeader = true)
94 {
95 if (empty($index)) {
96 $index = 0;
97 }
98
99 $this->categoryIndex = $index;
100 $this->setCategory($this->category, $isCreateBinaryHeader);
101 }
102
103 /**
104 * @param $category
105 * @param $isCreateBinaryHeader
106 */
107 public function setCategory($category = '', $isCreateBinaryHeader = false)
108 {
109 $this->category = $category;
110 $this->setupTmpBackupFile();
111
112 if ($isCreateBinaryHeader && !$this->tempBackup->isValid()) {
113 // Create temp file with binary header
114 $this->tempBackup->save(file_get_contents(WPSTG_PLUGIN_DIR . 'Backup/wpstgBackupHeader.txt'));
115 }
116 }
117
118 /**
119 * Setup temp backup file and temp files index file for the given job id,
120 * If multipart backup category and category index are given, then they are used to create unique file names
121 */
122 public function setupTmpBackupFile()
123 {
124 $additionalInfo = empty($this->category) ? '' : $this->category . '_' . $this->categoryIndex . '_';
125
126 $postFix = $additionalInfo . $this->jobDataDto->getId();
127
128 //debug_log("[Set Tmp Backup Files] File name postfix: " . $postFix);
129
130 $this->tempBackup->setFilename('temp_wpstg_backup_' . $postFix);
131 $this->tempBackup->setLifetime(DAY_IN_SECONDS);
132
133 $tempBackupIndexFilePrefix = 'temp_backup_index_';
134 $this->tempBackupIndex->setFilename($tempBackupIndexFilePrefix . $postFix);
135 $this->tempBackupIndex->setLifetime(DAY_IN_SECONDS);
136 }
137
138 /**
139 * @param int $fileSize
140 * @param int $maxPartSize
141 * @return bool
142 */
143 public function doExceedMaxPartSize($fileSize, $maxPartSize)
144 {
145 $allowedSize = $fileSize - $this->compressorDto->getWrittenBytesTotal();
146 $sizeAfterAdding = $allowedSize + filesize($this->tempBackup->getFilePath());
147 return $sizeAfterAdding >= $maxPartSize;
148 }
149
150 /**
151 * @var bool $isLocalBackup
152 */
153 public function setIsLocalBackup($isLocalBackup)
154 {
155 $this->isLocalBackup = $isLocalBackup;
156 }
157
158 /**
159 * @return CompressorDto
160 */
161 public function getDto()
162 {
163 return $this->compressorDto;
164 }
165
166 /**
167 * @return int
168 */
169 public function getBytesWrittenInThisRequest()
170 {
171 return $this->bytesWrittenInThisRequest;
172 }
173
174 /**
175 * @param string $fullFilePath
176 *
177 * `true` -> finished
178 * `false` -> not finished
179 * `null` -> skip / didn't do anything
180 *
181 * @throws DiskNotWritableException
182 * @throws RuntimeException
183 *
184 * @return bool|null
185 */
186 public function appendFileToBackup($fullFilePath)
187 {
188 // We can use evil '@' as we don't check is_file || file_exists to speed things up.
189 // Since in this case speed > anything else
190 // However if @ is not used, depending on if file exists or not this can throw E_WARNING.
191 $resource = @fopen($fullFilePath, 'rb');
192 if (!$resource) {
193 debug_log("appendFileToBackup(): Can't open file {$fullFilePath} for reading");
194 return null;
195 }
196
197 $fileStats = fstat($resource);
198 $this->initiateDtoByFilePath($fullFilePath, $fileStats);
199 $writtenBytesBefore = $this->compressorDto->getWrittenBytesTotal();
200 $writtenBytesTotal = $this->appendToCompressedFile($resource, $fullFilePath);
201 $bytesAddedForIndex = $this->addIndex($writtenBytesTotal);
202 $retries = 0;
203 while ($bytesAddedForIndex === 0 && $retries < 3) {
204 $delayInMs = $this->getDelayForRetry($retries);
205 // sleep in ms
206 usleep($delayInMs);
207 $bytesAddedForIndex = $this->addIndex($writtenBytesTotal);
208 $retries++;
209 }
210
211 $this->compressorDto->setWrittenBytesTotal($writtenBytesTotal);
212
213 $this->bytesWrittenInThisRequest += $writtenBytesTotal - $writtenBytesBefore;
214
215 $isFinished = $this->compressorDto->isFinished();
216
217 $this->compressorDto->resetIfFinished();
218
219 return $isFinished;
220 }
221
222 /**
223 * @param string $filePath
224 * @param array $fileStats
225 */
226 public function initiateDtoByFilePath($filePath, array $fileStats = [])
227 {
228 if ($filePath === null || ($filePath === $this->compressorDto->getFilePath() && $fileStats['size'] === $this->compressorDto->getFileSize())) {
229 return;
230 }
231
232 $this->compressorDto->setFilePath($filePath);
233 $this->compressorDto->setFileSize($fileStats['size']);
234 }
235
236 /**
237 * @param int $sizeBeforeAddingIndex
238 * @param string $category
239 * @param string $partName
240 * @param int $categoryIndex
241 */
242 public function generateBackupMetadataForBackupPart($sizeBeforeAddingIndex, $category, $partName, $categoryIndex)
243 {
244 $this->category = $category;
245 $this->categoryIndex = $categoryIndex;
246 $this->setupTmpBackupFile();
247 $this->generateBackupMetadata($sizeBeforeAddingIndex, $partName, $isBackupPart = true);
248 }
249
250 /**
251 * Combines index and compressed file, renames / moves it to destination
252 *
253 * This function is called only once, so performance improvements has no impact here.
254 *
255 * @param int $backupSizeBeforeAddingIndex
256 * @param string $finalFileNameOnRename
257 * @param bool $isBackupPart
258 *
259 * @return string|null
260 */
261 public function generateBackupMetadata($backupSizeBeforeAddingIndex = 0, $finalFileNameOnRename = '', $isBackupPart = false)
262 {
263 clearstatcache();
264 $backupSizeAfterAddingIndex = filesize($this->tempBackup->getFilePath());
265
266 $backupMetadata = $this->compressorDto->getBackupMetadata();
267 $backupMetadata->setHeaderStart($backupSizeBeforeAddingIndex);
268 $backupMetadata->setHeaderEnd($backupSizeAfterAddingIndex);
269
270 if ($isBackupPart) {
271 $this->multipartSplit->updateMultipartMetadata($this->jobDataDto, $backupMetadata, $this->category, $this->categoryIndex);
272 }
273
274 if ($this->jobDataDto instanceof JobBackupDataDto) {
275 /** @var JobBackupDataDto */
276 $jobDataDto = $this->jobDataDto;
277 $backupMetadata->setIndexPartSize($jobDataDto->getCategorySizes());
278 }
279
280 $this->tempBackup->append(json_encode($backupMetadata));
281
282 return $this->renameBackup($finalFileNameOnRename);
283 }
284
285 /**
286 * @return array
287 */
288 public function getFinalizeBackupInfo()
289 {
290 return [
291 'category' => $this->category,
292 'index' => $this->categoryIndex,
293 'filePath' => $this->tempBackup->getFilePath(),
294 'destination' => $this->getDestinationPath(),
295 'status' => 'Pending',
296 'sizeBeforeAddingIndex' => 0
297 ];
298 }
299
300 /** @return int|null */
301 public function addFileIndex()
302 {
303 clearstatcache();
304 $indexResource = fopen($this->tempBackupIndex->getFilePath(), 'rb');
305
306 if (!$indexResource) {
307 debug_log('[Add File Index] Nothing to backup, no index resource! File Index: ' . $this->tempBackupIndex->getFilePath());
308 throw new NotFoundException('Nothing to backup, no index resource found!');
309 }
310
311 static $isFirstInsert = false;
312 $insertSeparator = '';
313 if ($isFirstInsert === false) {
314 $lastLine = $this->tempBackup->readLastLine();
315 if (!empty($lastLine) && preg_match('@^INSERT\sINTO\s@', $lastLine)) {
316 $isFirstInsert = true;
317 $insertSeparator = "\n--\n-- SQL DATA END\n--\n";
318 $this->tempBackup->append($insertSeparator);
319 $this->tempBackup->deleteBottomBytes(strlen(PHP_EOL));
320 }
321 }
322
323 $indexStats = fstat($indexResource);
324 $this->initiateDtoByFilePath($this->tempBackupIndex->getFilePath(), $indexStats);
325
326 clearstatcache();
327 $backupSizeBeforeAddingIndex = filesize($this->tempBackup->getFilePath());
328
329 // Write the index to the backup file, regardless of resource limits threshold
330 // @throws Exception
331 $writtenBytes = $this->appendToCompressedFile($indexResource, $this->tempBackupIndex->getFilePath());
332 $this->compressorDto->setWrittenBytesTotal($writtenBytes);
333
334 if ($writtenBytes === 0) {
335 $this->jobDataDto->setRetries($this->jobDataDto->getRetries() + 1);
336 } else {
337 $this->jobDataDto->setRetries(0);
338 }
339
340 // close the index file handle to make it deletable for Windows where PHP < 7.3
341 fclose($indexResource);
342
343 if ($this->jobDataDto->getRetries() > 3) {
344 debug_log('[Add File Index] Failed to write files-index to backup file!');
345 throw new Exception('Failed to write files-index to backup file!');
346 } elseif ($writtenBytes === 0) {
347 debug_log('[Add File Index] Failed to write any byte to files-index! Retrying...');
348 }
349
350 if (!$this->compressorDto->isFinished()) {
351 return null;
352 }
353
354 $this->tempBackupIndex->delete();
355 $this->compressorDto->reset();
356
357 $this->tempBackup->append(PHP_EOL);
358
359 return $backupSizeBeforeAddingIndex;
360 }
361
362 /**
363 * @return string
364 */
365 private function getDestinationPath()
366 {
367 $extension = "wpstg";
368
369 if ($this->category !== '') {
370 $index = $this->categoryIndex === 0 ? '' : ($this->categoryIndex . '.');
371 $extension = $this->category . '.' . $index . $extension;
372 }
373
374 return sprintf(
375 '%s_%s_%s.%s',
376 parse_url(get_home_url())['host'],
377 current_time('Ymd-His'),
378 $this->jobDataDto->getId(),
379 $extension
380 );
381 }
382
383 /**
384 * @param string $renameFileTo
385 * @param bool $isLocalBackup
386 * @return string
387 */
388 public function getFinalPath($renameFileTo = '', $isLocalBackup = true)
389 {
390 $backupsDirectory = $this->getFinalBackupParentDirectory($isLocalBackup);
391 if ($renameFileTo === '') {
392 $renameFileTo = $this->getDestinationPath();
393 }
394
395 return $backupsDirectory . $renameFileTo;
396 }
397
398 /**
399 * @return string
400 */
401 public function getFinalBackupParentDirectory($isLocalBackup = true)
402 {
403 if ($isLocalBackup) {
404 return WPStaging::make(BackupsFinder::class)->getBackupsDirectory();
405 }
406
407 return WPStaging::make(Directory::class)->getCacheDirectory();
408 }
409
410 /**
411 * Get delay in milliseconds for retry according to retry number
412 *
413 * @param int $retry
414 * @return float
415 */
416 protected function getDelayForRetry($retry)
417 {
418 $delay = 0.1;
419 for ($i = 0; $i < $retry; $i++) {
420 $delay *= 2;
421 }
422
423 return $delay * 1000;
424 }
425
426 /** @var string $renameFileTo */
427 private function renameBackup($renameFileTo = '')
428 {
429 if ($renameFileTo === '') {
430 $renameFileTo = $this->getDestinationPath();
431 }
432
433 $destination = trailingslashit(dirname($this->tempBackup->getFilePath())) . $renameFileTo;
434 if ($this->isLocalBackup) {
435 $destination = $this->getFinalPath($renameFileTo);
436 }
437
438 if (!rename($this->tempBackup->getFilePath(), $destination)) {
439 throw new RuntimeException('Failed to generate destination');
440 }
441
442 return $destination;
443 }
444
445 /**
446 * @param int $writtenBytesTotal
447 * @return int
448 * @throws \WPStaging\Framework\Exceptions\IOException
449 * @throws LogicException
450 * @throws RuntimeException
451 */
452 private function addIndex($writtenBytesTotal)
453 {
454 clearstatcache();
455 if (file_exists($this->tempBackup->getFilePath())) {
456 $this->compressedFileSize = filesize($this->tempBackup->getFilePath());
457 }
458
459 $start = max($this->compressedFileSize - $writtenBytesTotal, 0);
460
461 if ($this->compressorDto->isIndexPositionCreated($this->category, $this->categoryIndex)) {
462 return $this->updateIndexInformationForAlreadyAddedIndex($writtenBytesTotal);
463 }
464
465 $identifiablePath = $this->pathIdentifier->transformPathToIdentifiable($this->compressorDto->getFilePath());
466 $info = $identifiablePath . '|' . $start . ':' . $writtenBytesTotal;
467 $bytesWritten = $this->tempBackupIndex->append($info);
468 $this->compressorDto->setIndexPositionCreated(true);
469
470 $this->addIndexPartSize($identifiablePath, $writtenBytesTotal);
471
472 /**
473 * We require JobDataDto in the constructor because it is wired in the DI container
474 * to the current job DTO instance. However, here we need to make sure this DTO
475 * is the jobBackupDataDto.
476 */
477 if (!$this->phpAdapter->isCallable([$this->jobDataDto, 'setTotalFiles']) || !$this->phpAdapter->isCallable([$this->jobDataDto, 'getTotalFiles'])) {
478 debug_log('This method can only be called from the context of Backup');
479 throw new LogicException('This method can only be called from the context of Backup');
480 }
481
482 /** @var JobBackupDataDto $jobBackupDataDto */
483 $jobBackupDataDto = $this->jobDataDto;
484 $jobBackupDataDto->setTotalFiles($jobBackupDataDto->getTotalFiles() + 1);
485
486 $this->multipartSplit->incrementFileCountInPart($jobBackupDataDto, $this->category, $this->categoryIndex);
487
488 return $bytesWritten;
489 }
490
491 /**
492 * At the moment this is used when processing adding of big file which is not done in a single request
493 * @param int $writtenBytesTotal
494 * @return int
495 * @throws RuntimeException
496 */
497 private function updateIndexInformationForAlreadyAddedIndex($writtenBytesTotal)
498 {
499 $lastLine = $this->tempBackupIndex->readLines(1, null, BufferedCache::POSITION_BOTTOM);
500 if (!is_array($lastLine)) {
501 debug_log('Failed to read backup metadata file index information. Error: The last line is no array. Last line: ' . $lastLine);
502 throw new RuntimeException('Failed to read backup metadata file index information. Error: The last line is no array.');
503 }
504
505 $lastLine = array_filter($lastLine, function ($item) {
506 return !empty($item) && strpos($item, ':') !== false && strpos($item, '|') !== false;
507 });
508
509 if (count($lastLine) !== 1) {
510 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, 1));
511 throw new RuntimeException('Failed to read backup metadata file index information. Error: The last line is not an array or element with countable interface.');
512 }
513
514 $lastLine = array_shift($lastLine);
515
516 list($relativePath, $indexPosition) = explode('|', trim($lastLine));
517
518 // ['9378469', '4491']
519 list($offsetStart, $writtenPreviously) = explode(':', trim($indexPosition));
520
521 // @todo Should we use mb_strlen($_writtenBytes, '8bit') instead of strlen?
522 $this->tempBackupIndex->deleteBottomBytes(strlen($lastLine));
523
524 $identifiablePath = $this->pathIdentifier->transformPathToIdentifiable($this->compressorDto->getFilePath());
525 $info = $identifiablePath . '|' . $offsetStart . ':' . $writtenBytesTotal;
526 $bytesWritten = $this->tempBackupIndex->append($info);
527 $this->compressorDto->setIndexPositionCreated(true, $this->category, $this->categoryIndex);
528
529 // We only need to increment newly added bytes
530 $this->addIndexPartSize($identifiablePath, $writtenBytesTotal - (int)$writtenPreviously);
531
532 return $bytesWritten;
533 }
534
535 /**
536 * @param $resource
537 * @param $filePath
538 *
539 * @return int
540 * @throws DiskNotWritableException
541 * @throws RuntimeException
542 */
543 private function appendToCompressedFile($resource, $filePath)
544 {
545 try {
546 return $this->tempBackup->appendFile(
547 $resource,
548 $this->compressorDto->getWrittenBytesTotal()
549 );
550 } catch (DiskNotWritableException $e) {
551 debug_log('Failed to write to file: ' . $filePath);
552 // Re-throw for readability
553 throw $e;
554 }
555 }
556
557 /**
558 * @param string $identifiablePath
559 * @param int $newBytesWritten
560 */
561 private function addIndexPartSize($identifiablePath, $newBytesWritten)
562 {
563 // Early bail if jobDataDto is not instance of jobBackupDataDto
564 if (!$this->jobDataDto instanceof JobBackupDataDto) {
565 return;
566 }
567
568 /** @var JobBackupDataDto $jobDataDto */
569 $jobDataDto = $this->jobDataDto;
570
571 $collectPartsize = $jobDataDto->getCategorySizes();
572
573 $partName = 'unknownSize';
574 switch ($identifiablePath) {
575 case ($this->pathIdentifier::IDENTIFIER_WP_CONTENT === substr($identifiablePath, 0, strlen($this->pathIdentifier::IDENTIFIER_WP_CONTENT))):
576 $partName = 'wpcontentSize';
577 break;
578 case ($this->pathIdentifier::IDENTIFIER_PLUGINS === substr($identifiablePath, 0, strlen($this->pathIdentifier::IDENTIFIER_PLUGINS))):
579 $partName = 'pluginsSize';
580 break;
581 case ($this->pathIdentifier::IDENTIFIER_THEMES === substr($identifiablePath, 0, strlen($this->pathIdentifier::IDENTIFIER_THEMES))):
582 $partName = 'themesSize';
583 break;
584 case ($this->pathIdentifier::IDENTIFIER_MUPLUGINS === substr($identifiablePath, 0, strlen($this->pathIdentifier::IDENTIFIER_MUPLUGINS))):
585 $partName = 'mupluginsSize';
586 break;
587 case ($this->pathIdentifier::IDENTIFIER_UPLOADS === substr($identifiablePath, 0, strlen($this->pathIdentifier::IDENTIFIER_UPLOADS))):
588 $partName = 'uploadsSize';
589 if (substr($identifiablePath, -4) === '.sql') {
590 $partName = 'sqlSize';
591 }
592
593 break;
594 case ($this->pathIdentifier::IDENTIFIER_LANG === substr($identifiablePath, 0, strlen($this->pathIdentifier::IDENTIFIER_LANG))):
595 $partName = 'langSize';
596 break;
597 }
598
599 // TODO: This should never happen. Log this when we have our own Logger, see https://github.com/wp-staging/wp-staging-pro/pull/2440#discussion_r1247951548
600 if (!isset($collectPartsize[$partName])) {
601 $collectPartsize[$partName] = 0;
602 }
603
604 $collectPartsize[$partName] += $newBytesWritten;
605 $jobDataDto->setCategorySizes($collectPartsize);
606 }
607 }
608