| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476 |
- <?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
- * - Signed PUT → the webhost streams uploaded originals/thumbs to S3
- * - Signed DELETE → server-side cleanup when images/galleries are removed
- *
- * 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);
- /** Percent-encode an object key, keeping the "/" separators. */
- 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
- {
- $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. */
- 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 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 = s3_canonical_uri($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 s3_base_url() . $canonicalUri . '?' . $query;
- }
- function s3_presign_get(string $key, ?int $ttl = null): string
- {
- return s3_presign('GET', $key, $ttl);
- }
- /**
- * One curl handle per PHP process, reused across requests to the same endpoint.
- * curl_reset() clears the options but keeps the handle's live connection, DNS
- * and TLS-session caches, so the second PUT of a request (the thumbnail) and
- * any retry skip a full TCP + TLS handshake.
- */
- function s3_curl(): CurlHandle
- {
- static $ch = null;
- if ($ch === null) {
- $ch = curl_init();
- } else {
- curl_reset($ch);
- }
- return $ch;
- }
- /**
- * Whether an S3 attempt failed in a way that is worth repeating: a curl-level
- * failure (status 0), throttling, or a server-side error. 4xx is a real
- * rejection (bad key, bad signature) and must not be retried.
- */
- function s3_is_transient(int $status): bool
- {
- return $status === 0 || $status === 408 || $status === 429 || $status >= 500;
- }
- /**
- * 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.
- *
- * Transient failures are retried up to $attempts times with a short backoff; the
- * file handle is rewound and the request re-signed for each try, so a dropped
- * connection costs one repeat instead of a failed image.
- * Returns [httpStatus, responseBody] of the last attempt.
- */
- function s3_put_file(string $key, string $filePath, string $contentType = 'application/octet-stream', int $attempts = 3): array
- {
- // Suppressed: a warning printed here would land in front of the JSON body
- // the API endpoints emit, and the failure is reported through the return.
- $fh = @fopen($filePath, 'rb');
- if ($fh === false) {
- return [0, 'Cannot open upload for reading'];
- }
- $size = (int)filesize($filePath);
- $status = 0;
- $body = '';
- for ($try = 1; $try <= $attempts; $try++) {
- rewind($fh);
- [$status, $body] = s3_put_stream($key, $fh, $size, $contentType);
- if (!s3_is_transient($status) || $try === $attempts) {
- break;
- }
- usleep(250000 * $try); // 0.25s, then 0.5s
- }
- fclose($fh);
- return [$status, $body];
- }
- /**
- * One signed PUT attempt streaming from an open, positioned file handle.
- *
- * @param resource $fh
- */
- function s3_put_stream(string $key, $fh, int $size, string $contentType): array
- {
- $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;
- $ch = s3_curl();
- curl_setopt_array($ch, [
- CURLOPT_URL => s3_base_url() . $canonicalUri,
- CURLOPT_UPLOAD => true, // sets method to PUT and streams CURLOPT_INFILE
- CURLOPT_INFILE => $fh,
- CURLOPT_INFILESIZE => $size,
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_CONNECTTIMEOUT => 30,
- CURLOPT_TIMEOUT => 0, // no cap: originals can be large
- // Abort a connection that has stalled below 1 KB/s for two minutes,
- // instead of pinning a PHP worker on a dead socket until the web
- // server kills it. A retry then gets a fresh connection.
- CURLOPT_LOW_SPEED_LIMIT => 1024,
- CURLOPT_LOW_SPEED_TIME => 120,
- CURLOPT_TCP_NODELAY => true,
- 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);
- return [$status, (string)$body];
- }
- /**
- * 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 = s3_canonical_uri($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 = s3_curl();
- curl_setopt_array($ch, [
- CURLOPT_URL => s3_base_url() . $canonicalUri,
- 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);
- 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']);
- }
- }
- }
- /** Human-readable reason for a PHP upload error code. */
- function upload_error_message(int $code): string
- {
- return match ($code) {
- UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'file exceeds the server upload size limit',
- UPLOAD_ERR_PARTIAL => 'upload was interrupted',
- UPLOAD_ERR_NO_FILE => 'no file received',
- UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE => 'server cannot store the upload',
- default => 'upload error ' . $code,
- };
- }
- /**
- * Ingest one uploaded image into a gallery: stream the original (and optional
- * browser-generated thumbnail) to S3, then append it to the gallery's JSON file.
- *
- * Shared by admin/api.php (trusted admin) and upload-api.php (public guest link).
- * The browser uploads several images at once, so the gallery entry is appended
- * through gallery_append_image(), which re-reads and rewrites the JSON file
- * under an exclusive lock — two uploads finishing together cannot drop one
- * another's entry. Object keys are generated server-side under the gallery's
- * own prefix — never taken from the client.
- *
- * $original / $thumb are $_FILES entries (or null). When $imagesOnly is true the
- * original must have a recognised image extension and decode via getimagesize(),
- * so a public link cannot be used to store arbitrary file types.
- *
- * Returns [int $httpStatus, array $payload] for the caller to hand to
- * json_response(); a thumbnail failure is non-fatal (the grid falls back to the
- * original key).
- */
- function gallery_store_s3_upload(array $gallery, ?array $original, ?array $thumb, bool $imagesOnly = false): array
- {
- if (!is_array($original) || ($original['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
- return [400, ['error' => upload_error_message((int)($original['error'] ?? UPLOAD_ERR_NO_FILE))]];
- }
- if (!is_uploaded_file((string)$original['tmp_name'])) {
- return [400, ['error' => 'Invalid upload']];
- }
- if ($imagesOnly) {
- $ext = strtolower(pathinfo((string)($original['name'] ?? ''), PATHINFO_EXTENSION));
- if (!in_array($ext, MEDIA_EXTENSIONS, true) || getimagesize((string)$original['tmp_name']) === false) {
- return [400, ['error' => 'Only image files are allowed']];
- }
- }
- $slug = $gallery['slug'];
- $name = substr(safe_filename((string)($original['name'] ?? '')), 0, 120);
- $token = random_token(6);
- $base = s3_gallery_prefix($slug);
- $key = "$base/originals/$token-$name";
- // Stream the original to S3 byte-for-byte from the PHP upload temp file.
- $type = (string)($original['type'] ?? '') ?: 'application/octet-stream';
- [$status] = s3_put_file($key, (string)$original['tmp_name'], $type);
- if ($status < 200 || $status >= 300) {
- return [502, ['error' => "S3 rejected the original (HTTP $status)"]];
- }
- // Optional browser-generated thumbnail. A thumb failure is non-fatal: the
- // original stays, and the grid falls back to the original key.
- $thumbKey = null;
- if (is_array($thumb)
- && ($thumb['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK
- && is_uploaded_file((string)$thumb['tmp_name'])
- ) {
- $candidate = "$base/thumbs/$token-$name.jpg";
- [$tstatus] = s3_put_file($candidate, (string)$thumb['tmp_name'], 'image/jpeg');
- if ($tstatus >= 200 && $tstatus < 300) {
- $thumbKey = $candidate;
- } else {
- s3_delete($candidate);
- }
- }
- // Locked read-modify-write: concurrent uploads append without clobbering.
- $count = gallery_append_image($slug, [
- 'key' => $key,
- 'thumb' => $thumbKey,
- 'name' => substr((string)($original['name'] ?? basename($key)), 0, 200),
- 'size' => (int)($original['size'] ?? 0),
- ]);
- // The gallery was deleted while this image was in flight: drop the objects
- // we just wrote rather than leaving them unreferenced in the bucket.
- if ($count === null) {
- s3_delete($key);
- if ($thumbKey !== null) {
- s3_delete($thumbKey);
- }
- return [404, ['error' => 'Gallery no longer exists']];
- }
- return [200, ['ok' => true, 'key' => $key, 'thumb' => $thumbKey, 'count' => $count]];
- }
|