feat: SHA-256 checksums (#15) and S3 storage backend (#16)
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled

#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) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-06-02 18:37:20 -05:00
parent 24fd066caf
commit 74ee3ea3ab
10 changed files with 594 additions and 3 deletions
+6
View File
@@ -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
@@ -144,6 +144,7 @@
<option value="none">COM_MOKOBACKUP_REMOTE_NONE</option>
<option value="ftp">COM_MOKOBACKUP_REMOTE_FTP</option>
<option value="google_drive">COM_MOKOBACKUP_REMOTE_GDRIVE</option>
<option value="s3">COM_MOKOBACKUP_REMOTE_S3</option>
</field>
<field
name="remote_keep_local"
@@ -292,4 +293,56 @@
showon="remote_storage:google_drive"
/>
</fieldset>
<fieldset name="s3" label="COM_MOKOBACKUP_FIELDSET_S3">
<field
name="s3_endpoint"
type="text"
label="COM_MOKOBACKUP_FIELD_S3_ENDPOINT"
description="COM_MOKOBACKUP_FIELD_S3_ENDPOINT_DESC"
maxlength="512"
hint="https://s3.amazonaws.com"
showon="remote_storage:s3"
/>
<field
name="s3_region"
type="text"
label="COM_MOKOBACKUP_FIELD_S3_REGION"
description="COM_MOKOBACKUP_FIELD_S3_REGION_DESC"
default="us-east-1"
maxlength="50"
showon="remote_storage:s3"
/>
<field
name="s3_access_key"
type="text"
label="COM_MOKOBACKUP_FIELD_S3_ACCESS_KEY"
maxlength="255"
showon="remote_storage:s3"
/>
<field
name="s3_secret_key"
type="password"
label="COM_MOKOBACKUP_FIELD_S3_SECRET_KEY"
maxlength="255"
showon="remote_storage:s3"
/>
<field
name="s3_bucket"
type="text"
label="COM_MOKOBACKUP_FIELD_S3_BUCKET"
description="COM_MOKOBACKUP_FIELD_S3_BUCKET_DESC"
maxlength="255"
showon="remote_storage:s3"
/>
<field
name="s3_path"
type="text"
label="COM_MOKOBACKUP_FIELD_S3_PATH"
description="COM_MOKOBACKUP_FIELD_S3_PATH_DESC"
default="/backups"
maxlength="512"
showon="remote_storage:s3"
/>
</fieldset>
</form>
@@ -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?"
@@ -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`),
@@ -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));
}
}
@@ -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',
};
@@ -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),
};
}
@@ -0,0 +1,441 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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>([^<]+)<\/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 = '<CompleteMultipartUpload>';
foreach ($parts as $part) {
$xml .= '<Part>'
. '<PartNumber>' . $part['PartNumber'] . '</PartNumber>'
. '<ETag>' . $part['ETag'] . '</ETag>'
. '</Part>';
}
$xml .= '</CompleteMultipartUpload>';
$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, '/');
}
}
@@ -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');
}
@@ -64,6 +64,7 @@ HTMLHelper::_('behavior.keepalive');
<?php echo $this->form->renderFieldset('remote'); ?>
<?php echo $this->form->renderFieldset('ftp'); ?>
<?php echo $this->form->renderFieldset('google_drive'); ?>
<?php echo $this->form->renderFieldset('s3'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>