Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/S3Uploader.php
T
Jonathan Miller 9990240d2d
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 5s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 22s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 49s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 5s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 46s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 2m32s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
fix: remaining audit findings — OOM, security, error handling (#81)
CRITICAL:
- #73: S3Uploader now streams file via CURLOPT_PUT/INFILE instead of
  loading entire file into RAM with file_get_contents
- #74: DatabaseDumper gains dumpToFile() that streams SQL to disk;
  BackupEngine uses addFile() instead of addFromString() to avoid
  holding the entire dump in memory
- #75: AkeebaImporter removes unserialize() — only uses json_decode,
  skips legacy serialized filter data to prevent object injection

MEDIUM (also fixed):
- BackupEngine: $archiveName initialized before try block (prevents
  undefined variable in catch)
- BackupEngine: plaintext archive deleted on encryption failure
- BackupEngine: temp SQL file cleaned up in both success and failure
- BackupEngine: createArchiver() throws on unknown format instead of
  silently falling back to ZIP
- TarGzArchiver: intermediate .tar cleaned up in finally block

Closes #73, closes #74, closes #75
Ref #81
2026-06-21 18:16:46 -05:00

453 lines
13 KiB
PHP

<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoSuiteBackup\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);
$fileSize = filesize($localPath);
// Stream file to compute SHA-256 without loading into RAM
$contentHash = hash_file('sha256', $localPath);
$headers = $this->signRequest('PUT', $url, $contentHash, [
'Content-Type' => 'application/zip',
'Content-Length' => (string) $fileSize,
]);
$fp = fopen($localPath, 'rb');
if ($fp === false) {
throw new \RuntimeException('Cannot open file for upload: ' . $localPath);
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_PUT => true,
CURLOPT_INFILE => $fp,
CURLOPT_INFILESIZE => $fileSize,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 600,
]);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
fclose($fp);
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, '/');
}
}