|
|
@@ -0,0 +1,197 @@
|
|
|
+<?php
|
|
|
+/**
|
|
|
+ * Minimal S3 client for Hetzner Object Storage (or any S3-compatible store).
|
|
|
+ * Implements AWS Signature v4 in plain PHP — no SDK, no Composer.
|
|
|
+ *
|
|
|
+ * - Presigned GET → visitors load gallery images directly from S3
|
|
|
+ * - Presigned PUT → the admin browser uploads directly to S3
|
|
|
+ * - Signed DELETE → server-side cleanup when images/galleries are removed
|
|
|
+ *
|
|
|
+ * Uses path-style URLs: https://<endpoint>/<bucket>/<key>
|
|
|
+ */
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+/** Percent-encode an object key, keeping the "/" separators. */
|
|
|
+function s3_encode_key(string $key): string
|
|
|
+{
|
|
|
+ return implode('/', array_map('rawurlencode', explode('/', $key)));
|
|
|
+}
|
|
|
+
|
|
|
+function s3_host(): string
|
|
|
+{
|
|
|
+ return parse_url(config('s3.endpoint'), PHP_URL_HOST);
|
|
|
+}
|
|
|
+
|
|
|
+/** HMAC-SHA256 chain producing the SigV4 signing key. */
|
|
|
+function s3_signing_key(string $date): string
|
|
|
+{
|
|
|
+ $k = hash_hmac('sha256', $date, 'AWS4' . config('s3.secret_key'), true);
|
|
|
+ $k = hash_hmac('sha256', config('s3.region'), $k, true);
|
|
|
+ $k = hash_hmac('sha256', 's3', $k, true);
|
|
|
+ return hash_hmac('sha256', 'aws4_request', $k, true);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * SigV4 query-string signing core. Separated from s3_presign() so the
|
|
|
+ * algorithm can be verified against the official AWS example vectors.
|
|
|
+ * Returns the full query string including X-Amz-Signature.
|
|
|
+ */
|
|
|
+function s3_presign_query(
|
|
|
+ string $method,
|
|
|
+ string $host,
|
|
|
+ string $canonicalUri,
|
|
|
+ string $accessKey,
|
|
|
+ string $secretKey,
|
|
|
+ string $region,
|
|
|
+ int $ttl,
|
|
|
+ string $amzDate
|
|
|
+): string {
|
|
|
+ $date = substr($amzDate, 0, 8);
|
|
|
+ $scope = $date . '/' . $region . '/s3/aws4_request';
|
|
|
+
|
|
|
+ $query = [
|
|
|
+ 'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256',
|
|
|
+ 'X-Amz-Credential' => $accessKey . '/' . $scope,
|
|
|
+ 'X-Amz-Date' => $amzDate,
|
|
|
+ 'X-Amz-Expires' => (string)$ttl,
|
|
|
+ 'X-Amz-SignedHeaders' => 'host',
|
|
|
+ ];
|
|
|
+ ksort($query);
|
|
|
+ $canonicalQuery = implode('&', array_map(
|
|
|
+ fn($k, $v) => rawurlencode($k) . '=' . rawurlencode($v),
|
|
|
+ array_keys($query),
|
|
|
+ $query
|
|
|
+ ));
|
|
|
+
|
|
|
+ $canonicalRequest = implode("\n", [
|
|
|
+ strtoupper($method),
|
|
|
+ $canonicalUri,
|
|
|
+ $canonicalQuery,
|
|
|
+ 'host:' . $host,
|
|
|
+ '',
|
|
|
+ 'host',
|
|
|
+ 'UNSIGNED-PAYLOAD',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $stringToSign = implode("\n", [
|
|
|
+ 'AWS4-HMAC-SHA256',
|
|
|
+ $amzDate,
|
|
|
+ $scope,
|
|
|
+ hash('sha256', $canonicalRequest),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $k = hash_hmac('sha256', $date, 'AWS4' . $secretKey, true);
|
|
|
+ $k = hash_hmac('sha256', $region, $k, true);
|
|
|
+ $k = hash_hmac('sha256', 's3', $k, true);
|
|
|
+ $k = hash_hmac('sha256', 'aws4_request', $k, true);
|
|
|
+ $signature = hash_hmac('sha256', $stringToSign, $k);
|
|
|
+
|
|
|
+ return $canonicalQuery . '&X-Amz-Signature=' . $signature;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Build a presigned URL for GET or PUT on an object key.
|
|
|
+ * Only the Host header is signed, so the browser is free to set its own
|
|
|
+ * Content-Type on PUT.
|
|
|
+ */
|
|
|
+function s3_presign(string $method, string $key, ?int $ttl = null): string
|
|
|
+{
|
|
|
+ $ttl ??= (int)config('s3.url_ttl', 3600);
|
|
|
+ $canonicalUri = '/' . rawurlencode(config('s3.bucket')) . '/' . s3_encode_key($key);
|
|
|
+ $query = s3_presign_query(
|
|
|
+ $method,
|
|
|
+ s3_host(),
|
|
|
+ $canonicalUri,
|
|
|
+ config('s3.access_key'),
|
|
|
+ config('s3.secret_key'),
|
|
|
+ config('s3.region'),
|
|
|
+ $ttl,
|
|
|
+ gmdate('Ymd\THis\Z')
|
|
|
+ );
|
|
|
+ return config('s3.endpoint') . $canonicalUri . '?' . $query;
|
|
|
+}
|
|
|
+
|
|
|
+function s3_presign_get(string $key, ?int $ttl = null): string
|
|
|
+{
|
|
|
+ return s3_presign('GET', $key, $ttl);
|
|
|
+}
|
|
|
+
|
|
|
+function s3_presign_put(string $key, int $ttl = 900): string
|
|
|
+{
|
|
|
+ return s3_presign('PUT', $key, $ttl);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Server-side signed request (header auth). Used for DELETE.
|
|
|
+ * Returns [httpStatus, responseBody].
|
|
|
+ */
|
|
|
+function s3_request(string $method, string $key): array
|
|
|
+{
|
|
|
+ $host = s3_host();
|
|
|
+ $amzDate = gmdate('Ymd\THis\Z');
|
|
|
+ $date = substr($amzDate, 0, 8);
|
|
|
+ $scope = $date . '/' . config('s3.region') . '/s3/aws4_request';
|
|
|
+ $canonicalUri = '/' . rawurlencode(config('s3.bucket')) . '/' . s3_encode_key($key);
|
|
|
+ $payloadHash = hash('sha256', '');
|
|
|
+
|
|
|
+ $canonicalRequest = implode("\n", [
|
|
|
+ strtoupper($method),
|
|
|
+ $canonicalUri,
|
|
|
+ '', // no query string
|
|
|
+ 'host:' . $host,
|
|
|
+ 'x-amz-content-sha256:' . $payloadHash,
|
|
|
+ 'x-amz-date:' . $amzDate,
|
|
|
+ '',
|
|
|
+ 'host;x-amz-content-sha256;x-amz-date',
|
|
|
+ $payloadHash,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $stringToSign = implode("\n", [
|
|
|
+ 'AWS4-HMAC-SHA256',
|
|
|
+ $amzDate,
|
|
|
+ $scope,
|
|
|
+ hash('sha256', $canonicalRequest),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $signature = hash_hmac('sha256', $stringToSign, s3_signing_key($date));
|
|
|
+ $authorization = 'AWS4-HMAC-SHA256 Credential=' . config('s3.access_key') . '/' . $scope
|
|
|
+ . ', SignedHeaders=host;x-amz-content-sha256;x-amz-date'
|
|
|
+ . ', Signature=' . $signature;
|
|
|
+
|
|
|
+ $ch = curl_init(config('s3.endpoint') . $canonicalUri);
|
|
|
+ curl_setopt_array($ch, [
|
|
|
+ CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
|
|
+ CURLOPT_RETURNTRANSFER => true,
|
|
|
+ CURLOPT_TIMEOUT => 30,
|
|
|
+ CURLOPT_HTTPHEADER => [
|
|
|
+ 'Authorization: ' . $authorization,
|
|
|
+ 'x-amz-content-sha256: ' . $payloadHash,
|
|
|
+ 'x-amz-date: ' . $amzDate,
|
|
|
+ ],
|
|
|
+ ]);
|
|
|
+ $body = curl_exec($ch);
|
|
|
+ $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
|
+ curl_close($ch);
|
|
|
+ return [$status, (string)$body];
|
|
|
+}
|
|
|
+
|
|
|
+/** Delete one object. S3 returns 204 for success and for already-gone keys. */
|
|
|
+function s3_delete(string $key): bool
|
|
|
+{
|
|
|
+ [$status] = s3_request('DELETE', $key);
|
|
|
+ return $status === 204 || $status === 200 || $status === 404;
|
|
|
+}
|
|
|
+
|
|
|
+/** Delete every S3 object referenced by a gallery (originals + thumbs). */
|
|
|
+function s3_delete_gallery_objects(array $gallery): void
|
|
|
+{
|
|
|
+ foreach ($gallery['images'] ?? [] as $img) {
|
|
|
+ if (!empty($img['key'])) {
|
|
|
+ s3_delete($img['key']);
|
|
|
+ }
|
|
|
+ if (!empty($img['thumb'])) {
|
|
|
+ s3_delete($img['thumb']);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|