PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backups, Restore, Migration & Clone / 4.11.0
WP STAGING – WordPress Backups, Restore, Migration & Clone v4.11.0
4.11.0 4.10.0 4.9.5 4.9.4 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 / Backup / Ajax / BackupDownloader.php
wp-staging / Backup / Ajax Last commit date
Backup 1 day ago FileList 1 day ago Restore 1 day ago Backup.php 1 day ago BackupDownloader.php 1 day ago BackupSizeCalculator.php 1 day ago BackupSpeedIndex.php 1 day ago BaseFileList.php 1 day ago BaseListing.php 1 day ago Delete.php 1 day ago Edit.php 1 day ago Explore.php 1 day ago ExploreCache.php 1 day ago FileInfo.php 1 day ago FileList.php 1 day ago Listing.php 1 day ago Parts.php 1 day ago Restore.php 1 day ago ScheduleList.php 1 day ago Upload.php 1 day ago
BackupDownloader.php
533 lines
1 <?php
2
3 namespace WPStaging\Backup\Ajax;
4
5 use WPStaging\Backup\BackupHeader;
6 use WPStaging\Core\WPStaging;
7 use WPStaging\Backup\Exceptions\BackupRuntimeException;
8 use WPStaging\Backup\Service\BackupsFinder;
9 use WPStaging\Framework\Filesystem\Filesystem;
10 use WPStaging\Framework\Facades\Hooks;
11 use WPStaging\Framework\Network\RemoteDownloader;
12 use WPStaging\Framework\Security\Otp\Otp;
13 use WPStaging\Framework\Security\Otp\OtpDisabledException;
14 use WPStaging\Framework\Security\Otp\OtpException;
15 use WPStaging\Framework\Utils\Sanitize;
16 use WPStaging\Framework\Security\Auth;
17 use WPStaging\Framework\Network\SsrfProtection;
18
19 use function WPStaging\functions\debug_log;
20
21
22
23
24 class BackupDownloader
25 {
26
27
28
29 const FILTER_REMOTE_DOWNLOAD_CHUNK_SIZE = 'wpstg.framework.network.ajax_backup_downloader_chunk_size';
30
31
32
33
34 const FILTER_MINIMUM_BACKUP_SIZE_FOR_DYNAMIC_CHUNK_SIZE = 'wpstg.framework.network.ajax_backup_downloader_minimum_size_for_chunk_size_filter';
35
36
37
38
39 const BACKUP_HEADER_V1_PATTERN = '01101000 01110100 01110100 01110000 01110011 00111010';
40
41
42
43
44 const BACKUP_HEADER_V2_PATTERN = '@^wpstg(\x00)+([0-9a-fA-F]+)@';
45
46
47
48
49 const BACKUP_HEADER_SIZE_FOR_QUICK_VERIFY = 100;
50
51
52
53
54 const OPTION_UPLOAD_PREPARED = 'wpstg.backups.upload_from_url_prepared';
55
56
57
58
59 private $backupsFinder;
60
61
62
63
64 private $filesystem;
65
66
67
68
69 private $otpService;
70
71
72
73
74 private $remoteDownloader;
75
76
77
78
79 private $auth;
80
81
82
83
84 private $sanitize;
85
86
87
88
89 private $ssrfProtection;
90
91
92
93
94 private $remoteHeaderProbeWasEmpty = false;
95
96
97
98
99
100
101
102
103
104
105 public function __construct(BackupsFinder $backupsFinder, Filesystem $filesystem, Otp $otpService, RemoteDownloader $remoteDownloader, Auth $auth, Sanitize $sanitize, SsrfProtection $ssrfProtection)
106 {
107 $this->backupsFinder = $backupsFinder;
108 $this->filesystem = $filesystem;
109 $this->otpService = $otpService;
110 $this->remoteDownloader = $remoteDownloader;
111 $this->auth = $auth;
112 $this->sanitize = $sanitize;
113 $this->ssrfProtection = $ssrfProtection;
114 }
115
116 public function ajaxPrepareUpload()
117 {
118 if (!$this->auth->isAuthenticatedRequest()) {
119 wp_send_json_error([
120 'message' => esc_html__('Invalid Request!', 'wp-staging'),
121 ], 401);
122 }
123
124 try {
125 $this->otpService->validateOtpRequest();
126 } catch (OtpDisabledException $ex) {
127 debug_log($ex->getMessage());
128 } catch (OtpException $ex) {
129 wp_send_json_error([
130 'message' => esc_html($ex->getMessage()),
131 ], $ex->getCode());
132 }
133
134 $backupUrl = empty($_REQUEST['backupUrl']) ? '' : sanitize_url($_REQUEST['backupUrl']);
135 $remoteFileUrl = (string)strtok($backupUrl, '?#');
136 if (!$this->filesystem->isWpstgBackupFile($remoteFileUrl)) {
137 wp_send_json_error([
138 'message' => esc_html__('Not a valid wpstg backup file', 'wp-staging'),
139 ], 403);
140 }
141
142 if ($this->ssrfProtection->isBlockedUrl($remoteFileUrl)) {
143 wp_send_json_error([
144 'message' => esc_html__('The URL resolves to a blocked IP address.', 'wp-staging'),
145 ], 403);
146 }
147
148 if ($this->prepareUploadFromUrl($remoteFileUrl)) {
149 wp_send_json_success(esc_html__('Backup upload is prepared from url', 'wp-staging'));
150 }
151
152 wp_send_json_error([
153 'message' => esc_html__('Unable to prepare backup upload from url', 'wp-staging'),
154 ], 500);
155 }
156
157
158
159
160
161
162
163 public function ajaxDownloadBackupFromRemoteServer()
164 {
165 if (!$this->auth->isAuthenticatedRequest()) {
166 return;
167 }
168
169 $remoteFileUrl = $this->sanitize->sanitizeUrl($_POST['backupUrl'] ?? '');
170 if (empty($remoteFileUrl)) {
171 $this->setFailResponse(__('Backup file URL is empty', 'wp-staging'));
172 $this->remoteDownloader->writeResponse();
173 return;
174 }
175
176 $remoteFileUrl = (string)strtok($remoteFileUrl, '?#');
177 if (!$this->filesystem->isWpstgBackupFile($remoteFileUrl)) {
178 $this->setFailResponse(sprintf(__('Invalid backup file extension: %s', 'wp-staging'), basename($remoteFileUrl)));
179 $this->remoteDownloader->writeResponse();
180 return;
181 }
182
183 if ($this->ssrfProtection->isBlockedUrl($remoteFileUrl)) {
184 $this->setFailResponse(__('The URL resolves to a blocked IP address.', 'wp-staging'));
185 $this->remoteDownloader->writeResponse();
186 return;
187 }
188
189 $startByte = $this->sanitize->sanitizeInt($_POST['startByte'] ?? 0);
190 $fileSize = $this->sanitize->sanitizeInt($_POST['fileSize'] ?? 0);
191 $preparedUploadMetadata = $this->getPreparedUploadMetadata();
192 $fetchMissingFileSize = true;
193
194 if ($fileSize === 0 && $this->isPreparedUploadForUrl($preparedUploadMetadata, $remoteFileUrl)) {
195 $fileSize = $preparedUploadMetadata['fileSize'];
196 $fetchMissingFileSize = false;
197 }
198
199 $this->setDownloadParameters($remoteFileUrl, $startByte, $fileSize, $fetchMissingFileSize);
200
201 try {
202 $this->validateIsUploadPrepared($remoteFileUrl);
203 } catch (\Exception $e) {
204 $this->setFailResponse(__('Invalid Request! Backup upload was not prepared...', 'wp-staging'));
205 $this->remoteDownloader->writeResponse();
206 return;
207 }
208
209 $this->downloadBackup();
210 }
211
212 protected function prepareUploadFromUrl(string $remoteFileUrl): bool
213 {
214 $this->setDownloadParameters($remoteFileUrl, 0, 0);
215
216 $uploadPath = $this->remoteDownloader->getUploadPath();
217 if (file_exists($uploadPath)) {
218 return false;
219 }
220
221 delete_option(static::OPTION_UPLOAD_PREPARED);
222 update_option(static::OPTION_UPLOAD_PREPARED, [
223 'url' => $remoteFileUrl,
224 'fileSize' => $this->remoteDownloader->getRemoteFileSize(),
225 ]);
226
227 $uploadParent = dirname($uploadPath);
228 if (!is_dir($uploadParent)) {
229 $this->filesystem->mkdir($uploadParent);
230 }
231
232 return @touch($uploadPath);
233 }
234
235
236
237
238
239
240
241
242
243 protected function setDownloadParameters(
244 string $remoteFileUrl,
245 int $startByte,
246 int $fileSize,
247 bool $fetchMissingFileSize = true
248 ) {
249 $this->remoteDownloader->setAllowUnknownRemoteFileSize(true);
250 $this->remoteDownloader->setFollowRedirects(false);
251 $this->remoteDownloader->setRemoteUrl($remoteFileUrl);
252 $fileName = basename($remoteFileUrl);
253 $this->remoteDownloader->setFileName($fileName);
254 $this->remoteDownloader->setStartByte($startByte);
255 if ($fileSize === 0 && $fetchMissingFileSize) {
256 $fileSize = $this->remoteDownloader->fetchRemoteFileSizeWithFallbacks();
257 }
258
259 $this->remoteDownloader->setRemoteFileSize($fileSize);
260 $localFilePath = $this->backupsFinder->getBackupsDirectory() . '/' . $this->remoteDownloader->getFileName();
261 $this->remoteDownloader->setLocalPath($localFilePath);
262 $this->setDownloadChunkSize($fileSize);
263 }
264
265
266
267
268
269
270
271
272 private function setDownloadChunkSize(int $fileSize)
273 {
274 if ($fileSize === 0) {
275 $this->setUnknownSizeDownloadChunkSize();
276 return;
277 }
278
279 $fileSizeThreshold = Hooks::applyFilters(self::FILTER_MINIMUM_BACKUP_SIZE_FOR_DYNAMIC_CHUNK_SIZE, 500 * MB_IN_BYTES);
280 if ($fileSize < $fileSizeThreshold) {
281 return;
282 }
283
284 $newChunkSizeInBytes = 25 * MB_IN_BYTES;
285 $newChunkSizeInBytes = $this->applyChunkSizeFilter($newChunkSizeInBytes);
286 if (empty($newChunkSizeInBytes) || $newChunkSizeInBytes < MB_IN_BYTES) {
287 return;
288 }
289
290 $newChunkSizeInBytes = $this->capChunkSizeByAvailableMemory($newChunkSizeInBytes);
291
292 $this->remoteDownloader->setChunkSize($newChunkSizeInBytes);
293 }
294
295
296
297
298 private function setUnknownSizeDownloadChunkSize()
299 {
300 $newChunkSizeInBytes = $this->remoteDownloader->getChunkSize();
301 if (empty($newChunkSizeInBytes) || $newChunkSizeInBytes < MB_IN_BYTES) {
302 return;
303 }
304
305 $newChunkSizeInBytes = $this->applyChunkSizeFilter($newChunkSizeInBytes);
306 if (empty($newChunkSizeInBytes) || $newChunkSizeInBytes < MB_IN_BYTES) {
307 return;
308 }
309
310 $newChunkSizeInBytes = $this->capChunkSizeByAvailableMemory($newChunkSizeInBytes);
311
312 $this->remoteDownloader->setChunkSize($newChunkSizeInBytes);
313 }
314
315
316
317
318
319 private function applyChunkSizeFilter(int $chunkSize): int
320 {
321 return absint(Hooks::applyFilters(self::FILTER_REMOTE_DOWNLOAD_CHUNK_SIZE, $chunkSize));
322 }
323
324
325
326
327
328 private function capChunkSizeByAvailableMemory(int $chunkSize): int
329 {
330 $memoryLimit = wp_convert_hr_to_bytes(ini_get('memory_limit'));
331 if ($memoryLimit <= 0) {
332 return $chunkSize;
333 }
334
335 $availableMemory = absint(($memoryLimit * 20) / 100);
336 if ($availableMemory > 0 && $chunkSize > $availableMemory) {
337 return $availableMemory;
338 }
339
340 return $chunkSize;
341 }
342
343
344
345
346 private function hasValidBackupContentFromRemoteServer(): bool
347 {
348 if (!$this->isQuickValidateRemoteBackupHeader()) {
349 if ($this->remoteHeaderProbeWasEmpty) {
350 if (!$this->remoteDownloader->remoteFileExists()) {
351 return false;
352 }
353
354 $this->setFailResponse(__('Could not reach or read the remote backup file.', 'wp-staging'));
355 return false;
356 }
357
358 $this->setFailResponse(__('Invalid backup file content', 'wp-staging'));
359 return false;
360 }
361
362 return true;
363 }
364
365
366
367
368 private function isDownloadStarted(): bool
369 {
370 $uploadPath = $this->remoteDownloader->getUploadPath();
371 if (empty($uploadPath)) {
372 return false;
373 }
374
375 clearstatcache(true, $uploadPath);
376 if (!is_file($uploadPath)) {
377 return false;
378 }
379
380 $uploadedBytes = filesize($uploadPath);
381
382 return $uploadedBytes !== false && $uploadedBytes > 0;
383 }
384
385
386
387
388
389
390
391
392 private function isQuickValidateRemoteBackupHeader(): bool
393 {
394 $this->remoteHeaderProbeWasEmpty = false;
395
396
397
398
399 if ($this->isDownloadStarted()) {
400 return true;
401 }
402
403 $startByte = 0;
404 $endByte = self::BACKUP_HEADER_SIZE_FOR_QUICK_VERIFY;
405 $remoteFileHeaderContent = $this->remoteDownloader->fetchRemoteFileContent($startByte, $endByte);
406 $remoteFileHeaderContent = trim($remoteFileHeaderContent);
407 if (empty($remoteFileHeaderContent)) {
408 $this->remoteHeaderProbeWasEmpty = true;
409 return false;
410 }
411
412
413 $sqlDumpHeader = substr(trim(BackupHeader::WPSTG_SQL_BACKUP_DUMP_HEADER), 0, self::BACKUP_HEADER_SIZE_FOR_QUICK_VERIFY);
414 if (strpos($remoteFileHeaderContent, $sqlDumpHeader) === 0) {
415 return true;
416 }
417
418
419 if (strpos($remoteFileHeaderContent, self::BACKUP_HEADER_V1_PATTERN) === 0) {
420 return true;
421 }
422
423
424 if (preg_match(self::BACKUP_HEADER_V2_PATTERN, $remoteFileHeaderContent)) {
425 return true;
426 }
427
428
429 return false;
430 }
431
432
433
434
435
436
437 private function downloadBackup()
438 {
439
440 if (!$this->hasValidBackupContentFromRemoteServer()) {
441 $this->remoteDownloader->writeResponse();
442 return;
443 }
444
445 $this->remoteDownloader->downloadChunk();
446 $this->remoteDownloader->closeFileHandle();
447 if ($this->remoteDownloader->getIsSuccess()) {
448 $this->remoteDownloader->advanceStartByte();
449 }
450
451 if ($this->remoteDownloader->getIsCompleted()) {
452 delete_option(static::OPTION_UPLOAD_PREPARED);
453 }
454
455 $this->remoteDownloader->writeResponse();
456 }
457
458
459
460
461
462 private function validateIsUploadPrepared(string $remoteFileUrl)
463 {
464 $uploadPath = $this->remoteDownloader->getUploadPath();
465 if (!file_exists($uploadPath)) {
466 throw new \Exception('Upload file does not exist');
467 }
468
469 $preparedUploadMetadata = $this->getPreparedUploadMetadata();
470
471 if (!empty($preparedUploadMetadata['url']) && $preparedUploadMetadata['url'] !== $remoteFileUrl) {
472 throw new \Exception('Remote file URL does not match the prepared URL');
473 }
474
475
476 if ($preparedUploadMetadata['fileSize'] !== $this->remoteDownloader->getRemoteFileSize()) {
477 throw new \Exception('Remote file size does not match the prepared file size');
478 }
479 }
480
481
482
483
484 private function getPreparedUploadMetadata(): array
485 {
486 $preparedUpload = get_option(static::OPTION_UPLOAD_PREPARED, null);
487 if ($preparedUpload === null) {
488 return [
489 'url' => '',
490 'fileSize' => -1,
491 ];
492 }
493
494 if (!is_array($preparedUpload)) {
495 $fileSize = is_scalar($preparedUpload) ? absint($preparedUpload) : 0;
496
497 return [
498 'url' => '',
499 'fileSize' => $fileSize > 0 ? $fileSize : -1,
500 ];
501 }
502
503 $url = isset($preparedUpload['url']) ? sanitize_url((string)$preparedUpload['url']) : '';
504 $fileSize = isset($preparedUpload['fileSize']) && is_scalar($preparedUpload['fileSize']) ?
505 absint($preparedUpload['fileSize']) :
506 -1;
507
508 return [
509 'url' => $url,
510 'fileSize' => $fileSize === 0 && empty($url) ? -1 : $fileSize,
511 ];
512 }
513
514
515
516
517
518
519 private function isPreparedUploadForUrl(array $preparedUploadMetadata, string $remoteFileUrl): bool
520 {
521 return !empty($preparedUploadMetadata['url']) && $preparedUploadMetadata['url'] === $remoteFileUrl;
522 }
523
524
525
526
527
528 private function setFailResponse(string $message)
529 {
530 $this->remoteDownloader->setResponse($message, false, true);
531 }
532 }
533