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
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
453 lines
13 KiB
PHP
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, '/');
|
|
}
|
|
}
|