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'); ?>