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
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:
@@ -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'); ?>
|
||||
|
||||
Reference in New Issue
Block a user