From 74ee3ea3aba58194a0d55a6c8a7775ca2ae84830 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 2 Jun 2026 18:37:20 -0500 Subject: [PATCH] feat: SHA-256 checksums (#15) and S3 storage backend (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #15 — Backup Integrity Verification: - Compute SHA-256 hash after archive creation, store in checksum column - "Verify Integrity" toolbar button re-computes and compares - Hash logged in backup step log #16 — S3-Compatible Storage: - S3Uploader with AWS Signature V4 (no SDK dependency, pure cURL) - Single PUT for files <= 100 MB, multipart upload for larger files - Multipart: 10 MB parts, abort on failure, XML completion - Works with AWS S3, Wasabi, Backblaze B2, MinIO (custom endpoints) - Profile form fields: endpoint, region, access key, secret key, bucket, path - showon conditional visibility (only shown when remote_storage=s3) - Akeeba importer maps S3 credentials from Akeeba profiles - Added to BackupEngine createUploader() factory Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 6 + src/packages/com_mokobackup/forms/profile.xml | 53 +++ .../language/en-GB/com_mokobackup.ini | 20 + .../com_mokobackup/sql/install.mysql.sql | 9 +- .../src/Controller/BackupsController.php | 52 +++ .../src/Engine/AkeebaImporter.php | 8 +- .../src/Engine/BackupEngine.php | 6 +- .../com_mokobackup/src/Engine/S3Uploader.php | 441 ++++++++++++++++++ .../src/View/Backups/HtmlView.php | 1 + .../com_mokobackup/tmpl/profile/edit.php | 1 + 10 files changed, 594 insertions(+), 3 deletions(-) create mode 100644 src/packages/com_mokobackup/src/Engine/S3Uploader.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d483db..0705611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ - "Include Restore Script" toggle per profile — wraps backup with restore.php + site-backup.zip - FileRestorer class with protected file handling (preserves configuration.php, .htaccess) - DatabaseImporter with streaming line-by-line SQL execution and error tolerance +- SHA-256 checksum computed and stored after archive creation (#15) +- "Verify Integrity" toolbar button re-computes hash and compares against stored checksum +- S3-compatible remote storage: AWS S3, Wasabi, Backblaze B2, MinIO (#16) +- S3 uploader with AWS Signature V4, single PUT for files <= 100 MB, multipart for larger +- S3 fields in profile form with showon conditional visibility +- Akeeba importer now maps S3 credentials from Akeeba profiles - Email notifications on backup success/failure via Joomla mailer (#14) - Per-profile notification settings: recipient emails, notify on success/failure - Failure emails include last 30 lines of backup log for debugging diff --git a/src/packages/com_mokobackup/forms/profile.xml b/src/packages/com_mokobackup/forms/profile.xml index e187491..371e6c9 100644 --- a/src/packages/com_mokobackup/forms/profile.xml +++ b/src/packages/com_mokobackup/forms/profile.xml @@ -144,6 +144,7 @@ + + +
+ + + + + + +
diff --git a/src/packages/com_mokobackup/language/en-GB/com_mokobackup.ini b/src/packages/com_mokobackup/language/en-GB/com_mokobackup.ini index f54c576..187a731 100644 --- a/src/packages/com_mokobackup/language/en-GB/com_mokobackup.ini +++ b/src/packages/com_mokobackup/language/en-GB/com_mokobackup.ini @@ -162,6 +162,26 @@ COM_MOKOBACKUP_FIELD_NOTIFY_SUCCESS_DESC="Send an email when a backup completes COM_MOKOBACKUP_FIELD_NOTIFY_FAILURE="Notify on Failure" COM_MOKOBACKUP_FIELD_NOTIFY_FAILURE_DESC="Send an email when a backup fails. Includes log excerpt for debugging." +; Integrity verification +COM_MOKOBACKUP_TOOLBAR_VERIFY="Verify Integrity" +COM_MOKOBACKUP_VERIFY_OK="Archive integrity verified — SHA-256 checksum matches." +COM_MOKOBACKUP_VERIFY_FAILED="INTEGRITY CHECK FAILED — archive has been modified or corrupted since backup." +COM_MOKOBACKUP_VERIFY_NO_CHECKSUM="No checksum stored for this backup. Only backups created after this update can be verified." + +; S3 storage +COM_MOKOBACKUP_REMOTE_S3="Amazon S3 / S3-Compatible" +COM_MOKOBACKUP_FIELDSET_S3="S3 Storage Settings" +COM_MOKOBACKUP_FIELD_S3_ENDPOINT="S3 Endpoint" +COM_MOKOBACKUP_FIELD_S3_ENDPOINT_DESC="S3 API endpoint URL. Leave blank for AWS S3. For Wasabi, MinIO, Backblaze B2, enter their endpoint URL." +COM_MOKOBACKUP_FIELD_S3_REGION="Region" +COM_MOKOBACKUP_FIELD_S3_REGION_DESC="AWS region (e.g. us-east-1, eu-west-1). Required for AWS Signature V4." +COM_MOKOBACKUP_FIELD_S3_ACCESS_KEY="Access Key" +COM_MOKOBACKUP_FIELD_S3_SECRET_KEY="Secret Key" +COM_MOKOBACKUP_FIELD_S3_BUCKET="Bucket Name" +COM_MOKOBACKUP_FIELD_S3_BUCKET_DESC="S3 bucket name where backups will be stored." +COM_MOKOBACKUP_FIELD_S3_PATH="Path Prefix" +COM_MOKOBACKUP_FIELD_S3_PATH_DESC="Optional path prefix inside the bucket (e.g. /backups or /sites/mysite)." + ; Akeeba Import COM_MOKOBACKUP_TOOLBAR_IMPORT_AKEEBA="Import from Akeeba" COM_MOKOBACKUP_AKEEBA_NOT_FOUND="Akeeba Backup tables not found. Is Akeeba Backup Pro installed?" diff --git a/src/packages/com_mokobackup/sql/install.mysql.sql b/src/packages/com_mokobackup/sql/install.mysql.sql index 1f2e66f..aaad328 100644 --- a/src/packages/com_mokobackup/sql/install.mysql.sql +++ b/src/packages/com_mokobackup/sql/install.mysql.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` ( `exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude', `exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude', `exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude', - `remote_storage` VARCHAR(20) NOT NULL DEFAULT 'none' COMMENT 'none, ftp, google_drive', + `remote_storage` VARCHAR(20) NOT NULL DEFAULT 'none' COMMENT 'none, ftp, google_drive, s3', `ftp_host` VARCHAR(255) NOT NULL DEFAULT '', `ftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 21, `ftp_username` VARCHAR(255) NOT NULL DEFAULT '', @@ -22,6 +22,12 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` ( `gdrive_client_secret` VARCHAR(255) NOT NULL DEFAULT '', `gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '', `gdrive_folder_id` VARCHAR(255) NOT NULL DEFAULT '', + `s3_endpoint` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'S3 endpoint URL (blank = AWS default)', + `s3_region` VARCHAR(50) NOT NULL DEFAULT 'us-east-1', + `s3_access_key` VARCHAR(255) NOT NULL DEFAULT '', + `s3_secret_key` VARCHAR(255) NOT NULL DEFAULT '', + `s3_bucket` VARCHAR(255) NOT NULL DEFAULT '', + `s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups', `remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload', `include_kickstart` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include standalone restore.php in archive', `notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails', @@ -54,6 +60,7 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_records` ( `backupend` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', `filesexist` TINYINT(1) NOT NULL DEFAULT 1, `remote_filename` VARCHAR(512) NOT NULL DEFAULT '', + `checksum` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'SHA-256 hash of archive', `log` MEDIUMTEXT NOT NULL COMMENT 'Step-by-step backup log', PRIMARY KEY (`id`), KEY `idx_profile` (`profile_id`), diff --git a/src/packages/com_mokobackup/src/Controller/BackupsController.php b/src/packages/com_mokobackup/src/Controller/BackupsController.php index d74ae2c..ad2acaf 100644 --- a/src/packages/com_mokobackup/src/Controller/BackupsController.php +++ b/src/packages/com_mokobackup/src/Controller/BackupsController.php @@ -113,4 +113,56 @@ class BackupsController extends AdminController $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); } + + /** + * Verify integrity of a backup archive by re-computing SHA-256. + */ + public function verify(): void + { + $this->checkToken(); + + $cid = $this->input->get('cid', [], 'array'); + $id = !empty($cid) ? (int) $cid[0] : $this->input->getInt('id', 0); + + if (!$id) { + $this->setMessage('COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED', 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + + return; + } + + $model = $this->getModel('Backup'); + $item = $model->getItem($id); + + if (!$item || !$item->id) { + $this->setMessage('COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED', 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + + return; + } + + if (!is_file($item->absolute_path)) { + $this->setMessage('COM_MOKOBACKUP_ERROR_FILE_NOT_FOUND', 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + + return; + } + + if (empty($item->checksum)) { + $this->setMessage('COM_MOKOBACKUP_VERIFY_NO_CHECKSUM', 'warning'); + $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + + return; + } + + $currentHash = hash_file('sha256', $item->absolute_path); + + if ($currentHash === $item->checksum) { + $this->setMessage('COM_MOKOBACKUP_VERIFY_OK'); + } else { + $this->setMessage('COM_MOKOBACKUP_VERIFY_FAILED', 'error'); + } + + $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + } } diff --git a/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php b/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php index 8b1d0cc..cafa191 100644 --- a/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php +++ b/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php @@ -239,6 +239,12 @@ class AkeebaImporter 'gdrive_client_secret' => $config['engine.postproc.googledrive.client_secret'] ?? '', 'gdrive_refresh_token' => $config['engine.postproc.googledrive.refresh_token'] ?? '', 'gdrive_folder_id' => $config['engine.postproc.googledrive.directory'] ?? '', + 's3_endpoint' => $config['engine.postproc.s3.custom_endpoint'] ?? '', + 's3_region' => $config['engine.postproc.s3.region'] ?? 'us-east-1', + 's3_access_key' => $config['engine.postproc.s3.access_key'] ?? ($config['engine.postproc.s3.accesskey'] ?? ''), + 's3_secret_key' => $config['engine.postproc.s3.secret_key'] ?? ($config['engine.postproc.s3.secretkey'] ?? ''), + 's3_bucket' => $config['engine.postproc.s3.bucket'] ?? '', + 's3_path' => $config['engine.postproc.s3.directory'] ?? '/backups', 'remote_keep_local' => 1, 'include_kickstart' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'), 'published' => 1, @@ -496,7 +502,7 @@ class AkeebaImporter return match ($engine) { 'ftp', 'ftpcurl', 'sftp', 'sftpcurl' => 'ftp', 'googledrive' => 'google_drive', - 's3', 'amazons3' => 'none', // S3 not yet supported + 's3', 'amazons3' => 's3', 'dropbox', 'onedrive', 'box' => 'none', // not yet supported default => 'none', }; diff --git a/src/packages/com_mokobackup/src/Engine/BackupEngine.php b/src/packages/com_mokobackup/src/Engine/BackupEngine.php index 2ef5a65..60ce135 100644 --- a/src/packages/com_mokobackup/src/Engine/BackupEngine.php +++ b/src/packages/com_mokobackup/src/Engine/BackupEngine.php @@ -147,10 +147,12 @@ class BackupEngine $zip->close(); - // Record archive size + // Record archive size and compute checksum $totalSize = file_exists($archivePath) ? filesize($archivePath) : 0; $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; + $checksum = is_file($archivePath) ? hash_file('sha256', $archivePath) : ''; $this->log('Archive created: ' . $sizeHuman); + $this->log('SHA-256: ' . ($checksum ?: 'N/A')); // Step 2.5: Wrap with Kickstart restore script (if enabled) $includeKickstart = (bool) ($profile->include_kickstart ?? false); @@ -205,6 +207,7 @@ class BackupEngine 'backupend' => date('Y-m-d H:i:s'), 'filesexist' => is_file($archivePath) ? 1 : 0, 'remote_filename' => $remoteFilename, + 'checksum' => $checksum, 'log' => implode("\n", $this->log), ]; @@ -325,6 +328,7 @@ class BackupEngine return match ($type) { 'ftp' => new FtpUploader($profile), 'google_drive' => new GoogleDriveUploader($profile), + 's3' => new S3Uploader($profile), default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type), }; } diff --git a/src/packages/com_mokobackup/src/Engine/S3Uploader.php b/src/packages/com_mokobackup/src/Engine/S3Uploader.php new file mode 100644 index 0000000..eeae5b4 --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/S3Uploader.php @@ -0,0 +1,441 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * S3-compatible storage uploader using AWS Signature V4. + * Works with AWS S3, Wasabi, Backblaze B2, MinIO, and any S3-compatible API. + * No SDK dependency — pure PHP with cURL. + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +class S3Uploader implements RemoteUploaderInterface +{ + private string $endpoint; + private string $region; + private string $accessKey; + private string $secretKey; + private string $bucket; + private string $path; + + private const DEFAULT_ENDPOINT = 'https://s3.amazonaws.com'; + private const SERVICE = 's3'; + + public function __construct(object $profile) + { + $this->endpoint = rtrim($profile->s3_endpoint ?? '', '/') ?: self::DEFAULT_ENDPOINT; + $this->region = $profile->s3_region ?? 'us-east-1'; + $this->accessKey = $profile->s3_access_key ?? ''; + $this->secretKey = $profile->s3_secret_key ?? ''; + $this->bucket = $profile->s3_bucket ?? ''; + $this->path = trim($profile->s3_path ?? '/backups', '/'); + } + + public function upload(string $localPath, string $remoteName): array + { + if (!extension_loaded('curl')) { + return ['success' => false, 'message' => 'PHP ext-curl is required for S3 uploads. Enable it in php.ini.']; + } + + if (empty($this->accessKey) || empty($this->secretKey) || empty($this->bucket)) { + return ['success' => false, 'message' => 'S3 credentials or bucket not configured']; + } + + if (!is_file($localPath) || !is_readable($localPath)) { + return ['success' => false, 'message' => 'Local file not readable: ' . $localPath]; + } + + try { + $objectKey = ($this->path ? $this->path . '/' : '') . $remoteName; + $fileSize = filesize($localPath); + + // For files > 100 MB use multipart upload, otherwise single PUT + if ($fileSize > 100 * 1024 * 1024) { + $this->multipartUpload($localPath, $objectKey, $fileSize); + } else { + $this->singleUpload($localPath, $objectKey); + } + + $remotePath = 's3://' . $this->bucket . '/' . $objectKey; + + return [ + 'success' => true, + 'message' => 'Uploaded to S3: ' . $remotePath, + 'remote_path' => $remotePath, + ]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'S3 upload failed: ' . $e->getMessage()]; + } + } + + public function testConnection(): array + { + if (empty($this->accessKey) || empty($this->secretKey) || empty($this->bucket)) { + return ['success' => false, 'message' => 'S3 credentials or bucket not configured']; + } + + try { + // HEAD bucket to test access + $url = $this->getBucketUrl(); + $headers = $this->signRequest('HEAD', $url, ''); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_NOBODY => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 30, + ]); + + curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code === 200 || $code === 301) { + return ['success' => true, 'message' => 'Connected to S3 bucket: ' . $this->bucket]; + } + + return ['success' => false, 'message' => 'S3 returned HTTP ' . $code . ' for bucket ' . $this->bucket]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'Connection test failed: ' . $e->getMessage()]; + } + } + + /** + * Single PUT upload for files <= 100 MB. + */ + private function singleUpload(string $localPath, string $objectKey): void + { + $url = $this->getObjectUrl($objectKey); + $fileContent = file_get_contents($localPath); + $contentHash = hash('sha256', $fileContent); + $headers = $this->signRequest('PUT', $url, $contentHash, [ + 'Content-Type' => 'application/zip', + 'Content-Length' => (string) strlen($fileContent), + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_POSTFIELDS => $fileContent, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 600, + ]); + + $response = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if (curl_errno($ch)) { + $error = curl_error($ch); + curl_close($ch); + + throw new \RuntimeException('cURL error: ' . $error); + } + + curl_close($ch); + + if ($code < 200 || $code >= 300) { + throw new \RuntimeException('S3 PUT failed (HTTP ' . $code . '): ' . substr($response, 0, 500)); + } + } + + /** + * Multipart upload for files > 100 MB. + */ + private function multipartUpload(string $localPath, string $objectKey, int $fileSize): void + { + $chunkSize = 10 * 1024 * 1024; // 10 MB parts + + // Step 1: Initiate multipart upload + $uploadId = $this->initiateMultipart($objectKey); + + $handle = fopen($localPath, 'rb'); + + if ($handle === false) { + throw new \RuntimeException('Cannot open file: ' . $localPath); + } + + $parts = []; + $partNum = 1; + + try { + // Step 2: Upload parts + while (!feof($handle)) { + $chunk = fread($handle, $chunkSize); + + if ($chunk === false || $chunk === '') { + break; + } + + $etag = $this->uploadPart($objectKey, $uploadId, $partNum, $chunk); + $parts[] = ['PartNumber' => $partNum, 'ETag' => $etag]; + $partNum++; + } + + fclose($handle); + + // Step 3: Complete multipart upload + $this->completeMultipart($objectKey, $uploadId, $parts); + } catch (\Throwable $e) { + fclose($handle); + // Abort the multipart upload on failure + $this->abortMultipart($objectKey, $uploadId); + + throw $e; + } + } + + private function initiateMultipart(string $objectKey): string + { + $url = $this->getObjectUrl($objectKey) . '?uploads'; + $contentHash = hash('sha256', ''); + $headers = $this->signRequest('POST', $url, $contentHash, [ + 'Content-Type' => 'application/zip', + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => '', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 60, + ]); + + $response = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code < 200 || $code >= 300) { + throw new \RuntimeException('Initiate multipart failed (HTTP ' . $code . ')'); + } + + // Parse UploadId from XML response + preg_match('/([^<]+)<\/UploadId>/', $response, $matches); + + if (empty($matches[1])) { + throw new \RuntimeException('No UploadId in initiate response'); + } + + return $matches[1]; + } + + private function uploadPart(string $objectKey, string $uploadId, int $partNumber, string $data): string + { + $url = $this->getObjectUrl($objectKey) . '?partNumber=' . $partNumber . '&uploadId=' . urlencode($uploadId); + $contentHash = hash('sha256', $data); + $headers = $this->signRequest('PUT', $url, $contentHash, [ + 'Content-Length' => (string) strlen($data), + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_POSTFIELDS => $data, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$etag) { + if (stripos($header, 'ETag:') === 0) { + $etag = trim(substr($header, 5)); + } + + return strlen($header); + }, + CURLOPT_TIMEOUT => 300, + ]); + + $etag = ''; + curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code < 200 || $code >= 300) { + throw new \RuntimeException('Upload part ' . $partNumber . ' failed (HTTP ' . $code . ')'); + } + + return $etag; + } + + private function completeMultipart(string $objectKey, string $uploadId, array $parts): void + { + $xml = ''; + + foreach ($parts as $part) { + $xml .= '' + . '' . $part['PartNumber'] . '' + . '' . $part['ETag'] . '' + . ''; + } + + $xml .= ''; + + $url = $this->getObjectUrl($objectKey) . '?uploadId=' . urlencode($uploadId); + $contentHash = hash('sha256', $xml); + $headers = $this->signRequest('POST', $url, $contentHash, [ + 'Content-Type' => 'application/xml', + 'Content-Length' => (string) strlen($xml), + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $xml, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 60, + ]); + + $response = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code < 200 || $code >= 300) { + throw new \RuntimeException('Complete multipart failed (HTTP ' . $code . '): ' . substr($response, 0, 500)); + } + } + + private function abortMultipart(string $objectKey, string $uploadId): void + { + $url = $this->getObjectUrl($objectKey) . '?uploadId=' . urlencode($uploadId); + $contentHash = hash('sha256', ''); + $headers = $this->signRequest('DELETE', $url, $contentHash); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 30, + ]); + + curl_exec($ch); + curl_close($ch); + } + + // ── AWS Signature V4 ─────────────────────────────────────────── + + /** + * Sign a request using AWS Signature V4. + * + * @return string[] Array of HTTP headers ready for cURL + */ + private function signRequest(string $method, string $url, string $payloadHash, array $extraHeaders = []): array + { + $parsed = parse_url($url); + $host = $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : ''); + $path = $parsed['path'] ?? '/'; + $query = $parsed['query'] ?? ''; + + $now = gmdate('Ymd\THis\Z'); + $dateOnly = gmdate('Ymd'); + $scope = $dateOnly . '/' . $this->region . '/' . self::SERVICE . '/aws4_request'; + + // Canonical headers + $headers = array_merge([ + 'host' => $host, + 'x-amz-content-sha256' => $payloadHash, + 'x-amz-date' => $now, + ], array_change_key_case($extraHeaders, CASE_LOWER)); + + ksort($headers); + + $signedHeaders = implode(';', array_keys($headers)); + $canonicalHeaders = ''; + + foreach ($headers as $k => $v) { + $canonicalHeaders .= $k . ':' . trim($v) . "\n"; + } + + // Canonical query string (sorted) + $queryParts = []; + + if (!empty($query)) { + parse_str($query, $qp); + ksort($qp); + + foreach ($qp as $k => $v) { + $queryParts[] = rawurlencode($k) . '=' . rawurlencode($v); + } + } + + $canonicalQuery = implode('&', $queryParts); + + // Canonical request + $canonicalRequest = implode("\n", [ + $method, + $path, + $canonicalQuery, + $canonicalHeaders, + $signedHeaders, + $payloadHash, + ]); + + // String to sign + $stringToSign = implode("\n", [ + 'AWS4-HMAC-SHA256', + $now, + $scope, + hash('sha256', $canonicalRequest), + ]); + + // Signing key + $kDate = hash_hmac('sha256', $dateOnly, 'AWS4' . $this->secretKey, true); + $kRegion = hash_hmac('sha256', $this->region, $kDate, true); + $kService = hash_hmac('sha256', self::SERVICE, $kRegion, true); + $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true); + + $signature = hash_hmac('sha256', $stringToSign, $kSigning); + + // Authorization header + $authHeader = 'AWS4-HMAC-SHA256 ' + . 'Credential=' . $this->accessKey . '/' . $scope . ', ' + . 'SignedHeaders=' . $signedHeaders . ', ' + . 'Signature=' . $signature; + + // Build cURL header array + $curlHeaders = [ + 'Authorization: ' . $authHeader, + 'x-amz-content-sha256: ' . $payloadHash, + 'x-amz-date: ' . $now, + ]; + + foreach ($extraHeaders as $k => $v) { + $curlHeaders[] = $k . ': ' . $v; + } + + return $curlHeaders; + } + + private function getBucketUrl(): string + { + if ($this->endpoint === self::DEFAULT_ENDPOINT) { + return 'https://' . $this->bucket . '.s3.' . $this->region . '.amazonaws.com/'; + } + + // Path-style for custom endpoints (MinIO, Wasabi, B2) + return rtrim($this->endpoint, '/') . '/' . $this->bucket . '/'; + } + + private function getObjectUrl(string $objectKey): string + { + if ($this->endpoint === self::DEFAULT_ENDPOINT) { + return 'https://' . $this->bucket . '.s3.' . $this->region . '.amazonaws.com/' . ltrim($objectKey, '/'); + } + + return rtrim($this->endpoint, '/') . '/' . $this->bucket . '/' . ltrim($objectKey, '/'); + } +} diff --git a/src/packages/com_mokobackup/src/View/Backups/HtmlView.php b/src/packages/com_mokobackup/src/View/Backups/HtmlView.php index d69fef5..1dde363 100644 --- a/src/packages/com_mokobackup/src/View/Backups/HtmlView.php +++ b/src/packages/com_mokobackup/src/View/Backups/HtmlView.php @@ -54,6 +54,7 @@ class HtmlView extends BaseHtmlView ToolbarHelper::title(Text::_('COM_MOKOBACKUP_BACKUPS_TITLE'), 'database'); ToolbarHelper::custom('backups.start', 'download', '', 'COM_MOKOBACKUP_TOOLBAR_BACKUP_NOW', false); ToolbarHelper::custom('backups.restore', 'upload', '', 'COM_MOKOBACKUP_TOOLBAR_RESTORE', true); + ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOBACKUP_TOOLBAR_VERIFY', true); ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete'); ToolbarHelper::preferences('com_mokobackup'); } diff --git a/src/packages/com_mokobackup/tmpl/profile/edit.php b/src/packages/com_mokobackup/tmpl/profile/edit.php index aa73556..d208ed9 100644 --- a/src/packages/com_mokobackup/tmpl/profile/edit.php +++ b/src/packages/com_mokobackup/tmpl/profile/edit.php @@ -64,6 +64,7 @@ HTMLHelper::_('behavior.keepalive'); form->renderFieldset('remote'); ?> form->renderFieldset('ftp'); ?> form->renderFieldset('google_drive'); ?> + form->renderFieldset('s3'); ?>