PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 4.5.0
WP STAGING – WordPress Backup, Restore, Migration & Clone v4.5.0
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 / Extractor.php
wp-staging / Backup / Service Last commit date
Compression 1 year ago Database 5 months ago AbstractBackupsFinder.php 1 year ago AbstractExtractor.php 5 months ago AbstractServiceProvider.php 2 years ago Archiver.php 5 months ago BackupAssets.php 2 years ago BackupContent.php 1 year ago BackupMetadataEditor.php 1 year ago BackupMetadataReader.php 5 months ago BackupSigner.php 6 months ago BackupsFinder.php 5 months ago Extractor.php 5 months ago FileBackupService.php 6 months ago FileBackupServiceProvider.php 2 years ago ServiceInterface.php 2 years ago ZlibCompressor.php 11 months ago
Extractor.php
408 lines
1 <?php
2
3 /**
4 * Extracts files from WP Staging backup archives to restore sites
5 *
6 * Handles the extraction process for both compressed and uncompressed backups,
7 * including validation, disk space checks, and file restoration with proper permissions.
8 */
9
10 namespace WPStaging\Backup\Service;
11
12 use Exception;
13 use OutOfRangeException;
14 use RuntimeException;
15 use WPStaging\Backup\BackupFileIndex;
16 use WPStaging\Backup\BackupHeader;
17 use WPStaging\Backup\BackupValidator;
18 use WPStaging\Backup\Exceptions\EmptyChunkException;
19 use WPStaging\Backup\FileHeader;
20 use WPStaging\Backup\Interfaces\ExtractorTaskInterface;
21 use WPStaging\Core\WPStaging;
22 use WPStaging\Framework\Adapter\Directory;
23 use WPStaging\Framework\Job\Exception\DiskNotWritableException;
24 use WPStaging\Framework\Job\Exception\FileValidationException;
25 use WPStaging\Framework\Facades\Hooks;
26 use WPStaging\Framework\Filesystem\FileObject;
27 use WPStaging\Framework\Filesystem\DiskWriteCheck;
28 use WPStaging\Framework\Filesystem\MissingFileException;
29 use WPStaging\Framework\Filesystem\PathIdentifier;
30 use WPStaging\Framework\Filesystem\Permissions;
31 use WPStaging\Framework\Queue\FinishedQueueException;
32 use WPStaging\Framework\Traits\ResourceTrait;
33 use WPStaging\Framework\Traits\RestoreFileExclusionTrait;
34 use WPStaging\Vendor\Psr\Log\LoggerInterface;
35
36 class Extractor extends AbstractExtractor
37 {
38 use ResourceTrait;
39 use RestoreFileExclusionTrait;
40
41 /** @var LoggerInterface */
42 protected $logger;
43
44 /** @var DiskWriteCheck */
45 protected $diskWriteCheck;
46
47 /** @var BackupValidator */
48 protected $backupValidator;
49
50 /** @var ZlibCompressor */
51 protected $zlibCompressor;
52
53 /** @var ExtractorTaskInterface */
54 protected $extractorTask;
55
56 /** @var bool */
57 protected $isRepairMultipleHeadersIssue = false;
58
59 /** @var bool */
60 protected $isFastPerformanceMode = true;
61
62 /** @var bool */
63 protected $isLastRequestGracefulShutdown = true;
64
65 public function __construct(
66 PathIdentifier $pathIdentifier,
67 Directory $directory,
68 DiskWriteCheck $diskWriteCheck,
69 ZlibCompressor $zlibCompressor,
70 BackupValidator $backupValidator,
71 BackupHeader $backupHeader,
72 Permissions $permissions
73 ) {
74 parent::__construct($pathIdentifier, $directory, $backupHeader, $permissions);
75 $this->zlibCompressor = $zlibCompressor;
76 $this->backupValidator = $backupValidator;
77 $this->diskWriteCheck = $diskWriteCheck;
78 }
79
80 /**
81 * @param bool $isBackupFormatV1
82 * @return void
83 */
84 public function setIsBackupFormatV1(bool $isBackupFormatV1)
85 {
86 $this->isBackupFormatV1 = $isBackupFormatV1;
87 if ($isBackupFormatV1) {
88 $this->indexLineDto = new BackupFileIndex();
89 } else {
90 $this->indexLineDto = WPStaging::make(FileHeader::class);
91 }
92 }
93
94 /**
95 * @param bool $isRepairMultipleHeadersIssue
96 * @return void
97 */
98 public function setIsRepairMultipleHeadersIssue(bool $isRepairMultipleHeadersIssue)
99 {
100 $this->isRepairMultipleHeadersIssue = $isRepairMultipleHeadersIssue;
101 }
102
103 public function setIsFastPerformanceMode(bool $isFastPerformanceMode)
104 {
105 $this->isFastPerformanceMode = $isFastPerformanceMode;
106 }
107
108 public function setIsLastRequestGracefulShutdown(bool $isLastRequestGracefulShutdown)
109 {
110 $this->isLastRequestGracefulShutdown = $isLastRequestGracefulShutdown;
111 }
112
113 /**
114 * @param ExtractorTaskInterface $extractorTask
115 * @param LoggerInterface $logger
116 * @return void
117 */
118 public function inject(ExtractorTaskInterface $extractorTask, LoggerInterface $logger)
119 {
120 $this->extractorTask = $extractorTask;
121 $this->logger = $logger;
122 }
123
124 /**
125 * @param bool $isValidateOnly
126 * @return void
127 */
128 public function setIsValidateOnly(bool $isValidateOnly)
129 {
130 $this->isValidateOnly = $isValidateOnly;
131 if ($isValidateOnly) {
132 $this->throwExceptionOnValidationFailure = true;
133 }
134 }
135
136 /**
137 * @return void
138 * @throws DiskNotWritableException
139 */
140 public function execute()
141 {
142 while (!$this->isThreshold()) {
143 try {
144 $this->findFileToExtract();
145 } catch (OutOfRangeException $e) {
146 // Done processing, or failed
147 $this->logger->warning('OutOfRangeException. Error: ' . $e->getMessage());
148 return;
149 } catch (RuntimeException $e) {
150 $this->logger->warning($e->getMessage());
151 continue;
152 } catch (MissingFileException $e) {
153 $this->logger->warning('MissingFileException. Error: ' . $e->getMessage());
154 continue;
155 } catch (Exception $e) {
156 if ($e->getCode() === self::FILE_FILTERED_EXCEPTION_CODE) {
157 continue;
158 }
159
160 if ($e->getCode() === self::FINISHED_QUEUE_EXCEPTION_CODE) {
161 throw new FinishedQueueException();
162 }
163
164 if ($e->getCode() === self::ITEM_SKIP_EXCEPTION_CODE) {
165 continue;
166 }
167
168 throw $e;
169 }
170
171 try {
172 $this->processCurrentFile();
173 } catch (FileValidationException $e) {
174 if ($this->isValidateOnly || $this->throwExceptionOnValidationFailure) {
175 throw $e;
176 }
177
178 $this->logger->warning('Unable to validate file. Error: ' . $e->getMessage());
179 }
180 }
181 }
182
183 /**
184 * @param Exception $ex
185 * @param string $filePath
186 * @return void
187 */
188 protected function throwMissingFileException(Exception $ex, string $filePath)
189 {
190 throw new MissingFileException(sprintf("Following backup part missing: %s", $filePath), 0, $ex);
191 }
192
193 protected function isBigFile(): bool
194 {
195 $sizeToConsiderAsBigFile = Hooks::applyFilters('wpstg.tests.restore.bigFileSize', 10 * MB_IN_BYTES);
196
197 return $this->extractingFile->getTotalBytes() > $sizeToConsiderAsBigFile;
198 }
199
200 protected function cleanExistingFile(string $identifier)
201 {
202 if ($this->isValidateOnly) {
203 return;
204 }
205
206 if ($identifier !== PathIdentifier::IDENTIFIER_UPLOADS || $this->extractingFile->getWrittenBytes() > 0) {
207 return;
208 }
209
210 if (file_exists($this->extractingFile->getBackupPath())) {
211 // Delete the original upload file
212 if (!unlink($this->extractingFile->getBackupPath())) {
213 throw new \RuntimeException(sprintf(__('Could not delete original media library file %s. Skipping restore of it...', 'wp-staging'), $this->extractingFile->getRelativePath()));
214 }
215 }
216 }
217
218 /**
219 * Fixes issue https://github.com/wp-staging/wp-staging-pro/issues/2861
220 * @return void
221 */
222 protected function maybeRemoveLastAccidentalCharFromLastExtractedFile()
223 {
224 if ($this->backupMetadata->getTotalFiles() !== $this->extractorDto->getTotalFilesExtracted()) {
225 return;
226 }
227
228 if ($this->backupValidator->validateFileIndexFirstLine($this->wpstgFile, $this->backupMetadata)) {
229 return;
230 }
231
232 $this->removeLastCharInExtractedFile();
233 }
234
235 protected function getExtractFolder(string $identifier): string
236 {
237 if ($this->isValidateOnly) {
238 return trailingslashit($this->dirRestore . self::VALIDATE_DIRECTORY);
239 }
240
241 if ($identifier === PathIdentifier::IDENTIFIER_UPLOADS) {
242 return $this->directory->getUploadsDirectory();
243 }
244
245 return $this->dirRestore . $identifier;
246 }
247
248 /**
249 * @return void
250 * @throws DiskNotWritableException
251 */
252 private function processCurrentFile()
253 {
254 $destinationFilePath = $this->extractingFile->getBackupPath();
255 if ($this->currentIdentifier === PathIdentifier::IDENTIFIER_UPLOADS && $this->isExcludedFile($destinationFilePath)) {
256 $this->extractorDto->incrementTotalFilesSkipped();
257 $this->extractorDto->setCurrentIndexOffset($this->wpstgIndexOffsetForNextFile);
258 return;
259 }
260
261 if ($this->extractingFile->getWrittenBytes() > 0) {
262 $this->logger->debug(sprintf('Resuming extraction of file %s from byte %d. Total size: %d...', $this->extractingFile->getRelativePath(), $this->extractingFile->getWrittenBytes(), $this->extractingFile->getTotalBytes()));
263 }
264
265 try {
266 if ($this->isThreshold()) {
267 // Prevent considering a file as big just because we start extracting at the threshold
268 return;
269 }
270
271 $this->fileBatchWrite();
272
273 $isFileExtracted = $this->isExtractingFileExtracted(function ($message) {
274 $this->logger->info($message);
275 });
276
277 if (!$isFileExtracted) {
278 return;
279 }
280 } catch (DiskNotWritableException $e) {
281 // Re-throw
282 throw $e;
283 } catch (OutOfRangeException $e) {
284 // Backup header, should be ignored silently
285 $this->extractingFile->setWrittenBytes($this->extractingFile->getTotalBytes());
286 } catch (Exception $e) {
287 // Set this file as "written", so that we can skip to the next file.
288 $this->extractingFile->setWrittenBytes($this->extractingFile->getTotalBytes());
289
290 if (defined('WPSTG_DEBUG') && WPSTG_DEBUG) {
291 $this->logger->warning(sprintf('Skipped file %s. Reason: %s', $this->extractingFile->getRelativePath(), $e->getMessage()));
292 }
293 }
294
295 $this->validateExtractedFileAndMoveNext();
296 if ($this->isFastPerformanceMode) {
297 return;
298 }
299
300 $this->extractorTask->persistDto($this->extractorDto);
301 }
302
303 /**
304 * @return void
305 * @throws DiskNotWritableException
306 * @throws \WPStaging\Framework\Filesystem\FilesystemExceptions
307 */
308 private function fileBatchWrite()
309 {
310 $destinationFilePath = $this->extractingFile->getBackupPath();
311
312 if (strpos($destinationFilePath, '.sql') !== false) {
313 $this->logger->debug(sprintf('DEBUG: Extracting SQL file %s', $destinationFilePath));
314 }
315
316 wp_mkdir_p(dirname($destinationFilePath));
317
318 /**
319 * On some servers, it is required to create empty file first, so we will create empty files.
320 * On some servers, touch doesn't work consistently, so we will use fwrite, see the reason below.
321 * On sites hosted on SiteGround, creating files using file_puts_contents uses a lot of memory,
322 * so by default we will use fwrite to create the empty file.
323 * If creating the empty file using fwrite fails, let try creating it using file_put_contents
324 * @see https://github.com/wp-staging/wp-staging-pro/issues/3272 why it was needed.
325 */
326 if (!$this->createEmptyFile($destinationFilePath)) {
327 file_put_contents($destinationFilePath, '');
328 }
329
330 $destinationFileResource = @fopen($destinationFilePath, FileObject::MODE_APPEND);
331 if (!$destinationFileResource) {
332 $this->diskWriteCheck->testDiskIsWriteable();
333 throw new Exception("Can not extract file $destinationFilePath");
334 }
335
336 /**
337 * When last request is not graceful shutdown and it is not fast performance mode (i.e. safe performance mode),
338 * we need to set the file pointer to the correct position in the backup file to continue extraction from where it left off.
339 * But this solution only works for non-compressed backups
340 */
341 if (!$this->isLastRequestGracefulShutdown && !$this->isFastPerformanceMode && !$this->extractingFile->getIsCompressed()) {
342 $fileSize = filesize($destinationFilePath);
343 $this->wpstgFile->fseek($this->extractingFile->getStart() + $fileSize);
344 $this->extractingFile->setReadBytes($fileSize);
345 $this->extractingFile->setWrittenBytes($fileSize);
346 $this->logger->debug(sprintf('DEBUG: Seeking to byte %d in backup file to continue extraction of %s...', $this->extractingFile->getStart() + $fileSize, $this->extractingFile->getRelativePath()));
347 }
348
349 $lastDebugMessage = '';
350 while (!$this->extractingFile->isFinished() && !$this->isThreshold()) {
351 $readBytesBefore = $this->wpstgFile->ftell();
352
353 $chunk = null;
354 try {
355 $chunk = $this->zlibCompressor->getService()->readChunk($this->wpstgFile, $this->extractingFile, function ($currentChunkNumber) use (&$lastDebugMessage) {
356 // Log every 200 chunks to provide progress updates without overwhelming the logs.
357 if ($currentChunkNumber % 200 === 0 || $currentChunkNumber === $this->extractorDto->getTotalChunks()) {
358 $lastDebugMessage = sprintf('DEBUG: Extracting chunk %d/%d', $currentChunkNumber, $this->extractorDto->getTotalChunks());
359 }
360 });
361 } catch (DiskNotWritableException $ex) {
362 $this->diskWriteCheck->testDiskIsWriteable();
363 // If empty chunk, it is an empty file, so we can skip it
364 throw new Exception("Unable to extract file to $destinationFilePath. Please check if there is enough disk space available.");
365 } catch (EmptyChunkException $ex) {
366 // If empty chunk, it is an empty file, so we can skip it
367 continue;
368 }
369
370 if ($this->isRepairMultipleHeadersIssue) {
371 $chunk = $this->maybeRepairMultipleHeadersIssue($chunk);
372 }
373
374 $writtenBytes = fwrite($destinationFileResource, $chunk, (int)$this->getScriptMemoryLimit());
375
376 if ($writtenBytes === false || $writtenBytes <= 0) {
377 fclose($destinationFileResource);
378 $destinationFileResource = null;
379 throw DiskNotWritableException::diskNotWritable();
380 }
381
382 $readBytesAfter = $this->wpstgFile->ftell() - $readBytesBefore;
383
384 $this->extractingFile->addReadBytes($readBytesAfter);
385 $this->extractingFile->addWrittenBytes($writtenBytes);
386
387 $this->persistDto();
388 }
389
390 if (!empty($lastDebugMessage)) {
391 $this->logger->debug($lastDebugMessage);
392 }
393
394 fclose($destinationFileResource);
395 $destinationFileResource = null;
396 }
397
398 protected function persistDto()
399 {
400 if ($this->isFastPerformanceMode) {
401 return;
402 }
403
404 $this->updateExtractorDto();
405 $this->extractorTask->persistDto($this->extractorDto);
406 }
407 }
408