|
|
@@ -4,10 +4,14 @@
|
|
|
* 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 PUT → the webhost streams uploaded originals/thumbs to S3
|
|
|
* - Signed DELETE → server-side cleanup when images/galleries are removed
|
|
|
*
|
|
|
- * Uses path-style URLs: https://<endpoint>/<bucket>/<key>
|
|
|
+ * Addressing style is configurable via s3.path_style:
|
|
|
+ * path-style (default) → https://<endpoint-host>/<bucket>/<key>
|
|
|
+ * virtual-hosted-style → https://<bucket>.<endpoint-host>/<key>
|
|
|
+ * Hetzner Object Storage serves path-style reliably; virtual-hosted-style
|
|
|
+ * requires the bucket to resolve as a TLS subdomain of the endpoint.
|
|
|
*/
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
@@ -18,9 +22,64 @@ function s3_encode_key(string $key): string
|
|
|
return implode('/', array_map('rawurlencode', explode('/', $key)));
|
|
|
}
|
|
|
|
|
|
+/** Bare host of the configured endpoint, e.g. "fsn1.your-objectstorage.com". */
|
|
|
+function s3_endpoint_host(): string
|
|
|
+{
|
|
|
+ return (string)parse_url(config('s3.endpoint'), PHP_URL_HOST);
|
|
|
+}
|
|
|
+
|
|
|
+/** ":port" suffix when the endpoint pins a non-default port, else "". */
|
|
|
+function s3_endpoint_port_suffix(): string
|
|
|
+{
|
|
|
+ $port = parse_url(config('s3.endpoint'), PHP_URL_PORT);
|
|
|
+ return $port ? ':' . $port : '';
|
|
|
+}
|
|
|
+
|
|
|
+/** Whether to address the bucket in the path (true) or as a subdomain (false). */
|
|
|
+function s3_use_path_style(): bool
|
|
|
+{
|
|
|
+ return (bool)config('s3.path_style', true);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Request host. Path-style keeps the bare endpoint host; virtual-hosted-style
|
|
|
+ * prepends the bucket as a DNS label (never percent-encoded).
|
|
|
+ */
|
|
|
function s3_host(): string
|
|
|
{
|
|
|
- return parse_url(config('s3.endpoint'), PHP_URL_HOST);
|
|
|
+ $host = s3_endpoint_host();
|
|
|
+ $host = s3_use_path_style() ? $host : config('s3.bucket') . '.' . $host;
|
|
|
+ // The signed Host header and the request host must match, port included.
|
|
|
+ return $host . s3_endpoint_port_suffix();
|
|
|
+}
|
|
|
+
|
|
|
+/** Base URL for object requests: scheme + host, no trailing slash. */
|
|
|
+function s3_base_url(): string
|
|
|
+{
|
|
|
+ $scheme = parse_url(config('s3.endpoint'), PHP_URL_SCHEME) ?: 'https';
|
|
|
+ return $scheme . '://' . s3_host();
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Canonical (and actual) request path for a key. Path-style prefixes the
|
|
|
+ * bucket as the first, percent-encoded path segment; virtual-hosted-style
|
|
|
+ * does not, because the bucket lives in the host instead.
|
|
|
+ */
|
|
|
+function s3_canonical_uri(string $key): string
|
|
|
+{
|
|
|
+ $path = '/' . s3_encode_key($key);
|
|
|
+ return s3_use_path_style() ? '/' . rawurlencode(config('s3.bucket')) . $path : $path;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Key prefix under which one gallery's objects live: "<prefix>/<slug>".
|
|
|
+ * The prefix is configurable via s3.prefix (default "galleries"); an empty
|
|
|
+ * prefix puts galleries at the bucket root.
|
|
|
+ */
|
|
|
+function s3_gallery_prefix(string $slug): string
|
|
|
+{
|
|
|
+ $prefix = trim((string)config('s3.prefix', 'galleries'), '/');
|
|
|
+ return $prefix !== '' ? "$prefix/$slug" : $slug;
|
|
|
}
|
|
|
|
|
|
/** HMAC-SHA256 chain producing the SigV4 signing key. */
|
|
|
@@ -91,14 +150,14 @@ function s3_presign_query(
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 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.
|
|
|
+ * Build a presigned URL for an object key. Only the Host header is signed.
|
|
|
+ * Used for GET so visitors' browsers can load private images directly; uploads
|
|
|
+ * go through the webhost (s3_put_file), never a presigned 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);
|
|
|
+ $canonicalUri = s3_canonical_uri($key);
|
|
|
$query = s3_presign_query(
|
|
|
$method,
|
|
|
s3_host(),
|
|
|
@@ -109,7 +168,7 @@ function s3_presign(string $method, string $key, ?int $ttl = null): string
|
|
|
$ttl,
|
|
|
gmdate('Ymd\THis\Z')
|
|
|
);
|
|
|
- return config('s3.endpoint') . $canonicalUri . '?' . $query;
|
|
|
+ return s3_base_url() . $canonicalUri . '?' . $query;
|
|
|
}
|
|
|
|
|
|
function s3_presign_get(string $key, ?int $ttl = null): string
|
|
|
@@ -117,9 +176,70 @@ 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
|
|
|
+/**
|
|
|
+ * Stream a local file to S3 with a signed PUT (header auth). The payload is sent
|
|
|
+ * as UNSIGNED-PAYLOAD so the body is never hashed or buffered into memory — curl
|
|
|
+ * streams it straight from the file handle, letting the webhost proxy originals
|
|
|
+ * far larger than memory_limit. Content-Type is sent but not signed.
|
|
|
+ * Returns [httpStatus, responseBody].
|
|
|
+ */
|
|
|
+function s3_put_file(string $key, string $filePath, string $contentType = 'application/octet-stream'): array
|
|
|
{
|
|
|
- return s3_presign('PUT', $key, $ttl);
|
|
|
+ $host = s3_host();
|
|
|
+ $amzDate = gmdate('Ymd\THis\Z');
|
|
|
+ $date = substr($amzDate, 0, 8);
|
|
|
+ $scope = $date . '/' . config('s3.region') . '/s3/aws4_request';
|
|
|
+ $canonicalUri = s3_canonical_uri($key);
|
|
|
+ $payloadHash = 'UNSIGNED-PAYLOAD';
|
|
|
+
|
|
|
+ $canonicalRequest = implode("\n", [
|
|
|
+ 'PUT',
|
|
|
+ $canonicalUri,
|
|
|
+ '',
|
|
|
+ '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;
|
|
|
+
|
|
|
+ $fh = fopen($filePath, 'rb');
|
|
|
+ if ($fh === false) {
|
|
|
+ return [0, 'Cannot open upload for reading'];
|
|
|
+ }
|
|
|
+ $ch = curl_init(s3_base_url() . $canonicalUri);
|
|
|
+ curl_setopt_array($ch, [
|
|
|
+ CURLOPT_UPLOAD => true, // sets method to PUT and streams CURLOPT_INFILE
|
|
|
+ CURLOPT_INFILE => $fh,
|
|
|
+ CURLOPT_INFILESIZE => filesize($filePath),
|
|
|
+ CURLOPT_RETURNTRANSFER => true,
|
|
|
+ CURLOPT_CONNECTTIMEOUT => 30,
|
|
|
+ CURLOPT_TIMEOUT => 0, // no cap: originals can be large
|
|
|
+ CURLOPT_HTTPHEADER => [
|
|
|
+ 'Authorization: ' . $authorization,
|
|
|
+ 'x-amz-content-sha256: ' . $payloadHash,
|
|
|
+ 'x-amz-date: ' . $amzDate,
|
|
|
+ 'Content-Type: ' . $contentType,
|
|
|
+ 'Expect:', // skip 100-continue round-trip
|
|
|
+ ],
|
|
|
+ ]);
|
|
|
+ $body = curl_exec($ch);
|
|
|
+ $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
|
+ fclose($fh);
|
|
|
+ return [$status, (string)$body];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -132,7 +252,7 @@ function s3_request(string $method, string $key): array
|
|
|
$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);
|
|
|
+ $canonicalUri = s3_canonical_uri($key);
|
|
|
$payloadHash = hash('sha256', '');
|
|
|
|
|
|
$canonicalRequest = implode("\n", [
|
|
|
@@ -159,7 +279,7 @@ function s3_request(string $method, string $key): array
|
|
|
. ', SignedHeaders=host;x-amz-content-sha256;x-amz-date'
|
|
|
. ', Signature=' . $signature;
|
|
|
|
|
|
- $ch = curl_init(config('s3.endpoint') . $canonicalUri);
|
|
|
+ $ch = curl_init(s3_base_url() . $canonicalUri);
|
|
|
curl_setopt_array($ch, [
|
|
|
CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
|
@@ -172,7 +292,6 @@ function s3_request(string $method, string $key): array
|
|
|
]);
|
|
|
$body = curl_exec($ch);
|
|
|
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
|
- curl_close($ch);
|
|
|
return [$status, (string)$body];
|
|
|
}
|
|
|
|