FileMaster
Search
Toggle Dark Mode
Home
/
.
/
wp-content
/
plugins
/
backup-backup
/
includes
/
external
Edit File: s3.php
<?php namespace BMI\Plugin\External; require_once BMI_INCLUDES . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 's3-client' . DIRECTORY_SEPARATOR . 's3.php'; use BMI\Plugin\Dashboard; use BMI\Plugin\BMI_Logger as Logger; use BMI\Plugin\Scanner\BMI_BackupsScanner as Backups; use BMI\Plugin\Backup_Migration_Plugin as BMP; if ( ! defined( 'ABSPATH' ) ) exit; // Exception Class for S3 Client Errors class S3ClientException extends \Exception { } class S3Client { /** @var S3 */ private $s3; /** @var bool */ private $status = false; /** @var string */ private $rootDir = ''; /** @var string */ private $bucket = ''; /** @var array */ private $config = array(); /** @var int */ private $chunkSize = 5242880; // 5MB default chunk size /** * Constructor */ public function __construct($endpoint = 's3.amazonaws.com') { if (!function_exists('curl_version')) { $this->s3 = null; return; } $this->s3 = new S3(null, null, false, $endpoint); } /** * Sets the root directory for the S3 bucket. * * @param string $rootDir * @return $this */ public function setRootDir($rootDir) { $this->rootDir = BMP::fixSlashes($rootDir); return $this; } /** * Sets the bucket name for the S3 connection. * * @param string $bucket * @return $this */ public function setBucket($bucket) { $this->bucket = trim($bucket); return $this; } /** * Sets the configuration for the S3 connection. */ public function setConfig(array $config) { $this->config = $config; $this->s3->setAuth($config['accessKey'], $config['secretKey']); $this->s3->setSSL(true); $this->s3->setRegion($config['region']); $this->s3->setServerSideEncryption($config['sse']); $this->s3->setStorageClass($config['storageClass']); $this->bucket = $config['bucket']; $this->rootDir = BMP::fixSlashes($config['path']); return $this; } /** * Gets the connection status. * * @return bool */ public function getStatus() { return $this->status; } /** * Sets the connection status. * * @param bool $status * @return $this */ public function setStatus($status) { $this->status = (bool) $status; return $this; } /** * Connects to the S3 server with improved error handling. * * @param string $accessKey AWS access key * @param string $secretKey AWS secret key * @param string $bucket S3 bucket name * @param string $region AWS region (optional) * @param string $storageClass Storage class (default: 'STANDARD') * @param string $sse Server-side encryption (optional) * @return array Connection status * @throws S3ClientException */ public function connect($accessKey, $secretKey, $bucket, $region = '', $storageClass = 'STANDARD', $sse = '') { if ($this->status) { return array('status' => 'connected'); } try { // Configure S3 client $this->s3->setAuth($accessKey, $secretKey); $this->s3->setSSL(true); $this->s3->setRegion($region); $this->s3->setServerSideEncryption($sse); $this->s3->setStorageClass($storageClass); $this->bucket = $bucket; // Test connection $testResult = $this->s3->getBucket($bucket, null, null, 1); if ($testResult === false) { throw new S3ClientException($testResult); } $this->status = true; return array('status' => 'connected'); } catch (\Exception $e) { $this->status = false; Logger::error('[S3Client] Connection failed: ' . $e->getMessage()); $message = $e->getMessage(); if (strpos($message, 'the region') !== false && strpos($message, 'is wrong') !== false) { return array('status' => 'error', 'error' => 'Connection failed: The region is wrong.'); } return array('status' => 'error', 'error' => 'Connection failed: ' . $message); } } /** * Test the S3 connection by performing basic operations * * @return true|string True on success, error message on failure */ public function testConnection() { try { $this->s3->setExceptions(true); $this->s3->getBucket($this->bucket,null, null, 1); $testKey = '.bmi_permission_test_' . uniqid(); $testContent = 'test'; $writeTest = $this->s3->putObject($testContent, $this->bucket, $testKey); if ($writeTest === false) { return 'Failed to write test object.'; } $getTest = $this->s3->getObject($this->bucket, $testKey); if ($getTest->body !== $testContent) { return 'Test object content mismatch.'; } $deleteTest = $this->s3->deleteObject($this->bucket, $testKey); if ($deleteTest === false) { return 'Failed to delete test object.'; } $this->s3->setExceptions(false); return true; } catch (\Exception $e) { $message = $e->getMessage(); if (strpos($message, 'the region') !== false && strpos($message, 'is wrong') !== false) { return 'The region is wrong.'; } return $message; } } /** * Builds a URI for S3 operations with proper path handling * * @param string $fileName * @param string $bucket * @param string $path * @return string */ private function buildUri($fileName, $bucket = '', $path = '') { $path = empty($path) ? $this->rootDir : BMP::fixSlashes($path); $path = trim($path, '/'); $fileName = trim($fileName, '/'); return empty($path) ? $fileName : $path . '/' . $fileName; } /** * Starts a multipart upload session. * * @param string $fileName * @param string $bucket * @param string $path * @param array $metaHeaders Optional meta headers * @return string|bool Upload ID or false on failure */ public function startUploadSession($fileName, $bucket = '', $path = '', $metaHeaders = []) { if (!$this->status) { return false; } $uri = $this->buildUri($fileName, $bucket, $path); $bucket = $bucket ? $bucket : $this->bucket; return $this->s3->createMultipartUpload($bucket, $uri, 'private', $metaHeaders); } /** * Uploads a chunk of data with improved error handling and memory management * * @param string $uploadId * @param string $fileName * @param int $partNumber * @param string $data * @param string $bucket * @param string $path * @return string|bool ETag or false on failure */ public function uploadChunk($uploadId, $fileName, $partNumber, $data, $bucket = '', $path = '') { if (!$this->status) { Logger::error('[S3Client] Cannot upload chunk: Not connected'); return false; } try { $uri = $this->buildUri($fileName, $bucket, $path); $bucket = $bucket ? $bucket : $this->bucket; // Validate chunk size $dataSize = strlen($data); if ($dataSize > $this->chunkSize * 2) { throw new S3ClientException('Chunk size exceeds maximum allowed size'); } $result = $this->s3->uploadPart($bucket, $uri, $uploadId, $partNumber, $data); if ($result === false) { throw new S3ClientException('Failed to upload chunk'); } return $result; } catch (\Exception $e) { Logger::error(sprintf( '[S3Client] Failed to upload chunk for file %s (Part %d): %s', $fileName, $partNumber, $e->getMessage() )); return false; } } /** * Ends a multipart upload session. * * @param string $uploadId * @param string $fileName * @param array $parts * @param bool $success * @param string $bucket * @param string $path * @return bool */ public function endUploadSession($uploadId, $fileName, $parts, $success, $bucket = '', $path = '') { if (!$this->status) { return false; } $uri = $this->buildUri($fileName, $bucket, $path); $bucket = $bucket ? $bucket : $this->bucket; if ($success) { return $this->s3->completeMultipartUpload($bucket, $uri, $uploadId, $parts); } return $this->s3->abortMultipartUpload($bucket, $uri, $uploadId); } /** * Deletes a file from S3. * * @param string $fileName * @param string $bucket * @param string $path * @return bool */ public function deleteFile($fileName, $bucket = '', $path = '') { if (!$this->status) { return false; } $uri = $this->buildUri($fileName, $bucket, $path); $bucket = $bucket ? $bucket : $this->bucket; return $this->s3->deleteObject($bucket, $uri); } /** * Lists files in the S3 bucket. * * @param string $bucket * @param string $path * @return array|bool */ public function listFiles($bucket = '', $path = '') { if (!$this->status) { return false; } $bucket = $bucket ? $bucket : $this->bucket; $fullPath = $path ? BMP::fixSlashes($path) : $this->rootDir; return $this->s3->getBucket($bucket, $fullPath); } /** * Gets file metadata. * * @param string $fileName * @param string $bucket * @param string $path * @return array|bool */ public function getFileMeta($fileName, $bucket = '', $path = '') { if (!$this->status) { return false; } $uri = $this->buildUri($fileName, $bucket, $path); $bucket = $bucket ? $bucket : $this->bucket; return $this->s3->getObjectInfo($bucket, $uri); } /** * Uploads an entire file. * * @param string $fileName * @param string $localPath * @param string $bucket * @param string $path * @param array $metaHeaders Optional meta headers * @return bool */ public function uploadFile($fileName, $localPath, $bucket = '', $path = '', $metaHeaders = []) { if (!$this->status) { return false; } $uri = $this->buildUri($fileName, $bucket, $path); $bucket = $bucket ? $bucket : $this->bucket; try { $input = file_get_contents($localPath); if ($input === false) { Logger::error('[S3Client] Failed to read file: ' . $localPath); return false; } $result = $this->s3->putObject($input, $bucket, $uri, 'private', $metaHeaders); if ($result === false) { Logger::error('[S3Client] Failed to upload file: ' . $fileName); } return $result; } catch (\Exception $e) { Logger::error('[S3Client] Exception during file upload: ' . $e->getMessage()); return false; } } /** * Gets file content. * * @param string $fileName * @param int $offset * @param int $length * @param string $bucket * @param string $path * @return string|bool */ public function getFileContent($fileName, $offset = 0, $length = -1, $bucket = '', $path = '') { if (!$this->status) { return false; } $uri = $this->buildUri($fileName, $bucket, $path); $bucket = $bucket ? $bucket : $this->bucket; $range = 'bytes=' . $offset . '-' . ($length === -1 ? '' : ($offset + $length - 1)); $response = $this->s3->getObject($bucket, $uri, false, $range); return $response === false ? false : $response->body; } /** * Creates a directory if it doesn't exist. * * @param string $fullPath * @param string $bucket * @return bool */ public function createDirectoryIfNotExists($fullPath, $bucket = '') { if (!$this->status) { return false; } $bucket = $bucket ? $bucket : $this->bucket; $fullPath = BMP::fixSlashes($fullPath); if ($this->s3->getObjectInfo($bucket, $fullPath) !== false) { return true; } $parts = explode(DIRECTORY_SEPARATOR, trim($fullPath, DIRECTORY_SEPARATOR)); $currentPath = ''; foreach ($parts as $part) { $currentPath .= DIRECTORY_SEPARATOR . $part; if (!$this->s3->getObjectInfo($bucket, $currentPath)) { if (!$this->s3->createFolder($bucket, $currentPath)) { return false; } } } return $this->s3->getObjectInfo($bucket, $fullPath) !== false; } } class BMI_External_S3 { private $s3Client; private $s3Provider = 'aws'; const SINGLE_UPLOAD_THRESHOLD = 10485760; const CHUNK_SIZE = 10485760; private $checkConnection = false; const S3_PROVIDERS_REGIONS = [ 'aws' => [ 'us-east-1' => 'N. Virginia (us-east-1)', 'us-east-2' => 'Ohio (us-east-2)', 'us-west-1' => 'N. California (us-west-1)', 'us-west-2' => 'Oregon (us-west-2)', 'af-south-1' => 'Cape Town (af-south-1)', 'ap-east-1' => 'Hong Kong (ap-east-1)', 'ap-south-1' => 'Mumbai (ap-south-1)', 'ap-northeast-3' => 'Osaka (ap-northeast-3)', 'ap-northeast-2' => 'Seoul (ap-northeast-2)', 'ap-southeast-1' => 'Singapore (ap-southeast-1)', 'ap-southeast-2' => 'Sydney (ap-southeast-2)', 'ap-northeast-1' => 'Tokyo (ap-northeast-1)', 'ca-central-1' => 'Central (ca-central-1)', 'eu-central-1' => 'Frankfurt (eu-central-1)', 'eu-west-1' => 'Ireland (eu-west-1)', 'eu-west-2' => 'London (eu-west-2)', 'eu-south-1' => 'Milan (eu-south-1)', 'eu-west-3' => 'Paris (eu-west-3)', 'eu-north-1' => 'Stockholm (eu-north-1)', 'me-south-1' => 'Bahrain (me-south-1)', 'sa-east-1' => 'São Paulo (sa-east-1)', ], 'wasabi' => [ 'us-west-1' => 'Oregon (us-west-1)', 'us-east-1' => 'Virginia (us-east-1)', 'us-east-2' => 'Virginia (us-east-2)', 'us-central-1' => 'Texas (us-central-1)', 'ca-central-1' => 'Canada (ca-central-1)', 'eu-west-1' => 'England (eu-west-1)', 'eu-west-3' => 'England (eu-west-3)', 'eu-west-2' => 'France (eu-west-2)', 'eu-central-1' => 'Netherlands (eu-central-1)', 'eu-central-2' => 'Germany (eu-central-2)', 'eu-south-1' => 'Italy (eu-south-1)', 'ap-northeast-1' => 'Japan (ap-northeast-1)', 'ap-northeast-2' => 'Japan (ap-northeast-2)', 'ap-southeast-2' => 'Australia (ap-southeast-2)', 'ap-southeast-1' => 'Singapore (ap-southeast-1)', ], 'digitalocean' => [ 'nyc3' => 'New York 3', 'ams3' => 'Amsterdam 3', 'sgp1' => 'Singapore 1', 'sfo2' => 'San Francisco 2', ], ]; const S3_PROVIDERS_ENDPOINTS = [ 'aws' => 's3.amazonaws.com', 'wasabi' => 's3.wasabisys.com', 'digitalocean' => 'nyc3.digitaloceanspaces.com', ]; /** * Constructor * * @param string $provider S3-compatible service identifier (e.g. 'aws', 'wasabi', etc.) */ public function __construct($provider) { if ($provider) { $this->s3Provider = $provider; } $endpoint = self::S3_PROVIDERS_ENDPOINTS[$this->s3Provider]; if (!self::hasRequiredExtensions()) { return; } $this->s3Client = new S3Client($endpoint); $this->registerHooks(); set_error_handler([$this, 'errorHandler'], E_USER_WARNING); } /** * Checks if all required PHP extensions are available * * @return bool True if all required extensions are available */ public static function hasRequiredExtensions() { return self::getExtensionRequirements()['status'] === true; } /** * Validates and returns the status of required PHP extensions * * @return array Status and details of missing extensions */ public static function getExtensionRequirements() { $response = [ 'status' => true, 'errors' => [ 'missing_extensions' => [], ] ]; $requiredExtensions = [ // label of extension => method used to check its existence 'cURL' => 'curl_version', 'Hash' => 'hash_hmac', 'SimpleXML/LibXML' => 'simplexml_load_string', ]; foreach ($requiredExtensions as $label => $function) { if (!function_exists($function)) { $response['errors']['missing_extensions'][] = $label; $response['status'] = false; } } if (!class_exists('DOMDocument') && !class_exists('\DOMDocument')) { $response['errors']['missing_extensions'][] = 'DOM'; $response['status'] = false; } return $response; } public function errorHandler($errno, $context, $errfile, $errline) { $context = json_decode($context, true); if (isset($context['code'])){ switch ($context['code']) { case 429: $this->setIssue('rate_limit'); break; case 403: $this->setIssue('forbidden'); break; case 401: $this->setIssue('disconnected'); break; } } return true; } /** * Initializes the S3 connection */ private function initializeConnection() { $connectionStatus = get_transient('bmip_' . $this->s3Provider . '_connection_status'); $configs = $this->retrieveS3Configs(); if ($connectionStatus == true && get_transient('bmip_' . $this->s3Provider . '_issue') == false ) { $this->deleteIssue(); } else { if ($configs['accessKey'] == '' || $configs['secretKey'] == '' || $configs['bucket'] == '' || $configs['region'] == '') { return; } $connectionStatus = $this->s3Client->connect( $configs['accessKey'], $configs['secretKey'], $configs['bucket'], $configs['region'], $configs['storageClass'], $configs['sse'] )['status'] == 'connected'; if ($connectionStatus) { $this->deleteIssue(); } else { if (get_option('bmip_' . $this->s3Provider . '_was_connected', false)) { $this->setIssue('disconnected'); } } } if ($connectionStatus) { $this->s3Client->setConfig($configs); } $this->s3Client->setStatus($connectionStatus); } /** * Initializes hooks for the S3 provider */ private function registerHooks() { add_action('bmi_premium_remove_backup_file', [&$this, 'deleteBackup']); add_action('bmi_premium_remove_backup_json_file', [&$this, 'deleteBackupJson']); add_action('update_option_bmip_' . $this->s3Provider . '_access_key', [&$this, 'restartUploadProcess']); add_action('update_option_bmip_' . $this->s3Provider . '_secret_key', [&$this, 'restartUploadProcess']); add_action('update_option_bmip_' . $this->s3Provider . '_bucket', [&$this, 'restartUploadProcess']); add_action('update_option_bmip_' . $this->s3Provider . '_storage_class', [&$this, 'restartUploadProcess']); add_action('update_option_bmip_' . $this->s3Provider . '_path', [&$this, 'restartUploadProcess']); add_action('update_option_bmip_' . $this->s3Provider . '_region', [&$this, 'restartUploadProcess']); add_action('update_option_bmip_' . $this->s3Provider . '_sse', [&$this, 'restartUploadProcess']); add_action('set_transient_bmip_' . $this->s3Provider . '_connection_status', [&$this, 'restartUploadProcess']); add_action('delete_transient_bmip_' . $this->s3Provider . '_issue', [&$this, 'resetIssueDisplayOption']); } /** * Dismisses the issue display option */ public function resetIssueDisplayOption() { delete_option('bmip_' . $this->s3Provider . '_dismiss_issue'); } /** * Checks for backups to upload to S3 bucket * * @return array Status of the upload process */ public function checkForBackupsToUpload() { $isEnabled = Dashboard\bmi_get_config('STORAGE::EXTERNAL::' . strtoupper($this->s3Provider)); if (!($isEnabled === true || $isEnabled === 'true')) { update_option('bmip_to_be_uploaded', [ 'current_upload' => [], 'queue' => [] ]); return ['status' => 'not_enabled']; } if ($this->getConnectionStatus() === false) { return ['status' => 'error']; } $requiresUpload = get_option('bmip_to_be_uploaded', [ 'current_upload' => [], 'queue' => [], 'failed' => [] ]); require_once BMI_INCLUDES . DIRECTORY_SEPARATOR . 'scanner' . DIRECTORY_SEPARATOR . 'backups.php'; $backups = new Backups(); $backupsAvailable = $backups->getAvailableBackups("local"); $localBackups = $backupsAvailable['local']; $parsedS3Files = $this->getParsedFiles(); if ($parsedS3Files === false) { return ['status' => 'error']; } $backupsFileName = isset($parsedS3Files['zipFilesName']) ? $parsedS3Files['zipFilesName'] : []; $manifestFilesPath = isset($parsedS3Files['jsonFilesPath']) ? $parsedS3Files['jsonFilesPath'] : []; $availableManifests = array_map(function ($path) { return pathinfo($path, PATHINFO_FILENAME); }, $manifestFilesPath); $uploadedBackupStatus = get_option('bmi_uploaded_backups_status', []); foreach ($localBackups as $name => $details) { $md5 = $details[7]; if (isset($uploadedBackupStatus[$md5]) && isset($uploadedBackupStatus[$md5][$this->s3Provider])) { continue; } $isBackupNotExists = !in_array($md5, $availableManifests) || !in_array($name, array_keys($backupsFileName)); if ($isBackupNotExists && !(isset($requiresUpload['current_upload']['task']) && $requiresUpload['current_upload']['task'] == $this->s3Provider . '_' . $md5)) { $requiresUpload['queue'][$this->s3Provider . '_' . $md5] = [ 'name' => $name, 'md5' => $md5, 'json' => $md5 . '.json', ]; } } update_option('bmip_to_be_uploaded', $requiresUpload); return ['status' => 'success']; } /** * Restarts the upload process of backups * * @return array Status of the upload process */ public function restartUploadProcess() { $requiredToUpload = get_option('bmip_to_be_uploaded', [ 'current_upload' => [], 'queue' => [], 'failed' => [] ]); if (isset($requiredToUpload['current_upload']['task']) && strpos($requiredToUpload['current_upload']['task'], $this->s3Provider) !== false) { unset($requiredToUpload['current_upload']); } if (!isset($requiredToUpload['failed'])) { $requiredToUpload['failed'] = []; } foreach ($requiredToUpload['failed'] as $key => $value) { if (strpos($key, $this->s3Provider . '_') !== false) { unset($requiredToUpload['failed'][$key]); } } update_option('bmip_to_be_uploaded', $requiredToUpload); return $this->checkForBackupsToUpload(); } /** * Retrieves and parses files from the S3 bucket * * @return array|bool Parsed files or false on error */ public function getParsedFiles() { if ($this->getConnectionStatus() === false) { return false; } $zipFilesName = []; $jsonFilesPath = []; $files = $this->s3Client->listFiles(); if ($files === false) { return false; } $path = get_option('bmip_' . $this->s3Provider . '_path', ''); foreach ($files as $filename => $metadata) { $fileData = pathinfo($filename); if ($fileData['dirname'] == '.' && $path != '') { continue; } if ($fileData['dirname'] != '.' && $fileData['dirname'] != $path) { continue; } $filename = $fileData['basename']; $metadata['name'] = $filename; $extension = strtolower($fileData['extension']); if (in_array($extension, array('zip', 'tar', 'gz'))) { $zipFilesName[$filename] = ['id' => $filename, 'size' => $metadata['size']]; } elseif ($extension === 'json' && strlen($fileData['filename']) === 32) { $jsonFilesPath[] = $filename; } } return compact('zipFilesName', 'jsonFilesPath'); } /** * Verifies the S3 connection status * * @return array Connection status */ public function verifyConnection() { $status = $this->getConnectionStatus(); if ($status == true) { return ['result' => 'connected']; } else { return ['result' => 'disconnected']; } } /** * Tests the S3 bucket connection * * @param string $host * @param int $port * @param string $authType * @param string $username * @param string $password * @param string|null $fingerPrint * @param string|null $passphrase * @return array Connection test result */ public function testConnection($accessKey, $secretKey, $bucket, $region, $path, $storageClass = 'STANDARD', $sse = '') { try { // Initialize connection test status $testStatus = [ 'credentials' => false, 'bucket_exists' => false, 'bucket_access' => false, 'permissions' => [ 'list' => false, 'write' => false, 'delete' => false ] ]; $this->s3Client->setConfig([ 'accessKey' => $accessKey, 'secretKey' => $secretKey, 'bucket' => $bucket, 'region' => $region, 'path' => $path, 'storageClass' => $storageClass, 'sse' => $sse ]); $connectResult = $this->s3Client->testConnection(); if ($connectResult !== true) { // Add detailed error information return [ 'status' => 'error', 'error' => $connectResult, 'test_status' => $testStatus ]; } $testStatus['credentials'] = true; $testStatus['bucket_exists'] = true; $testStatus['bucket_access'] = true; $testStatus['permissions']['list'] = true; $testStatus['permissions']['write'] = true; $testStatus['permissions']['delete'] = true; return [ 'status' => 'success', 'test_status' => $testStatus, 'message' => 'Connection test successful. All required permissions verified.' ]; } catch (\Exception $e) { if (BMI_PRO_DEBUG) { Logger::error('[BMI_External_S3] Test connection failed: ' . $e->getMessage()); } return [ 'status' => 'error', 'error' => 'Connection test failed: ' . $e->getMessage(), 'test_status' => $testStatus ?? null ]; } } /** * Disconnects from the S3 bucket * * @return array Status of the disconnection */ public function disconnect() { delete_option('bmip_' . $this->s3Provider . '_access_key'); delete_option('bmip_' . $this->s3Provider . '_secret_key'); delete_option('bmip_' . $this->s3Provider . '_bucket'); delete_option('bmip_' . $this->s3Provider . '_storage_class'); delete_option('bmip_' . $this->s3Provider . '_path'); delete_option('bmip_' . $this->s3Provider . '_region'); delete_option('bmip_' . $this->s3Provider . '_sse'); delete_option('bmip_' . $this->s3Provider . '_was_connected'); delete_transient('bmip_' . $this->s3Provider . '_connection_status'); $this->restartUploadProcess(); Dashboard\bmi_set_config('STORAGE::EXTERNAL::' . strtoupper($this->s3Provider), false); return ['status' => 'success']; } /** * Uploads a backup to the S3 bucket. * * @param string $uploadId * @param string $backupName * @param int $offset * @param string $md5 * @return array */ public function uploadBackup($uploadId, $backupName, $offset, $md5) { if ($this->getConnectionStatus() === false) { return ['status' => 'error', 'error' => 'disconnected']; } $backupPath = BMI_BACKUPS . DIRECTORY_SEPARATOR . $backupName; $manifestPath = BMI_BACKUPS . DIRECTORY_SEPARATOR . $md5 . '.json'; if (!file_exists($backupPath)) { $this->restartUploadProcess(); return ['status' => 'error', 'error' => 'internal_file_not_found']; } $fileSize = filesize($backupPath); try { if ($fileSize <= self::SINGLE_UPLOAD_THRESHOLD) { return $this->uploadSmallFile($backupName, $backupPath, $manifestPath, $md5); } return $this->uploadLargeFile($uploadId, $backupName, $backupPath, $offset, $fileSize, $manifestPath, $md5); } catch (\Exception $e) { Logger::error('[BMI PRO] Upload failed for ' . $backupName . ': ' . $e->getMessage()); if ($uploadId) { $this->s3Client->endUploadSession($uploadId, $backupName, [], false); $this->removeParts($uploadId); } return [ 'status' => 'error', 'error' => 'upload_failed', 'message' => $e->getMessage() ]; } } /** * Uploads a small file in a single request. * * @param string $backupName * @param string $backupPath * @param string $manifestPath * @param string $md5 * @return array */ private function uploadSmallFile($backupName, $backupPath, $manifestPath, $md5) { $fileMd5 = md5_file($backupPath); $metaHeaders = ['md5' => $fileMd5]; $uploadResult = $this->s3Client->uploadFile($backupName, $backupPath, '', '', $metaHeaders); if (!$uploadResult) { Logger::error('[BMI PRO] Failed to upload small backup file: ' . $backupName); return ['status' => 'error', 'error' => 'upload_backup']; } $meta = $this->s3Client->getFileMeta($backupName); if ($meta === false || !isset($meta['x-amz-meta-md5']) || $meta['x-amz-meta-md5'] !== $fileMd5) { Logger::error('[BMI PRO] MD5 mismatch for small file: ' . $backupName . '. Local: ' . $fileMd5 . ', Remote: ' . ($meta['x-amz-meta-md5'] ?? 'N/A') . '. Deleting remote file.'); $this->s3Client->deleteFile($backupName); // Attempt to delete corrupted file return ['status' => 'error', 'error' => 'md5_mismatch']; } $manifestResult = $this->s3Client->uploadFile($md5 . '.json', $manifestPath); if (!$manifestResult) { Logger::error('[BMI PRO] Failed to upload manifest for ' . $backupName); $this->s3Client->deleteFile($backupName); return ['status' => 'error', 'error' => 'upload_manifest']; } return ['status' => 'success']; } /** * Uploads a large file using multipart upload. * * @param string $uploadId * @param string $backupName * @param string $backupPath * @param int $offset * @param int $fileSize * @param string $manifestPath * @param string $md5 * @return array */ private function uploadLargeFile($uploadId, $backupName, $backupPath, $offset, $fileSize, $manifestPath, $md5) { $fileMd5 = md5_file($backupPath); if (!$uploadId) { $metaHeaders = ['md5' => $fileMd5]; $uploadId = $this->s3Client->startUploadSession($backupName, '', '', $metaHeaders); if ($uploadId === false) { throw new \Exception('Failed to start upload session.'); } return ['status' => 'continue', 'offset' => 0, 'uploadId' => $uploadId]; } if ($offset < $fileSize) { $newOffset = $this->uploadChunk($uploadId, $backupName, $offset); if ($newOffset === false) { throw new \Exception('Failed to upload chunk.'); } return ['status' => 'continue', 'offset' => $newOffset, 'uploadId' => $uploadId]; } $parts = $this->getParts($uploadId); $endResult = $this->s3Client->endUploadSession($uploadId, $backupName, $parts, true); if ($endResult === false) { throw new \Exception('Failed to complete upload session.'); } $this->removeParts($uploadId); $meta = $this->s3Client->getFileMeta($backupName); if ($meta === false || !isset($meta['x-amz-meta-md5']) || $meta['x-amz-meta-md5'] !== $fileMd5) { Logger::error('[BMI PRO] MD5 mismatch for large file: ' . $backupName . '. Local: ' . $fileMd5 . ', Remote: ' . ($meta['x-amz-meta-md5'] ?? 'N/A')); $this->s3Client->deleteFile($backupName); return ['status' => 'error', 'error' => 'md5_mismatch']; } $manifestResult = $this->s3Client->uploadFile($md5 . '.json', $manifestPath); if (!$manifestResult) { Logger::error('[BMI PRO] Failed to upload manifest for ' . $backupName); $this->s3Client->deleteFile($backupName); return ['status' => 'error', 'error' => 'upload_manifest']; } return ['status' => 'success']; } /** * Uploads a chunk of a backup. * * @param string $uploadId * @param string $backupName * @param int $offset * @return int|bool */ public function uploadChunk($uploadId, $backupName, $offset) { if ($this->getConnectionStatus() === false) { return false; } $backupPath = BMI_BACKUPS . DIRECTORY_SEPARATOR . $backupName; $backupFile = fopen($backupPath, 'r'); if ($backupFile === false) { Logger::error('[BMI PRO] Unable to open backup file: ' . $backupName); return false; } if (fseek($backupFile, $offset) !== 0) { fclose($backupFile); Logger::error('[BMI PRO] Failed to seek in file: ' . $backupName); return false; } $data = fread($backupFile, self::CHUNK_SIZE); fclose($backupFile); if ($data === false) { Logger::error('[BMI PRO] Failed to read from file: ' . $backupName); return false; } $parts = $this->getParts($uploadId); $partNumber = empty($parts) ? 1 : max(array_keys($parts)) + 1; $eTag = $this->s3Client->uploadChunk($uploadId, $backupName, $partNumber, $data); if ($eTag === false) { Logger::error('[BMI PRO] Failed to upload chunk for file: ' . $backupName); return false; } $this->addPart($uploadId, $partNumber, $eTag); return $offset + strlen($data); } /** * Checks if a file exists on the S3 bucket * * @param string $fileName Path to the file on the S3 bucket * @return bool */ public function isFileExists($fileName) { if ($this->getConnectionStatus() === false) { return false; } $file = $this->s3Client->getFileMeta($fileName); return $file !== false; } /** * Get file metadata from the S3 bucket * * @param string $fileName Path to the file on the S3 bucket * @return array|bool File metadata or false on error */ public function getFileMeta($fileName) { if ($this->getConnectionStatus() === false) { return false; } return $this->s3Client->getFileMeta($fileName); } /** * Deletes a backup from the S3 bucket * * @param string $md5 MD5 of the backup to delete * @return bool */ public function deleteBackup($md5) { if ($this->getConnectionStatus() === false) { return false; } $manifestFile = $md5 . '.json'; if (file_exists(BMI_BACKUPS . DIRECTORY_SEPARATOR . $manifestFile)) { $manifestContent = json_decode(file_get_contents(BMI_BACKUPS . DIRECTORY_SEPARATOR . $manifestFile), true); } else { $manifestContent = json_decode($this->s3Client->getFileContent($manifestFile), true); } $backupName = isset($manifestContent['name']) ? $manifestContent['name'] : ''; if (empty($backupName)) { Logger::error('[BMI PRO] Manifest does not contain backup name.'); return false; } $deleteManifest = $this->s3Client->deleteFile($manifestFile); $deleteBackup = $this->s3Client->deleteFile($backupName); return $deleteManifest && $deleteBackup; } /** * Deletes a backup manifest from the S3 bucket * * @param string $md5 MD5 of the backup to delete * @return bool */ public function deleteBackupJson($md5) { if ($this->getConnectionStatus() === false) { return false; } $manifestFile = $md5 . '.json'; return $this->s3Client->deleteFile($manifestFile); } /** * Retrieves S3 configs from temporary file or options * * @return array Configs array */ public function retrieveS3Configs() { $tempKeyS3File = BMI_TMP . DIRECTORY_SEPARATOR . $this->s3Provider . 'Keys.php'; $options = [ 'bmip_' . $this->s3Provider . '_access_key', 'bmip_' . $this->s3Provider . '_secret_key', 'bmip_' . $this->s3Provider . '_bucket', 'bmip_' . $this->s3Provider . '_storage_class', 'bmip_' . $this->s3Provider . '_path', 'bmip_' . $this->s3Provider . '_region', 'bmip_' . $this->s3Provider . '_sse' ]; if (file_exists($tempKeyS3File) && !file_exists(BMI_BACKUPS . '/.migration_lock')) { $sftpKeys = file_get_contents($tempKeyS3File); $lines = explode("\n", $sftpKeys); foreach ($options as $index => $option) { if (isset($lines[$index + 1])) { update_option($option, trim(substr($lines[$index + 1], 2))); } } @unlink($tempKeyS3File); } $configs = [ 'accessKey' => get_option('bmip_' . $this->s3Provider . '_access_key'), 'secretKey' => get_option('bmip_' . $this->s3Provider . '_secret_key'), 'bucket' => get_option('bmip_' . $this->s3Provider . '_bucket'), 'storageClass' => get_option('bmip_' . $this->s3Provider . '_storage_class'), 'path' => get_option('bmip_' . $this->s3Provider . '_path'), 'region' => get_option('bmip_' . $this->s3Provider . '_region'), 'sse' => get_option('bmip_' . $this->s3Provider . '_sse') ]; return $configs; } /** * Get file content from the S3 bucket * * @param string $fileName Path to the file on the S3 bucket * @param string $range Range of bytes to retrieve in the format "start-end" * @return string|bool File content or false on error */ public function getFileContent($fileName, $range = '0-0') { if ($this->getConnectionStatus() === false) { return false; } if ($range === '0-0') { return $this->s3Client->getFileContent($fileName); } $range = explode('-', $range); if (count($range) !== 2) { return false; } $offset = intval($range[0]); $length = intval($range[1]) - $offset + 1; return $this->s3Client->getFileContent($fileName, $offset, $length); } /** * Get the manifest content from the S3 bucket * * @param string $md5 MD5 of the backup * @return array|bool Manifest content or false on error */ public function getManifestContent($md5) { if ($this->getConnectionStatus() === false) { return false; } $manifestFile = $md5 . '.json'; $manifestContent = $this->s3Client->getFileContent($manifestFile); if ($manifestContent === false) { return false; } return json_decode($manifestContent, true); } /** * Get parts for upload * * @param string $uploadId * @param string $fileName * @return array */ public function getParts($uploadId) { return get_option('bmip_' . $this->s3Provider . '_parts_' . $uploadId, []); } /** * Set parts for upload * * @param string $uploadId * @param string $partNumber * @param string $part */ public function addPart($uploadId, $partNumber, $part) { $parts = $this->getParts($uploadId); $parts[$partNumber] = $part; update_option('bmip_' . $this->s3Provider . '_parts_' . $uploadId, $parts); } /** * Remove parts for upload * @param mixed $uploadId * @return void */ public function removeParts($uploadId) { delete_option('bmip_' . $this->s3Provider . '_parts_' . $uploadId); } /** * Get the issue status * * @return array Issue status */ public function getIssue() { if (Dashboard\bmi_get_config('STORAGE::EXTERNAL::' . strtoupper($this->s3Provider)) != true) { return [ 'issue' => false, 'retryAfter' => false, 'dismissed' => false ]; } return [ 'issue' => get_transient('bmip_' . $this->s3Provider . '_issue'), 'retryAfter' => human_time_diff(get_option('_transient_timeout_bmip_' . $this->s3Provider . '_issue'), current_time('timestamp')), 'dismissed' => $this->isIssueDismissed() ]; } /** * Set the issue status * * @param string $issue * @param int $timeout */ public function setIssue($issue, $timeout = HOUR_IN_SECONDS) { $currentIssue = get_transient('bmip_' . $this->s3Provider . '_issue'); if ($currentIssue == $issue) { return; } if ($currentIssue == 'forbidden' && $issue == 'disconnected') { return; } set_transient('bmip_' . $this->s3Provider . '_issue', $issue, $timeout); if (in_array($issue, ['disconnected', 'forbidden'])) { delete_transient('bmip_' . $this->s3Provider . '_connection_status'); delete_option('bmip_' . $this->s3Provider . '_was_connected'); } delete_option('bmip_' . $this->s3Provider . '_dismiss_issue'); } /** * Dismisses the issue */ public function dismissIssue() { update_option('bmip_' . $this->s3Provider . '_dismiss_issue', true); } public function deleteIssue() { if (get_transient('bmip_' . $this->s3Provider . '_issue') == false) { return; } set_transient('bmip_' . $this->s3Provider . '_connection_status', true, HOUR_IN_SECONDS); update_option('bmip_' . $this->s3Provider . '_was_connected', true); if (in_array(get_transient('bmip_' . $this->s3Provider . '_issue'), ['disconnected', 'forbidden'])) { delete_transient('bmip_' . $this->s3Provider . '_issue'); } } /** * Checks if the issue is dismissed * * @return bool */ public function isIssueDismissed() { return get_option('bmip_' . $this->s3Provider . '_dismiss_issue', false); } public function getRegions() { return isset(self::S3_PROVIDERS_REGIONS[$this->s3Provider]) ? self::S3_PROVIDERS_REGIONS[$this->s3Provider] : []; } public function getConnectionStatus() { if (!$this->checkConnection) { $this->initializeConnection(); $this->checkConnection = true; } return $this->s3Client->getStatus(); } }
Save
Back