PluginProbe ʕ •ᴥ•ʔ
Backup Migration / 2.1.2
Backup Migration v2.1.2
2.1.6 2.1.5.2 trunk 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.6.1 1.4.7 1.4.8 1.4.9 1.4.9.1 2.0.0 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.5.1
backup-backup / includes / external / s3.php
backup-backup / includes / external Last commit date
backupbliss.php 3 months ago controller.php 3 months ago dropbox.php 3 months ago ftp.php 3 months ago google-drive.php 3 months ago s3.php 3 months ago
s3.php
1367 lines
1 <?php
2
3 namespace BMI\Plugin\External;
4
5 require_once BMI_INCLUDES . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 's3-client' . DIRECTORY_SEPARATOR . 's3.php';
6
7 use BMI\Plugin\Dashboard;
8 use BMI\Plugin\BMI_Logger as Logger;
9 use BMI\Plugin\Scanner\BMI_BackupsScanner as Backups;
10 use BMI\Plugin\Backup_Migration_Plugin as BMP;
11
12 if ( ! defined( 'ABSPATH' ) ) exit;
13
14
15 // Exception Class for S3 Client Errors
16 class S3ClientException extends \Exception
17 {
18 }
19
20
21 class S3Client
22 {
23 /** @var S3 */
24 private $s3;
25
26 /** @var bool */
27 private $status = false;
28
29 /** @var string */
30 private $rootDir = '';
31
32 /** @var string */
33 private $bucket = '';
34
35 /** @var array */
36 private $config = array();
37
38 /** @var int */
39 private $chunkSize = 5242880; // 5MB default chunk size
40
41 /**
42 * Constructor
43 */
44 public function __construct($endpoint = 's3.amazonaws.com')
45 {
46 if (!function_exists('curl_version')) {
47 $this->s3 = null;
48 return;
49 }
50 $this->s3 = new S3(null, null, false, $endpoint);
51 }
52
53 /**
54 * Sets the root directory for the S3 bucket.
55 *
56 * @param string $rootDir
57 * @return $this
58 */
59 public function setRootDir($rootDir)
60 {
61 $this->rootDir = BMP::fixSlashes($rootDir);
62 return $this;
63 }
64
65 /**
66 * Sets the bucket name for the S3 connection.
67 *
68 * @param string $bucket
69 * @return $this
70 */
71 public function setBucket($bucket)
72 {
73 $this->bucket = trim($bucket);
74 return $this;
75 }
76
77 /**
78 * Sets the configuration for the S3 connection.
79 */
80
81 public function setConfig(array $config)
82 {
83 $this->config = $config;
84 $this->s3->setAuth($config['accessKey'], $config['secretKey']);
85 $this->s3->setSSL(true);
86 $this->s3->setRegion($config['region']);
87 $this->s3->setServerSideEncryption($config['sse']);
88 $this->s3->setStorageClass($config['storageClass']);
89 $this->bucket = $config['bucket'];
90 $this->rootDir = BMP::fixSlashes($config['path']);
91 return $this;
92 }
93
94 /**
95 * Gets the connection status.
96 *
97 * @return bool
98 */
99 public function getStatus()
100 {
101 return $this->status;
102 }
103
104 /**
105 * Sets the connection status.
106 *
107 * @param bool $status
108 * @return $this
109 */
110 public function setStatus($status)
111 {
112 $this->status = (bool) $status;
113 return $this;
114 }
115
116 /**
117 * Connects to the S3 server with improved error handling.
118 *
119 * @param string $accessKey AWS access key
120 * @param string $secretKey AWS secret key
121 * @param string $bucket S3 bucket name
122 * @param string $region AWS region (optional)
123 * @param string $storageClass Storage class (default: 'STANDARD')
124 * @param string $sse Server-side encryption (optional)
125 * @return array Connection status
126 * @throws S3ClientException
127 */
128 public function connect($accessKey, $secretKey, $bucket, $region = '', $storageClass = 'STANDARD', $sse = '')
129 {
130 if ($this->status) {
131 return array('status' => 'connected');
132 }
133
134 try {
135 // Configure S3 client
136 $this->s3->setAuth($accessKey, $secretKey);
137 $this->s3->setSSL(true);
138 $this->s3->setRegion($region);
139 $this->s3->setServerSideEncryption($sse);
140 $this->s3->setStorageClass($storageClass);
141
142 $this->bucket = $bucket;
143
144 // Test connection
145 $testResult = $this->s3->getBucket($bucket, null, null, 1);
146 if ($testResult === false) {
147 throw new S3ClientException($testResult);
148 }
149
150 $this->status = true;
151 return array('status' => 'connected');
152
153 } catch (\Exception $e) {
154 $this->status = false;
155 Logger::error('[S3Client] Connection failed: ' . $e->getMessage());
156
157 $message = $e->getMessage();
158 if (strpos($message, 'the region') !== false && strpos($message, 'is wrong') !== false) {
159 return array('status' => 'error', 'error' => 'Connection failed: The region is wrong.');
160 }
161
162 return array('status' => 'error', 'error' => 'Connection failed: ' . $message);
163 }
164 }
165
166 /**
167 * Test the S3 connection by performing basic operations
168 *
169 * @return true|string True on success, error message on failure
170 */
171 public function testConnection()
172 {
173 try {
174 $this->s3->setExceptions(true);
175 $this->s3->getBucket($this->bucket,null, null, 1);
176
177 $testKey = '.bmi_permission_test_' . uniqid();
178 $testContent = 'test';
179
180 $writeTest = $this->s3->putObject($testContent, $this->bucket, $testKey);
181
182 if ($writeTest === false) {
183 return 'Failed to write test object.';
184 }
185
186 $getTest = $this->s3->getObject($this->bucket, $testKey);
187
188
189 if ($getTest->body !== $testContent) {
190 return 'Test object content mismatch.';
191 }
192
193 $deleteTest = $this->s3->deleteObject($this->bucket, $testKey);
194
195 if ($deleteTest === false) {
196 return 'Failed to delete test object.';
197 }
198
199 $this->s3->setExceptions(false);
200 return true;
201
202 } catch (\Exception $e) {
203 $message = $e->getMessage();
204 if (strpos($message, 'the region') !== false && strpos($message, 'is wrong') !== false) {
205 return 'The region is wrong.';
206 }
207 return $message;
208 }
209 }
210
211 /**
212 * Builds a URI for S3 operations with proper path handling
213 *
214 * @param string $fileName
215 * @param string $bucket
216 * @param string $path
217 * @return string
218 */
219 private function buildUri($fileName, $bucket = '', $path = '')
220 {
221 $path = empty($path) ? $this->rootDir : BMP::fixSlashes($path);
222 $path = trim($path, '/');
223 $fileName = trim($fileName, '/');
224
225 return empty($path) ? $fileName : $path . '/' . $fileName;
226 }
227
228 /**
229 * Starts a multipart upload session.
230 *
231 * @param string $fileName
232 * @param string $bucket
233 * @param string $path
234 * @param array $metaHeaders Optional meta headers
235 * @return string|bool Upload ID or false on failure
236 */
237 public function startUploadSession($fileName, $bucket = '', $path = '', $metaHeaders = [])
238 {
239 if (!$this->status) {
240 return false;
241 }
242 $uri = $this->buildUri($fileName, $bucket, $path);
243 $bucket = $bucket ? $bucket : $this->bucket;
244 return $this->s3->createMultipartUpload($bucket, $uri, 'private', $metaHeaders);
245 }
246
247 /**
248 * Uploads a chunk of data with improved error handling and memory management
249 *
250 * @param string $uploadId
251 * @param string $fileName
252 * @param int $partNumber
253 * @param string $data
254 * @param string $bucket
255 * @param string $path
256 * @return string|bool ETag or false on failure
257 */
258 public function uploadChunk($uploadId, $fileName, $partNumber, $data, $bucket = '', $path = '')
259 {
260 if (!$this->status) {
261 Logger::error('[S3Client] Cannot upload chunk: Not connected');
262 return false;
263 }
264
265 try {
266 $uri = $this->buildUri($fileName, $bucket, $path);
267 $bucket = $bucket ? $bucket : $this->bucket;
268
269 // Validate chunk size
270 $dataSize = strlen($data);
271 if ($dataSize > $this->chunkSize * 2) {
272 throw new S3ClientException('Chunk size exceeds maximum allowed size');
273 }
274
275 $result = $this->s3->uploadPart($bucket, $uri, $uploadId, $partNumber, $data);
276
277 if ($result === false) {
278 throw new S3ClientException('Failed to upload chunk');
279 }
280
281 return $result;
282
283 } catch (\Exception $e) {
284 Logger::error(sprintf(
285 '[S3Client] Failed to upload chunk for file %s (Part %d): %s',
286 $fileName,
287 $partNumber,
288 $e->getMessage()
289 ));
290 return false;
291 }
292 }
293
294 /**
295 * Ends a multipart upload session.
296 *
297 * @param string $uploadId
298 * @param string $fileName
299 * @param array $parts
300 * @param bool $success
301 * @param string $bucket
302 * @param string $path
303 * @return bool
304 */
305 public function endUploadSession($uploadId, $fileName, $parts, $success, $bucket = '', $path = '')
306 {
307 if (!$this->status) {
308 return false;
309 }
310 $uri = $this->buildUri($fileName, $bucket, $path);
311 $bucket = $bucket ? $bucket : $this->bucket;
312 if ($success) {
313 return $this->s3->completeMultipartUpload($bucket, $uri, $uploadId, $parts);
314 }
315 return $this->s3->abortMultipartUpload($bucket, $uri, $uploadId);
316 }
317
318 /**
319 * Deletes a file from S3.
320 *
321 * @param string $fileName
322 * @param string $bucket
323 * @param string $path
324 * @return bool
325 */
326 public function deleteFile($fileName, $bucket = '', $path = '')
327 {
328 if (!$this->status) {
329 return false;
330 }
331 $uri = $this->buildUri($fileName, $bucket, $path);
332 $bucket = $bucket ? $bucket : $this->bucket;
333 return $this->s3->deleteObject($bucket, $uri);
334 }
335
336 /**
337 * Lists files in the S3 bucket.
338 *
339 * @param string $bucket
340 * @param string $path
341 * @return array|bool
342 */
343 public function listFiles($bucket = '', $path = '')
344 {
345 if (!$this->status) {
346 return false;
347 }
348 $bucket = $bucket ? $bucket : $this->bucket;
349 $fullPath = $path ? BMP::fixSlashes($path) : $this->rootDir;
350 return $this->s3->getBucket($bucket, $fullPath);
351 }
352
353 /**
354 * Gets file metadata.
355 *
356 * @param string $fileName
357 * @param string $bucket
358 * @param string $path
359 * @return array|bool
360 */
361 public function getFileMeta($fileName, $bucket = '', $path = '')
362 {
363 if (!$this->status) {
364 return false;
365 }
366 $uri = $this->buildUri($fileName, $bucket, $path);
367 $bucket = $bucket ? $bucket : $this->bucket;
368 return $this->s3->getObjectInfo($bucket, $uri);
369 }
370
371 /**
372 * Uploads an entire file.
373 *
374 * @param string $fileName
375 * @param string $localPath
376 * @param string $bucket
377 * @param string $path
378 * @param array $metaHeaders Optional meta headers
379 * @return bool
380 */
381 public function uploadFile($fileName, $localPath, $bucket = '', $path = '', $metaHeaders = [])
382 {
383 if (!$this->status) {
384 return false;
385 }
386 $uri = $this->buildUri($fileName, $bucket, $path);
387 $bucket = $bucket ? $bucket : $this->bucket;
388 try {
389 $input = file_get_contents($localPath);
390 if ($input === false) {
391 Logger::error('[S3Client] Failed to read file: ' . $localPath);
392 return false;
393 }
394 $result = $this->s3->putObject($input, $bucket, $uri, 'private', $metaHeaders);
395 if ($result === false) {
396 Logger::error('[S3Client] Failed to upload file: ' . $fileName);
397 }
398 return $result;
399 } catch (\Exception $e) {
400 Logger::error('[S3Client] Exception during file upload: ' . $e->getMessage());
401 return false;
402 }
403 }
404
405 /**
406 * Gets file content.
407 *
408 * @param string $fileName
409 * @param int $offset
410 * @param int $length
411 * @param string $bucket
412 * @param string $path
413 * @return string|bool
414 */
415 public function getFileContent($fileName, $offset = 0, $length = -1, $bucket = '', $path = '')
416 {
417 if (!$this->status) {
418 return false;
419 }
420 $uri = $this->buildUri($fileName, $bucket, $path);
421 $bucket = $bucket ? $bucket : $this->bucket;
422 $range = 'bytes=' . $offset . '-' . ($length === -1 ? '' : ($offset + $length - 1));
423 $response = $this->s3->getObject($bucket, $uri, false, $range);
424 return $response === false ? false : $response->body;
425 }
426
427 /**
428 * Creates a directory if it doesn't exist.
429 *
430 * @param string $fullPath
431 * @param string $bucket
432 * @return bool
433 */
434 public function createDirectoryIfNotExists($fullPath, $bucket = '')
435 {
436 if (!$this->status) {
437 return false;
438 }
439 $bucket = $bucket ? $bucket : $this->bucket;
440 $fullPath = BMP::fixSlashes($fullPath);
441 if ($this->s3->getObjectInfo($bucket, $fullPath) !== false) {
442 return true;
443 }
444 $parts = explode(DIRECTORY_SEPARATOR, trim($fullPath, DIRECTORY_SEPARATOR));
445 $currentPath = '';
446 foreach ($parts as $part) {
447 $currentPath .= DIRECTORY_SEPARATOR . $part;
448 if (!$this->s3->getObjectInfo($bucket, $currentPath)) {
449 if (!$this->s3->createFolder($bucket, $currentPath)) {
450 return false;
451 }
452 }
453 }
454 return $this->s3->getObjectInfo($bucket, $fullPath) !== false;
455 }
456 }
457
458 class BMI_External_S3 {
459
460 private $s3Client;
461 private $s3Provider = 'aws';
462 const SINGLE_UPLOAD_THRESHOLD = 10485760;
463 const CHUNK_SIZE = 10485760;
464 private $checkConnection = false;
465
466 const S3_PROVIDERS_REGIONS = [
467 'aws' => [
468 'us-east-1' => 'N. Virginia (us-east-1)',
469 'us-east-2' => 'Ohio (us-east-2)',
470 'us-west-1' => 'N. California (us-west-1)',
471 'us-west-2' => 'Oregon (us-west-2)',
472 'af-south-1' => 'Cape Town (af-south-1)',
473 'ap-east-1' => 'Hong Kong (ap-east-1)',
474 'ap-south-1' => 'Mumbai (ap-south-1)',
475 'ap-northeast-3' => 'Osaka (ap-northeast-3)',
476 'ap-northeast-2' => 'Seoul (ap-northeast-2)',
477 'ap-southeast-1' => 'Singapore (ap-southeast-1)',
478 'ap-southeast-2' => 'Sydney (ap-southeast-2)',
479 'ap-northeast-1' => 'Tokyo (ap-northeast-1)',
480 'ca-central-1' => 'Central (ca-central-1)',
481 'eu-central-1' => 'Frankfurt (eu-central-1)',
482 'eu-west-1' => 'Ireland (eu-west-1)',
483 'eu-west-2' => 'London (eu-west-2)',
484 'eu-south-1' => 'Milan (eu-south-1)',
485 'eu-west-3' => 'Paris (eu-west-3)',
486 'eu-north-1' => 'Stockholm (eu-north-1)',
487 'me-south-1' => 'Bahrain (me-south-1)',
488 'sa-east-1' => 'São Paulo (sa-east-1)',
489 ],
490 'wasabi' => [
491 'us-west-1' => 'Oregon (us-west-1)',
492 'us-east-1' => 'Virginia (us-east-1)',
493 'us-east-2' => 'Virginia (us-east-2)',
494 'us-central-1' => 'Texas (us-central-1)',
495 'ca-central-1' => 'Canada (ca-central-1)',
496 'eu-west-1' => 'England (eu-west-1)',
497 'eu-west-3' => 'England (eu-west-3)',
498 'eu-west-2' => 'France (eu-west-2)',
499 'eu-central-1' => 'Netherlands (eu-central-1)',
500 'eu-central-2' => 'Germany (eu-central-2)',
501 'eu-south-1' => 'Italy (eu-south-1)',
502 'ap-northeast-1' => 'Japan (ap-northeast-1)',
503 'ap-northeast-2' => 'Japan (ap-northeast-2)',
504 'ap-southeast-2' => 'Australia (ap-southeast-2)',
505 'ap-southeast-1' => 'Singapore (ap-southeast-1)',
506 ],
507 'digitalocean' => [
508 'nyc3' => 'New York 3',
509 'ams3' => 'Amsterdam 3',
510 'sgp1' => 'Singapore 1',
511 'sfo2' => 'San Francisco 2',
512 ],
513 ];
514 const S3_PROVIDERS_ENDPOINTS = [
515 'aws' => 's3.amazonaws.com',
516 'wasabi' => 's3.wasabisys.com',
517 'digitalocean' => 'nyc3.digitaloceanspaces.com',
518 ];
519
520 /**
521 * Constructor
522 *
523 * @param string $provider S3-compatible service identifier (e.g. 'aws', 'wasabi', etc.)
524 */
525 public function __construct($provider)
526 {
527 if ($provider) {
528 $this->s3Provider = $provider;
529 }
530 $endpoint = self::S3_PROVIDERS_ENDPOINTS[$this->s3Provider];
531 if (!self::hasRequiredExtensions()) {
532 return;
533 }
534 $this->s3Client = new S3Client($endpoint);
535 $this->registerHooks();
536 set_error_handler([$this, 'errorHandler'], E_USER_WARNING);
537 }
538
539 /**
540 * Checks if all required PHP extensions are available
541 *
542 * @return bool True if all required extensions are available
543 */
544 public static function hasRequiredExtensions()
545 {
546 return self::getExtensionRequirements()['status'] === true;
547 }
548
549 /**
550 * Validates and returns the status of required PHP extensions
551 *
552 * @return array Status and details of missing extensions
553 */
554 public static function getExtensionRequirements()
555 {
556 $response = [
557 'status' => true,
558 'errors' => [
559 'missing_extensions' => [],
560 ]
561 ];
562
563 $requiredExtensions = [
564 // label of extension => method used to check its existence
565 'cURL' => 'curl_version',
566 'Hash' => 'hash_hmac',
567 'SimpleXML/LibXML' => 'simplexml_load_string',
568 ];
569
570 foreach ($requiredExtensions as $label => $function) {
571 if (!function_exists($function)) {
572 $response['errors']['missing_extensions'][] = $label;
573 $response['status'] = false;
574 }
575 }
576
577 if (!class_exists('DOMDocument') && !class_exists('\DOMDocument')) {
578 $response['errors']['missing_extensions'][] = 'DOM';
579 $response['status'] = false;
580 }
581
582 return $response;
583 }
584
585
586 public function errorHandler($errno, $context, $errfile, $errline)
587 {
588 $context = json_decode($context, true);
589 if (isset($context['code'])){
590 switch ($context['code']) {
591 case 429:
592 $this->setIssue('rate_limit');
593 break;
594 case 403:
595 $this->setIssue('forbidden');
596 break;
597 case 401:
598 $this->setIssue('disconnected');
599 break;
600 }
601 }
602 return true;
603 }
604
605 /**
606 * Initializes the S3 connection
607 */
608 private function initializeConnection()
609 {
610 $connectionStatus = get_transient('bmip_' . $this->s3Provider . '_connection_status');
611 $configs = $this->retrieveS3Configs();
612 if ($connectionStatus == true && get_transient('bmip_' . $this->s3Provider . '_issue') == false ) {
613 $this->deleteIssue();
614 } else {
615 if ($configs['accessKey'] == '' || $configs['secretKey'] == '' || $configs['bucket'] == '' || $configs['region'] == '') {
616 return;
617 }
618 $connectionStatus = $this->s3Client->connect(
619 $configs['accessKey'],
620 $configs['secretKey'],
621 $configs['bucket'],
622 $configs['region'],
623 $configs['storageClass'],
624 $configs['sse']
625 )['status'] == 'connected';
626 if ($connectionStatus) {
627 $this->deleteIssue();
628 } else {
629 if (get_option('bmip_' . $this->s3Provider . '_was_connected', false)) {
630 $this->setIssue('disconnected');
631 }
632 }
633 }
634
635 if ($connectionStatus) {
636 $this->s3Client->setConfig($configs);
637 }
638 $this->s3Client->setStatus($connectionStatus);
639
640 }
641 /**
642 * Initializes hooks for the S3 provider
643 */
644 private function registerHooks() {
645
646 add_action('bmi_premium_remove_backup_file', [&$this, 'deleteBackup']);
647 add_action('bmi_premium_remove_backup_json_file', [&$this, 'deleteBackupJson']);
648 add_action('update_option_bmip_' . $this->s3Provider . '_access_key', [&$this, 'restartUploadProcess']);
649 add_action('update_option_bmip_' . $this->s3Provider . '_secret_key', [&$this, 'restartUploadProcess']);
650 add_action('update_option_bmip_' . $this->s3Provider . '_bucket', [&$this, 'restartUploadProcess']);
651 add_action('update_option_bmip_' . $this->s3Provider . '_storage_class', [&$this, 'restartUploadProcess']);
652 add_action('update_option_bmip_' . $this->s3Provider . '_path', [&$this, 'restartUploadProcess']);
653 add_action('update_option_bmip_' . $this->s3Provider . '_region', [&$this, 'restartUploadProcess']);
654 add_action('update_option_bmip_' . $this->s3Provider . '_sse', [&$this, 'restartUploadProcess']);
655 add_action('set_transient_bmip_' . $this->s3Provider . '_connection_status', [&$this, 'restartUploadProcess']);
656 add_action('delete_transient_bmip_' . $this->s3Provider . '_issue', [&$this, 'resetIssueDisplayOption']);
657 }
658
659 /**
660 * Dismisses the issue display option
661 */
662 public function resetIssueDisplayOption()
663 {
664 delete_option('bmip_' . $this->s3Provider . '_dismiss_issue');
665 }
666
667 /**
668 * Checks for backups to upload to S3 bucket
669 *
670 * @return array Status of the upload process
671 */
672 public function checkForBackupsToUpload()
673 {
674 $isEnabled = Dashboard\bmi_get_config('STORAGE::EXTERNAL::' . strtoupper($this->s3Provider));
675 if (!($isEnabled === true || $isEnabled === 'true')) {
676 update_option('bmip_to_be_uploaded', [ 'current_upload' => [], 'queue' => [] ]);
677 return ['status' => 'not_enabled'];
678 }
679 if ($this->getConnectionStatus() === false) {
680 return ['status' => 'error'];
681 }
682
683 $requiresUpload = get_option('bmip_to_be_uploaded', [
684 'current_upload' => [],
685 'queue' => [],
686 'failed' => []
687 ]);
688
689 require_once BMI_INCLUDES . DIRECTORY_SEPARATOR . 'scanner' . DIRECTORY_SEPARATOR . 'backups.php';
690 $backups = new Backups();
691 $backupsAvailable = $backups->getAvailableBackups("local");
692 $localBackups = $backupsAvailable['local'];
693 $parsedS3Files = $this->getParsedFiles();
694 if ($parsedS3Files === false) {
695 return ['status' => 'error'];
696 }
697 $backupsFileName = isset($parsedS3Files['zipFilesName']) ? $parsedS3Files['zipFilesName'] : [];
698 $manifestFilesPath = isset($parsedS3Files['jsonFilesPath']) ? $parsedS3Files['jsonFilesPath'] : [];
699 $availableManifests = array_map(function ($path) {
700 return pathinfo($path, PATHINFO_FILENAME);
701 }, $manifestFilesPath);
702 $uploadedBackupStatus = get_option('bmi_uploaded_backups_status', []);
703
704
705 foreach ($localBackups as $name => $details) {
706 $md5 = $details[7];
707 if (isset($uploadedBackupStatus[$md5]) && isset($uploadedBackupStatus[$md5][$this->s3Provider])) {
708 continue;
709 }
710 $isBackupNotExists = !in_array($md5, $availableManifests) || !in_array($name, array_keys($backupsFileName));
711 if ($isBackupNotExists && !(isset($requiresUpload['current_upload']['task']) && $requiresUpload['current_upload']['task'] == $this->s3Provider . '_' . $md5)) {
712 $requiresUpload['queue'][$this->s3Provider . '_' . $md5] = [
713 'name' => $name,
714 'md5' => $md5,
715 'json' => $md5 . '.json',
716 ];
717 }
718 }
719
720 update_option('bmip_to_be_uploaded', $requiresUpload);
721 return ['status' => 'success'];
722 }
723
724 /**
725 * Restarts the upload process of backups
726 *
727 * @return array Status of the upload process
728 */
729 public function restartUploadProcess()
730 {
731 $requiredToUpload = get_option('bmip_to_be_uploaded', [
732 'current_upload' => [],
733 'queue' => [],
734 'failed' => []
735 ]);
736
737 if (isset($requiredToUpload['current_upload']['task']) && strpos($requiredToUpload['current_upload']['task'], $this->s3Provider) !== false) {
738 unset($requiredToUpload['current_upload']);
739 }
740
741 if (!isset($requiredToUpload['failed'])) {
742 $requiredToUpload['failed'] = [];
743 }
744
745 foreach ($requiredToUpload['failed'] as $key => $value) {
746 if (strpos($key, $this->s3Provider . '_') !== false) {
747 unset($requiredToUpload['failed'][$key]);
748 }
749 }
750
751 update_option('bmip_to_be_uploaded', $requiredToUpload);
752 return $this->checkForBackupsToUpload();
753 }
754
755 /**
756 * Retrieves and parses files from the S3 bucket
757 *
758 * @return array|bool Parsed files or false on error
759 */
760 public function getParsedFiles()
761 {
762 if ($this->getConnectionStatus() === false) {
763 return false;
764 }
765 $zipFilesName = [];
766 $jsonFilesPath = [];
767 $files = $this->s3Client->listFiles();
768
769 if ($files === false) {
770 return false;
771 }
772
773 $path = get_option('bmip_' . $this->s3Provider . '_path', '');
774 foreach ($files as $filename => $metadata) {
775 $fileData = pathinfo($filename);
776 if ($fileData['dirname'] == '.' && $path != '') {
777 continue;
778 }
779 if ($fileData['dirname'] != '.' && $fileData['dirname'] != $path) {
780 continue;
781 }
782 $filename = $fileData['basename'];
783 $metadata['name'] = $filename;
784 $extension = strtolower($fileData['extension']);
785 if (in_array($extension, array('zip', 'tar', 'gz'))) {
786 $zipFilesName[$filename] = ['id' => $filename, 'size' => $metadata['size']];
787 } elseif ($extension === 'json' && strlen($fileData['filename']) === 32) {
788 $jsonFilesPath[] = $filename;
789 }
790 }
791
792 return compact('zipFilesName', 'jsonFilesPath');
793 }
794
795 /**
796 * Verifies the S3 connection status
797 *
798 * @return array Connection status
799 */
800 public function verifyConnection()
801 {
802 $status = $this->getConnectionStatus();
803 if ($status == true) {
804 return ['result' => 'connected'];
805 } else {
806 return ['result' => 'disconnected'];
807 }
808 }
809 /**
810 * Tests the S3 bucket connection
811 *
812 * @param string $host
813 * @param int $port
814 * @param string $authType
815 * @param string $username
816 * @param string $password
817 * @param string|null $fingerPrint
818 * @param string|null $passphrase
819 * @return array Connection test result
820 */
821 public function testConnection($accessKey, $secretKey, $bucket, $region, $path, $storageClass = 'STANDARD', $sse = '')
822 {
823 try {
824 // Initialize connection test status
825 $testStatus = [
826 'credentials' => false,
827 'bucket_exists' => false,
828 'bucket_access' => false,
829 'permissions' => [
830 'list' => false,
831 'write' => false,
832 'delete' => false
833 ]
834 ];
835
836 $this->s3Client->setConfig([
837 'accessKey' => $accessKey,
838 'secretKey' => $secretKey,
839 'bucket' => $bucket,
840 'region' => $region,
841 'path' => $path,
842 'storageClass' => $storageClass,
843 'sse' => $sse
844 ]);
845
846 $connectResult = $this->s3Client->testConnection();
847
848 if ($connectResult !== true) {
849 // Add detailed error information
850 return [
851 'status' => 'error',
852 'error' => $connectResult,
853 'test_status' => $testStatus
854 ];
855 }
856
857 $testStatus['credentials'] = true;
858 $testStatus['bucket_exists'] = true;
859 $testStatus['bucket_access'] = true;
860 $testStatus['permissions']['list'] = true;
861 $testStatus['permissions']['write'] = true;
862 $testStatus['permissions']['delete'] = true;
863
864 return [
865 'status' => 'success',
866 'test_status' => $testStatus,
867 'message' => 'Connection test successful. All required permissions verified.'
868 ];
869
870 } catch (\Exception $e) {
871 if (BMI_PRO_DEBUG) {
872 Logger::error('[BMI_External_S3] Test connection failed: ' . $e->getMessage());
873 }
874
875 return [
876 'status' => 'error',
877 'error' => 'Connection test failed: ' . $e->getMessage(),
878 'test_status' => $testStatus ?? null
879 ];
880 }
881 }
882
883 /**
884 * Disconnects from the S3 bucket
885 *
886 * @return array Status of the disconnection
887 */
888 public function disconnect()
889 {
890 delete_option('bmip_' . $this->s3Provider . '_access_key');
891 delete_option('bmip_' . $this->s3Provider . '_secret_key');
892 delete_option('bmip_' . $this->s3Provider . '_bucket');
893 delete_option('bmip_' . $this->s3Provider . '_storage_class');
894 delete_option('bmip_' . $this->s3Provider . '_path');
895 delete_option('bmip_' . $this->s3Provider . '_region');
896 delete_option('bmip_' . $this->s3Provider . '_sse');
897 delete_option('bmip_' . $this->s3Provider . '_was_connected');
898 delete_transient('bmip_' . $this->s3Provider . '_connection_status');
899 $this->restartUploadProcess();
900 Dashboard\bmi_set_config('STORAGE::EXTERNAL::' . strtoupper($this->s3Provider), false);
901 return ['status' => 'success'];
902 }
903
904 /**
905 * Uploads a backup to the S3 bucket.
906 *
907 * @param string $uploadId
908 * @param string $backupName
909 * @param int $offset
910 * @param string $md5
911 * @return array
912 */
913 public function uploadBackup($uploadId, $backupName, $offset, $md5)
914 {
915 if ($this->getConnectionStatus() === false) {
916 return ['status' => 'error', 'error' => 'disconnected'];
917 }
918 $backupPath = BMI_BACKUPS . DIRECTORY_SEPARATOR . $backupName;
919 $manifestPath = BMI_BACKUPS . DIRECTORY_SEPARATOR . $md5 . '.json';
920
921 if (!file_exists($backupPath)) {
922 $this->restartUploadProcess();
923 return ['status' => 'error', 'error' => 'internal_file_not_found'];
924 }
925 $fileSize = filesize($backupPath);
926
927 try {
928 if ($fileSize <= self::SINGLE_UPLOAD_THRESHOLD) {
929 return $this->uploadSmallFile($backupName, $backupPath, $manifestPath, $md5);
930 }
931 return $this->uploadLargeFile($uploadId, $backupName, $backupPath, $offset, $fileSize, $manifestPath, $md5);
932 } catch (\Exception $e) {
933 Logger::error('[BMI PRO] Upload failed for ' . $backupName . ': ' . $e->getMessage());
934 if ($uploadId) {
935 $this->s3Client->endUploadSession($uploadId, $backupName, [], false);
936 $this->removeParts($uploadId);
937 }
938 return [
939 'status' => 'error',
940 'error' => 'upload_failed',
941 'message' => $e->getMessage()
942 ];
943 }
944 }
945
946 /**
947 * Uploads a small file in a single request.
948 *
949 * @param string $backupName
950 * @param string $backupPath
951 * @param string $manifestPath
952 * @param string $md5
953 * @return array
954 */
955 private function uploadSmallFile($backupName, $backupPath, $manifestPath, $md5)
956 {
957 $fileMd5 = md5_file($backupPath);
958 $metaHeaders = ['md5' => $fileMd5];
959 $uploadResult = $this->s3Client->uploadFile($backupName, $backupPath, '', '', $metaHeaders);
960 if (!$uploadResult) {
961 Logger::error('[BMI PRO] Failed to upload small backup file: ' . $backupName);
962 return ['status' => 'error', 'error' => 'upload_backup'];
963 }
964
965 $meta = $this->s3Client->getFileMeta($backupName);
966 if ($meta === false || !isset($meta['x-amz-meta-md5']) || $meta['x-amz-meta-md5'] !== $fileMd5) {
967 Logger::error('[BMI PRO] MD5 mismatch for small file: ' . $backupName . '. Local: ' . $fileMd5 . ', Remote: ' . ($meta['x-amz-meta-md5'] ?? 'N/A') . '. Deleting remote file.');
968 $this->s3Client->deleteFile($backupName); // Attempt to delete corrupted file
969 return ['status' => 'error', 'error' => 'md5_mismatch'];
970 }
971 $manifestResult = $this->s3Client->uploadFile($md5 . '.json', $manifestPath);
972 if (!$manifestResult) {
973 Logger::error('[BMI PRO] Failed to upload manifest for ' . $backupName);
974 $this->s3Client->deleteFile($backupName);
975 return ['status' => 'error', 'error' => 'upload_manifest'];
976 }
977 return ['status' => 'success'];
978 }
979
980 /**
981 * Uploads a large file using multipart upload.
982 *
983 * @param string $uploadId
984 * @param string $backupName
985 * @param string $backupPath
986 * @param int $offset
987 * @param int $fileSize
988 * @param string $manifestPath
989 * @param string $md5
990 * @return array
991 */
992 private function uploadLargeFile($uploadId, $backupName, $backupPath, $offset, $fileSize, $manifestPath, $md5)
993 {
994 $fileMd5 = md5_file($backupPath);
995 if (!$uploadId) {
996 $metaHeaders = ['md5' => $fileMd5];
997 $uploadId = $this->s3Client->startUploadSession($backupName, '', '', $metaHeaders);
998 if ($uploadId === false) {
999 throw new \Exception('Failed to start upload session.');
1000 }
1001 return ['status' => 'continue', 'offset' => 0, 'uploadId' => $uploadId];
1002 }
1003 if ($offset < $fileSize) {
1004 $newOffset = $this->uploadChunk($uploadId, $backupName, $offset);
1005 if ($newOffset === false) {
1006 throw new \Exception('Failed to upload chunk.');
1007 }
1008 return ['status' => 'continue', 'offset' => $newOffset, 'uploadId' => $uploadId];
1009 }
1010 $parts = $this->getParts($uploadId);
1011 $endResult = $this->s3Client->endUploadSession($uploadId, $backupName, $parts, true);
1012 if ($endResult === false) {
1013 throw new \Exception('Failed to complete upload session.');
1014 }
1015 $this->removeParts($uploadId);
1016
1017 $meta = $this->s3Client->getFileMeta($backupName);
1018 if ($meta === false || !isset($meta['x-amz-meta-md5']) || $meta['x-amz-meta-md5'] !== $fileMd5) {
1019 Logger::error('[BMI PRO] MD5 mismatch for large file: ' . $backupName . '. Local: ' . $fileMd5 . ', Remote: ' . ($meta['x-amz-meta-md5'] ?? 'N/A'));
1020 $this->s3Client->deleteFile($backupName);
1021 return ['status' => 'error', 'error' => 'md5_mismatch'];
1022 }
1023
1024 $manifestResult = $this->s3Client->uploadFile($md5 . '.json', $manifestPath);
1025 if (!$manifestResult) {
1026 Logger::error('[BMI PRO] Failed to upload manifest for ' . $backupName);
1027 $this->s3Client->deleteFile($backupName);
1028 return ['status' => 'error', 'error' => 'upload_manifest'];
1029 }
1030 return ['status' => 'success'];
1031 }
1032
1033 /**
1034 * Uploads a chunk of a backup.
1035 *
1036 * @param string $uploadId
1037 * @param string $backupName
1038 * @param int $offset
1039 * @return int|bool
1040 */
1041 public function uploadChunk($uploadId, $backupName, $offset)
1042 {
1043 if ($this->getConnectionStatus() === false) {
1044 return false;
1045 }
1046 $backupPath = BMI_BACKUPS . DIRECTORY_SEPARATOR . $backupName;
1047 $backupFile = fopen($backupPath, 'r');
1048 if ($backupFile === false) {
1049 Logger::error('[BMI PRO] Unable to open backup file: ' . $backupName);
1050 return false;
1051 }
1052 if (fseek($backupFile, $offset) !== 0) {
1053 fclose($backupFile);
1054 Logger::error('[BMI PRO] Failed to seek in file: ' . $backupName);
1055 return false;
1056 }
1057 $data = fread($backupFile, self::CHUNK_SIZE);
1058 fclose($backupFile);
1059 if ($data === false) {
1060 Logger::error('[BMI PRO] Failed to read from file: ' . $backupName);
1061 return false;
1062 }
1063 $parts = $this->getParts($uploadId);
1064 $partNumber = empty($parts) ? 1 : max(array_keys($parts)) + 1;
1065 $eTag = $this->s3Client->uploadChunk($uploadId, $backupName, $partNumber, $data);
1066 if ($eTag === false) {
1067 Logger::error('[BMI PRO] Failed to upload chunk for file: ' . $backupName);
1068 return false;
1069 }
1070 $this->addPart($uploadId, $partNumber, $eTag);
1071 return $offset + strlen($data);
1072 }
1073
1074 /**
1075 * Checks if a file exists on the S3 bucket
1076 *
1077 * @param string $fileName Path to the file on the S3 bucket
1078 * @return bool
1079 */
1080 public function isFileExists($fileName)
1081 {
1082 if ($this->getConnectionStatus() === false) {
1083 return false;
1084 }
1085 $file = $this->s3Client->getFileMeta($fileName);
1086 return $file !== false;
1087 }
1088
1089 /**
1090 * Get file metadata from the S3 bucket
1091 *
1092 * @param string $fileName Path to the file on the S3 bucket
1093 * @return array|bool File metadata or false on error
1094 */
1095 public function getFileMeta($fileName)
1096 {
1097 if ($this->getConnectionStatus() === false) {
1098 return false;
1099 }
1100 return $this->s3Client->getFileMeta($fileName);
1101 }
1102
1103 /**
1104 * Deletes a backup from the S3 bucket
1105 *
1106 * @param string $md5 MD5 of the backup to delete
1107 * @return bool
1108 */
1109 public function deleteBackup($md5)
1110 {
1111 if ($this->getConnectionStatus() === false) {
1112 return false;
1113 }
1114
1115 $manifestFile = $md5 . '.json';
1116 if (file_exists(BMI_BACKUPS . DIRECTORY_SEPARATOR . $manifestFile)) {
1117 $manifestContent = json_decode(file_get_contents(BMI_BACKUPS . DIRECTORY_SEPARATOR . $manifestFile), true);
1118 } else {
1119 $manifestContent = json_decode($this->s3Client->getFileContent($manifestFile), true);
1120 }
1121 $backupName = isset($manifestContent['name']) ? $manifestContent['name'] : '';
1122 if (empty($backupName)) {
1123 Logger::error('[BMI PRO] Manifest does not contain backup name.');
1124 return false;
1125 }
1126 $deleteManifest = $this->s3Client->deleteFile($manifestFile);
1127 $deleteBackup = $this->s3Client->deleteFile($backupName);
1128 return $deleteManifest && $deleteBackup;
1129 }
1130
1131 /**
1132 * Deletes a backup manifest from the S3 bucket
1133 *
1134 * @param string $md5 MD5 of the backup to delete
1135 * @return bool
1136 */
1137 public function deleteBackupJson($md5)
1138 {
1139 if ($this->getConnectionStatus() === false) {
1140 return false;
1141 }
1142
1143 $manifestFile = $md5 . '.json';
1144 return $this->s3Client->deleteFile($manifestFile);
1145 }
1146
1147 /**
1148 * Retrieves S3 configs from temporary file or options
1149 *
1150 * @return array Configs array
1151 */
1152 public function retrieveS3Configs()
1153 {
1154 $tempKeyS3File = BMI_TMP . DIRECTORY_SEPARATOR . $this->s3Provider . 'Keys.php';
1155
1156 $options = [
1157 'bmip_' . $this->s3Provider . '_access_key',
1158 'bmip_' . $this->s3Provider . '_secret_key',
1159 'bmip_' . $this->s3Provider . '_bucket',
1160 'bmip_' . $this->s3Provider . '_storage_class',
1161 'bmip_' . $this->s3Provider . '_path',
1162 'bmip_' . $this->s3Provider . '_region',
1163 'bmip_' . $this->s3Provider . '_sse'
1164 ];
1165
1166 if (file_exists($tempKeyS3File) && !file_exists(BMI_BACKUPS . '/.migration_lock')) {
1167 $sftpKeys = file_get_contents($tempKeyS3File);
1168 $lines = explode("\n", $sftpKeys);
1169
1170 foreach ($options as $index => $option) {
1171 if (isset($lines[$index + 1])) {
1172 update_option($option, trim(substr($lines[$index + 1], 2)));
1173 }
1174 }
1175
1176 @unlink($tempKeyS3File);
1177 }
1178
1179 $configs = [
1180 'accessKey' => get_option('bmip_' . $this->s3Provider . '_access_key'),
1181 'secretKey' => get_option('bmip_' . $this->s3Provider . '_secret_key'),
1182 'bucket' => get_option('bmip_' . $this->s3Provider . '_bucket'),
1183 'storageClass' => get_option('bmip_' . $this->s3Provider . '_storage_class'),
1184 'path' => get_option('bmip_' . $this->s3Provider . '_path'),
1185 'region' => get_option('bmip_' . $this->s3Provider . '_region'),
1186 'sse' => get_option('bmip_' . $this->s3Provider . '_sse')
1187 ];
1188
1189 return $configs;
1190 }
1191
1192 /**
1193 * Get file content from the S3 bucket
1194 *
1195 * @param string $fileName Path to the file on the S3 bucket
1196 * @param string $range Range of bytes to retrieve in the format "start-end"
1197 * @return string|bool File content or false on error
1198 */
1199 public function getFileContent($fileName, $range = '0-0')
1200 {
1201 if ($this->getConnectionStatus() === false) {
1202 return false;
1203 }
1204 if ($range === '0-0') {
1205 return $this->s3Client->getFileContent($fileName);
1206 }
1207
1208 $range = explode('-', $range);
1209
1210 if (count($range) !== 2) {
1211 return false;
1212 }
1213
1214 $offset = intval($range[0]);
1215 $length = intval($range[1]) - $offset + 1;
1216
1217
1218 return $this->s3Client->getFileContent($fileName, $offset, $length);
1219 }
1220
1221 /**
1222 * Get the manifest content from the S3 bucket
1223 *
1224 * @param string $md5 MD5 of the backup
1225 * @return array|bool Manifest content or false on error
1226 */
1227 public function getManifestContent($md5)
1228 {
1229 if ($this->getConnectionStatus() === false) {
1230 return false;
1231 }
1232 $manifestFile = $md5 . '.json';
1233 $manifestContent = $this->s3Client->getFileContent($manifestFile);
1234 if ($manifestContent === false) {
1235 return false;
1236 }
1237 return json_decode($manifestContent, true);
1238 }
1239
1240
1241 /**
1242 * Get parts for upload
1243 *
1244 * @param string $uploadId
1245 * @param string $fileName
1246 * @return array
1247 */
1248 public function getParts($uploadId)
1249 {
1250 return get_option('bmip_' . $this->s3Provider . '_parts_' . $uploadId, []);
1251 }
1252
1253 /**
1254 * Set parts for upload
1255 *
1256 * @param string $uploadId
1257 * @param string $partNumber
1258 * @param string $part
1259 */
1260 public function addPart($uploadId, $partNumber, $part)
1261 {
1262 $parts = $this->getParts($uploadId);
1263 $parts[$partNumber] = $part;
1264 update_option('bmip_' . $this->s3Provider . '_parts_' . $uploadId, $parts);
1265 }
1266
1267 /**
1268 * Remove parts for upload
1269 * @param mixed $uploadId
1270 * @return void
1271 */
1272 public function removeParts($uploadId)
1273 {
1274 delete_option('bmip_' . $this->s3Provider . '_parts_' . $uploadId);
1275 }
1276
1277 /**
1278 * Get the issue status
1279 *
1280 * @return array Issue status
1281 */
1282 public function getIssue()
1283 {
1284 if (Dashboard\bmi_get_config('STORAGE::EXTERNAL::' . strtoupper($this->s3Provider)) != true) {
1285 return [
1286 'issue' => false,
1287 'retryAfter' => false,
1288 'dismissed' => false
1289 ];
1290 }
1291 return [
1292 'issue' => get_transient('bmip_' . $this->s3Provider . '_issue'),
1293 'retryAfter' => human_time_diff(get_option('_transient_timeout_bmip_' . $this->s3Provider . '_issue'), current_time('timestamp')),
1294 'dismissed' => $this->isIssueDismissed()
1295 ];
1296 }
1297
1298 /**
1299 * Set the issue status
1300 *
1301 * @param string $issue
1302 * @param int $timeout
1303 */
1304 public function setIssue($issue, $timeout = HOUR_IN_SECONDS)
1305 {
1306 $currentIssue = get_transient('bmip_' . $this->s3Provider . '_issue');
1307 if ($currentIssue == $issue) {
1308 return;
1309 }
1310 if ($currentIssue == 'forbidden' && $issue == 'disconnected') {
1311 return;
1312 }
1313
1314 set_transient('bmip_' . $this->s3Provider . '_issue', $issue, $timeout);
1315 if (in_array($issue, ['disconnected', 'forbidden'])) {
1316 delete_transient('bmip_' . $this->s3Provider . '_connection_status');
1317 delete_option('bmip_' . $this->s3Provider . '_was_connected');
1318 }
1319 delete_option('bmip_' . $this->s3Provider . '_dismiss_issue');
1320 }
1321
1322 /**
1323 * Dismisses the issue
1324 */
1325 public function dismissIssue()
1326 {
1327 update_option('bmip_' . $this->s3Provider . '_dismiss_issue', true);
1328 }
1329
1330 public function deleteIssue()
1331 {
1332 if (get_transient('bmip_' . $this->s3Provider . '_issue') == false) {
1333 return;
1334 }
1335 set_transient('bmip_' . $this->s3Provider . '_connection_status', true, HOUR_IN_SECONDS);
1336 update_option('bmip_' . $this->s3Provider . '_was_connected', true);
1337 if (in_array(get_transient('bmip_' . $this->s3Provider . '_issue'), ['disconnected', 'forbidden'])) {
1338 delete_transient('bmip_' . $this->s3Provider . '_issue');
1339 }
1340 }
1341
1342 /**
1343 * Checks if the issue is dismissed
1344 *
1345 * @return bool
1346 */
1347 public function isIssueDismissed()
1348 {
1349 return get_option('bmip_' . $this->s3Provider . '_dismiss_issue', false);
1350 }
1351
1352 public function getRegions()
1353 {
1354 return isset(self::S3_PROVIDERS_REGIONS[$this->s3Provider]) ? self::S3_PROVIDERS_REGIONS[$this->s3Provider] : [];
1355 }
1356
1357 public function getConnectionStatus()
1358 {
1359 if (!$this->checkConnection) {
1360 $this->initializeConnection();
1361 $this->checkConnection = true;
1362 }
1363 return $this->s3Client->getStatus();
1364 }
1365
1366
1367 }