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 / Ajax / BackupDownloader.php
wp-staging / Backup / Ajax Last commit date
Backup 2 months ago FileList 4 months ago Restore 2 months ago Backup.php 2 years ago BackupDownloader.php 1 day ago BackupSizeCalculator.php 1 day ago BackupSpeedIndex.php 9 months ago BaseFileList.php 1 year ago BaseListing.php 9 months ago Delete.php 4 months ago Edit.php 4 months ago Explore.php 3 months ago ExploreCache.php 3 months ago FileInfo.php 9 months ago FileList.php 1 year ago Listing.php 7 months ago Parts.php 1 day ago Restore.php 2 years ago ScheduleList.php 1 year ago Upload.php 1 month ago
BackupDownloader.php
338 lines
1 <?php
2 namespace WPStaging\Backup\Ajax;
3 use WPStaging\Backup\BackupHeader;
4 use WPStaging\Core\WPStaging;
5 use WPStaging\Backup\Exceptions\BackupRuntimeException;
6 use WPStaging\Backup\Service\BackupsFinder;
7 use WPStaging\Framework\Filesystem\Filesystem;
8 use WPStaging\Framework\Facades\Hooks;
9 use WPStaging\Framework\Network\RemoteDownloader;
10 use WPStaging\Framework\Security\Otp\Otp;
11 use WPStaging\Framework\Security\Otp\OtpDisabledException;
12 use WPStaging\Framework\Security\Otp\OtpException;
13 use WPStaging\Framework\Utils\Sanitize;
14 use WPStaging\Framework\Security\Auth;
15 use WPStaging\Framework\Network\SsrfProtection;
16 use function WPStaging\functions\debug_log;
17 class BackupDownloader
18 {
19 const FILTER_REMOTE_DOWNLOAD_CHUNK_SIZE = 'wpstg.framework.network.ajax_backup_downloader_chunk_size';
20 const FILTER_MINIMUM_BACKUP_SIZE_FOR_DYNAMIC_CHUNK_SIZE = 'wpstg.framework.network.ajax_backup_downloader_minimum_size_for_chunk_size_filter';
21 const BACKUP_HEADER_V1_PATTERN = '01101000 01110100 01110100 01110000 01110011 00111010';
22 const BACKUP_HEADER_V2_PATTERN = '@^wpstg(\x00)+([0-9a-fA-F]+)@';
23 const BACKUP_HEADER_SIZE_FOR_QUICK_VERIFY = 100;
24 const OPTION_UPLOAD_PREPARED = 'wpstg.backups.upload_from_url_prepared';
25 private $backupsFinder;
26 private $filesystem;
27 private $otpService;
28 private $remoteDownloader;
29 private $auth;
30 private $sanitize;
31 private $ssrfProtection;
32 private $remoteHeaderProbeWasEmpty = false;
33
34 public function __construct(BackupsFinder $backupsFinder, Filesystem $filesystem, Otp $otpService, RemoteDownloader $remoteDownloader, Auth $auth, Sanitize $sanitize, SsrfProtection $ssrfProtection)
35 {
36 $this->backupsFinder = $backupsFinder;
37 $this->filesystem = $filesystem;
38 $this->otpService = $otpService;
39 $this->remoteDownloader = $remoteDownloader;
40 $this->auth = $auth;
41 $this->sanitize = $sanitize;
42 $this->ssrfProtection = $ssrfProtection;
43 }
44
45 public function ajaxPrepareUpload()
46 {
47 if (!$this->auth->isAuthenticatedRequest()) {
48 wp_send_json_error([
49 'message' => esc_html__('Invalid Request!', 'wp-staging'),
50 ], 401);
51 }
52 try {
53 $this->otpService->validateOtpRequest();
54 } catch (OtpDisabledException $ex) {
55 debug_log($ex->getMessage());
56 } catch (OtpException $ex) {
57 wp_send_json_error([
58 'message' => esc_html($ex->getMessage()),
59 ], $ex->getCode());
60 }
61 $backupUrl = empty($_REQUEST['backupUrl']) ? '' : sanitize_url($_REQUEST['backupUrl']);
62 $remoteFileUrl = (string)strtok($backupUrl, '?#');
63 if (!$this->filesystem->isWpstgBackupFile($remoteFileUrl)) {
64 wp_send_json_error([
65 'message' => esc_html__('Not a valid wpstg backup file', 'wp-staging'),
66 ], 403);
67 }
68 if ($this->ssrfProtection->isBlockedUrl($remoteFileUrl)) {
69 wp_send_json_error([
70 'message' => esc_html__('The URL resolves to a blocked IP address.', 'wp-staging'),
71 ], 403);
72 }
73 if ($this->prepareUploadFromUrl($remoteFileUrl)) {
74 wp_send_json_success(esc_html__('Backup upload is prepared from url', 'wp-staging'));
75 }
76 wp_send_json_error([
77 'message' => esc_html__('Unable to prepare backup upload from url', 'wp-staging'),
78 ], 500);
79 }
80
81 public function ajaxDownloadBackupFromRemoteServer()
82 {
83 if (!$this->auth->isAuthenticatedRequest()) {
84 return;
85 }
86 $remoteFileUrl = $this->sanitize->sanitizeUrl($_POST['backupUrl'] ?? '');
87 if (empty($remoteFileUrl)) {
88 $this->setFailResponse(__('Backup file URL is empty', 'wp-staging'));
89 $this->remoteDownloader->writeResponse();
90 return;
91 }
92 $remoteFileUrl = (string)strtok($remoteFileUrl, '?#');
93 if (!$this->filesystem->isWpstgBackupFile($remoteFileUrl)) {
94 $this->setFailResponse(sprintf(__('Invalid backup file extension: %s', 'wp-staging'), basename($remoteFileUrl)));
95 $this->remoteDownloader->writeResponse();
96 return;
97 }
98 if ($this->ssrfProtection->isBlockedUrl($remoteFileUrl)) {
99 $this->setFailResponse(__('The URL resolves to a blocked IP address.', 'wp-staging'));
100 $this->remoteDownloader->writeResponse();
101 return;
102 }
103 $startByte = $this->sanitize->sanitizeInt($_POST['startByte'] ?? 0);
104 $fileSize = $this->sanitize->sanitizeInt($_POST['fileSize'] ?? 0);
105 $preparedUploadMetadata = $this->getPreparedUploadMetadata();
106 $fetchMissingFileSize = true;
107 if ($fileSize === 0 && $this->isPreparedUploadForUrl($preparedUploadMetadata, $remoteFileUrl)) {
108 $fileSize = $preparedUploadMetadata['fileSize'];
109 $fetchMissingFileSize = false;
110 }
111 $this->setDownloadParameters($remoteFileUrl, $startByte, $fileSize, $fetchMissingFileSize);
112 try {
113 $this->validateIsUploadPrepared($remoteFileUrl);
114 } catch (\Exception $e) {
115 $this->setFailResponse(__('Invalid Request! Backup upload was not prepared...', 'wp-staging'));
116 $this->remoteDownloader->writeResponse();
117 return;
118 }
119 $this->downloadBackup();
120 }
121
122 protected function prepareUploadFromUrl(string $remoteFileUrl): bool
123 {
124 $this->setDownloadParameters($remoteFileUrl, 0, 0);
125 $uploadPath = $this->remoteDownloader->getUploadPath();
126 if (file_exists($uploadPath)) {
127 return false;
128 }
129 delete_option(static::OPTION_UPLOAD_PREPARED);
130 update_option(static::OPTION_UPLOAD_PREPARED, [
131 'url' => $remoteFileUrl,
132 'fileSize' => $this->remoteDownloader->getRemoteFileSize(),
133 ]);
134 $uploadParent = dirname($uploadPath);
135 if (!is_dir($uploadParent)) {
136 $this->filesystem->mkdir($uploadParent);
137 }
138 return @touch($uploadPath);
139 }
140
141 protected function setDownloadParameters(
142 string $remoteFileUrl,
143 int $startByte,
144 int $fileSize,
145 bool $fetchMissingFileSize = true
146 ) {
147 $this->remoteDownloader->setAllowUnknownRemoteFileSize(true);
148 $this->remoteDownloader->setFollowRedirects(false);
149 $this->remoteDownloader->setRemoteUrl($remoteFileUrl);
150 $fileName = basename($remoteFileUrl);
151 $this->remoteDownloader->setFileName($fileName);
152 $this->remoteDownloader->setStartByte($startByte);
153 if ($fileSize === 0 && $fetchMissingFileSize) {
154 $fileSize = $this->remoteDownloader->fetchRemoteFileSizeWithFallbacks();
155 }
156 $this->remoteDownloader->setRemoteFileSize($fileSize);
157 $localFilePath = $this->backupsFinder->getBackupsDirectory() . '/' . $this->remoteDownloader->getFileName();
158 $this->remoteDownloader->setLocalPath($localFilePath);
159 $this->setDownloadChunkSize($fileSize);
160 }
161
162 private function setDownloadChunkSize(int $fileSize)
163 {
164 if ($fileSize === 0) {
165 $this->setUnknownSizeDownloadChunkSize();
166 return;
167 }
168 $fileSizeThreshold = Hooks::applyFilters(self::FILTER_MINIMUM_BACKUP_SIZE_FOR_DYNAMIC_CHUNK_SIZE, 500 * MB_IN_BYTES);
169 if ($fileSize < $fileSizeThreshold) {
170 return;
171 }
172 $newChunkSizeInBytes = 25 * MB_IN_BYTES;
173 $newChunkSizeInBytes = $this->applyChunkSizeFilter($newChunkSizeInBytes);
174 if (empty($newChunkSizeInBytes) || $newChunkSizeInBytes < MB_IN_BYTES) {
175 return;
176 }
177 $newChunkSizeInBytes = $this->capChunkSizeByAvailableMemory($newChunkSizeInBytes);
178 $this->remoteDownloader->setChunkSize($newChunkSizeInBytes);
179 }
180
181 private function setUnknownSizeDownloadChunkSize()
182 {
183 $newChunkSizeInBytes = $this->remoteDownloader->getChunkSize();
184 if (empty($newChunkSizeInBytes) || $newChunkSizeInBytes < MB_IN_BYTES) {
185 return;
186 }
187 $newChunkSizeInBytes = $this->applyChunkSizeFilter($newChunkSizeInBytes);
188 if (empty($newChunkSizeInBytes) || $newChunkSizeInBytes < MB_IN_BYTES) {
189 return;
190 }
191 $newChunkSizeInBytes = $this->capChunkSizeByAvailableMemory($newChunkSizeInBytes);
192 $this->remoteDownloader->setChunkSize($newChunkSizeInBytes);
193 }
194
195 private function applyChunkSizeFilter(int $chunkSize): int
196 {
197 return absint(Hooks::applyFilters(self::FILTER_REMOTE_DOWNLOAD_CHUNK_SIZE, $chunkSize));
198 }
199
200 private function capChunkSizeByAvailableMemory(int $chunkSize): int
201 {
202 $memoryLimit = wp_convert_hr_to_bytes(ini_get('memory_limit'));
203 if ($memoryLimit <= 0) {
204 return $chunkSize;
205 }
206 $availableMemory = absint(($memoryLimit * 20) / 100);
207 if ($availableMemory > 0 && $chunkSize > $availableMemory) {
208 return $availableMemory;
209 }
210 return $chunkSize;
211 }
212
213 private function hasValidBackupContentFromRemoteServer(): bool
214 {
215 if (!$this->isQuickValidateRemoteBackupHeader()) {
216 if ($this->remoteHeaderProbeWasEmpty) {
217 if (!$this->remoteDownloader->remoteFileExists()) {
218 return false;
219 }
220 $this->setFailResponse(__('Could not reach or read the remote backup file.', 'wp-staging'));
221 return false;
222 }
223 $this->setFailResponse(__('Invalid backup file content', 'wp-staging'));
224 return false;
225 }
226 return true;
227 }
228
229 private function isDownloadStarted(): bool
230 {
231 $uploadPath = $this->remoteDownloader->getUploadPath();
232 if (empty($uploadPath)) {
233 return false;
234 }
235 clearstatcache(true, $uploadPath);
236 if (!is_file($uploadPath)) {
237 return false;
238 }
239 $uploadedBytes = filesize($uploadPath);
240 return $uploadedBytes !== false && $uploadedBytes > 0;
241 }
242
243 private function isQuickValidateRemoteBackupHeader(): bool
244 {
245 $this->remoteHeaderProbeWasEmpty = false;
246 if ($this->isDownloadStarted()) {
247 return true;
248 }
249 $startByte = 0;
250 $endByte = self::BACKUP_HEADER_SIZE_FOR_QUICK_VERIFY;
251 $remoteFileHeaderContent = $this->remoteDownloader->fetchRemoteFileContent($startByte, $endByte);
252 $remoteFileHeaderContent = trim($remoteFileHeaderContent);
253 if (empty($remoteFileHeaderContent)) {
254 $this->remoteHeaderProbeWasEmpty = true;
255 return false;
256 }
257 $sqlDumpHeader = substr(trim(BackupHeader::WPSTG_SQL_BACKUP_DUMP_HEADER), 0, self::BACKUP_HEADER_SIZE_FOR_QUICK_VERIFY);
258 if (strpos($remoteFileHeaderContent, $sqlDumpHeader) === 0) {
259 return true;
260 }
261 if (strpos($remoteFileHeaderContent, self::BACKUP_HEADER_V1_PATTERN) === 0) {
262 return true;
263 }
264 if (preg_match(self::BACKUP_HEADER_V2_PATTERN, $remoteFileHeaderContent)) {
265 return true;
266 }
267 return false;
268 }
269
270 private function downloadBackup()
271 {
272 if (!$this->hasValidBackupContentFromRemoteServer()) {
273 $this->remoteDownloader->writeResponse();
274 return;
275 }
276 $this->remoteDownloader->downloadChunk();
277 $this->remoteDownloader->closeFileHandle();
278 if ($this->remoteDownloader->getIsSuccess()) {
279 $this->remoteDownloader->advanceStartByte();
280 }
281 if ($this->remoteDownloader->getIsCompleted()) {
282 delete_option(static::OPTION_UPLOAD_PREPARED);
283 }
284 $this->remoteDownloader->writeResponse();
285 }
286
287 private function validateIsUploadPrepared(string $remoteFileUrl)
288 {
289 $uploadPath = $this->remoteDownloader->getUploadPath();
290 if (!file_exists($uploadPath)) {
291 throw new \Exception('Upload file does not exist');
292 }
293 $preparedUploadMetadata = $this->getPreparedUploadMetadata();
294 if (!empty($preparedUploadMetadata['url']) && $preparedUploadMetadata['url'] !== $remoteFileUrl) {
295 throw new \Exception('Remote file URL does not match the prepared URL');
296 }
297 if ($preparedUploadMetadata['fileSize'] !== $this->remoteDownloader->getRemoteFileSize()) {
298 throw new \Exception('Remote file size does not match the prepared file size');
299 }
300 }
301
302 private function getPreparedUploadMetadata(): array
303 {
304 $preparedUpload = get_option(static::OPTION_UPLOAD_PREPARED, null);
305 if ($preparedUpload === null) {
306 return [
307 'url' => '',
308 'fileSize' => -1,
309 ];
310 }
311 if (!is_array($preparedUpload)) {
312 $fileSize = is_scalar($preparedUpload) ? absint($preparedUpload) : 0;
313 return [
314 'url' => '',
315 'fileSize' => $fileSize > 0 ? $fileSize : -1,
316 ];
317 }
318 $url = isset($preparedUpload['url']) ? sanitize_url((string)$preparedUpload['url']) : '';
319 $fileSize = isset($preparedUpload['fileSize']) && is_scalar($preparedUpload['fileSize']) ?
320 absint($preparedUpload['fileSize']) :
321 -1;
322 return [
323 'url' => $url,
324 'fileSize' => $fileSize === 0 && empty($url) ? -1 : $fileSize,
325 ];
326 }
327
328 private function isPreparedUploadForUrl(array $preparedUploadMetadata, string $remoteFileUrl): bool
329 {
330 return !empty($preparedUploadMetadata['url']) && $preparedUploadMetadata['url'] === $remoteFileUrl;
331 }
332
333 private function setFailResponse(string $message)
334 {
335 $this->remoteDownloader->setResponse($message, false, true);
336 }
337 }
338