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