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