* @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); $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, '/'); } }