// * virtual-hosted-style → https://./ * 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: "/". * 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; } /** * Canonical query string: keys sorted, both sides percent-encoded. The same * string goes into the signature and onto the wire, so the two cannot drift. */ function s3_canonical_query(array $query): string { ksort($query); $parts = []; foreach ($query as $name => $value) { $parts[] = rawurlencode((string)$name) . '=' . rawurlencode((string)$value); } return implode('&', $parts); } /** Full request URL for a key, with an optional canonical query string. */ function s3_url(string $key, array $query = []): string { $canonicalQuery = s3_canonical_query($query); return s3_base_url() . s3_canonical_uri($key) . ($canonicalQuery !== '' ? '?' . $canonicalQuery : ''); } /** * SigV4 header authentication — the shared core behind every server-side * request (PUT, DELETE, GET, and the multipart calls). Presigning stays * separate (s3_presign_query) because it carries the signature in the query * string instead, and is verified against the official AWS example vectors. * * $signable are extra headers to both sign and send, e.g. the Content-Type and * Content-Disposition that a multipart create stores on the finished object. * Host, x-amz-content-sha256 and x-amz-date are always included. * * Returns the header lines for CURLOPT_HTTPHEADER. */ function s3_auth_headers( string $method, string $canonicalUri, string $canonicalQuery, string $payloadHash, array $signable = [] ): array { $amzDate = gmdate('Ymd\THis\Z'); $date = substr($amzDate, 0, 8); $scope = $date . '/' . config('s3.region') . '/s3/aws4_request'; // Signed headers must be lowercase and sorted; values trimmed. $headers = array_change_key_case($signable, CASE_LOWER); $headers['host'] = s3_host(); $headers['x-amz-content-sha256'] = $payloadHash; $headers['x-amz-date'] = $amzDate; ksort($headers); $canonicalHeaders = ''; foreach ($headers as $name => $value) { $canonicalHeaders .= $name . ':' . trim((string)$value) . "\n"; } $signedHeaders = implode(';', array_keys($headers)); // $canonicalHeaders already ends in "\n", so implode's separator supplies // the blank line the canonical request format requires after it. $canonicalRequest = implode("\n", [ strtoupper($method), $canonicalUri, $canonicalQuery, $canonicalHeaders, $signedHeaders, $payloadHash, ]); $stringToSign = implode("\n", [ 'AWS4-HMAC-SHA256', $amzDate, $scope, hash('sha256', $canonicalRequest), ]); $signature = hash_hmac('sha256', $stringToSign, s3_signing_key($date)); $lines = ['Authorization: AWS4-HMAC-SHA256 Credential=' . config('s3.access_key') . '/' . $scope . ', SignedHeaders=' . $signedHeaders . ', Signature=' . $signature]; foreach ($headers as $name => $value) { if ($name !== 'host') { // curl derives Host from the URL itself $lines[] = $name . ': ' . $value; } } return $lines; } /** * Collect response headers into $into (lowercased names) as curl receives them. * Used for the ETag a multipart part upload returns. */ function s3_header_collector(array &$into): callable { return function ($ch, string $line) use (&$into): int { $parts = explode(':', $line, 2); if (count($parts) === 2) { $into[strtolower(trim($parts[0]))] = trim($parts[1]); } return strlen($line); }; } /** * 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. * * $query lets a multipart part upload reuse this exact streaming path * (?partNumber=N&uploadId=…); $contentType is skipped when empty, because a * part carries no type of its own. * * Returns [httpStatus, responseBody, responseHeaders]. * * @param resource $fh */ function s3_put_stream(string $key, $fh, int $size, string $contentType, array $query = []): array { $canonicalUri = s3_canonical_uri($key); $canonicalQuery = s3_canonical_query($query); $payloadHash = 'UNSIGNED-PAYLOAD'; // Content-Type is sent but deliberately not signed, matching how uploads // have always been signed here. $headers = s3_auth_headers('PUT', $canonicalUri, $canonicalQuery, $payloadHash); if ($contentType !== '') { $headers[] = 'Content-Type: ' . $contentType; } $headers[] = 'Expect:'; // skip 100-continue round-trip $responseHeaders = []; $ch = s3_curl(); curl_setopt_array($ch, [ CURLOPT_URL => s3_url($key, $query), 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_HEADERFUNCTION => s3_header_collector($responseHeaders), CURLOPT_HTTPHEADER => $headers, ]); $body = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); return [$status, (string)$body, $responseHeaders]; } /** * Server-side signed request with a small (or empty) body: DELETE, HEAD, and * the multipart create/complete/abort calls. * * The body is hashed in full rather than sent as UNSIGNED-PAYLOAD, which S3 * requires for the multipart XML calls; it is only ever a few hundred bytes. * $signable adds headers that must be signed as well as sent. * * Returns [httpStatus, responseBody, responseHeaders]. */ function s3_request(string $method, string $key, array $query = [], string $body = '', array $signable = []): array { $canonicalUri = s3_canonical_uri($key); $canonicalQuery = s3_canonical_query($query); $payloadHash = hash('sha256', $body); $headers = s3_auth_headers($method, $canonicalUri, $canonicalQuery, $payloadHash, $signable); if ($body !== '') { $headers[] = 'Content-Type: application/xml'; } $responseHeaders = []; $ch = s3_curl(); curl_setopt_array($ch, [ CURLOPT_URL => s3_url($key, $query), CURLOPT_CUSTOMREQUEST => strtoupper($method), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_NOBODY => strtoupper($method) === 'HEAD', CURLOPT_HEADERFUNCTION => s3_header_collector($responseHeaders), CURLOPT_HTTPHEADER => $headers, ] + ($body !== '' ? [CURLOPT_POSTFIELDS => $body] : [])); $response = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); return [$status, (string)$response, $responseHeaders]; } /** 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; } /** Object metadata, or null if it does not exist. */ function s3_head(string $key): ?array { [$status, , $headers] = s3_request('HEAD', $key); return $status === 200 ? $headers : null; } // --------------------------------------------------------------------------- // Downloading and multipart uploading — used by the gallery archive builder // (app/archive.php) to move objects back out of S3 and into a ZIP. // --------------------------------------------------------------------------- /** * GET an object, handing each chunk to $onChunk as it arrives. Nothing is * buffered, so an object far larger than memory_limit streams through fine. * * $onChunk is called only once the response is known to be a success — an error * response body is XML, and feeding that to the caller would silently corrupt * whatever it is writing. * * One attempt only: a caller that has already written part of the object * somewhere has to undo that itself before retrying, so the retry decision * belongs to it. Returns [httpStatus, bytesDelivered]. */ function s3_get_stream(string $key, callable $onChunk): array { $bytes = 0; $ch = s3_curl(); curl_setopt_array($ch, [ CURLOPT_URL => s3_url($key), CURLOPT_HTTPGET => true, CURLOPT_CONNECTTIMEOUT => 30, CURLOPT_TIMEOUT => 0, // no cap: originals can be large CURLOPT_LOW_SPEED_LIMIT => 1024, // give up on a stalled socket CURLOPT_LOW_SPEED_TIME => 120, CURLOPT_TCP_NODELAY => true, CURLOPT_HTTPHEADER => s3_auth_headers('GET', s3_canonical_uri($key), '', hash('sha256', '')), CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use ($onChunk, &$bytes): int { $length = strlen($chunk); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); if ($status >= 200 && $status < 300) { $onChunk($chunk); $bytes += $length; } return $length; // consume the error body too, or curl aborts }, ]); curl_exec($ch); return [(int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $bytes]; } /** * Begin a multipart upload. Content-Type and Content-Disposition are signed and * stored here, on the create — S3 keeps them as the finished object's metadata * and returns them on GET, which is what makes the presigned archive URL * download as a named .zip without any extra signing machinery. * * Returns the upload id, or null if S3 refused. */ function s3_mpu_create(string $key, string $contentType, string $disposition): ?string { [$status, $body] = s3_request('POST', $key, ['uploads' => ''], '', [ 'content-type' => $contentType, 'content-disposition' => $disposition, ]); if ($status < 200 || $status >= 300) { return null; } // A regex rather than ext-simplexml: this app ships with no dependencies, // and the response is a fixed, tiny document. return preg_match('#(.*?)#s', $body, $m) ? $m[1] : null; } /** * Upload one part from a local file. Parts must be at least 5 MB except the * last one, and a part number may be re-uploaded until the upload is completed * — which is what makes an interrupted build safe to resume. * * Returns the part's ETag (quoted, as S3 sends it), or null on failure. */ function s3_mpu_upload_part(string $key, string $uploadId, int $partNumber, string $filePath, int $attempts = 3): ?string { $fh = @fopen($filePath, 'rb'); if ($fh === false) { return null; } $size = (int)filesize($filePath); $query = ['partNumber' => (string)$partNumber, 'uploadId' => $uploadId]; $etag = null; for ($try = 1; $try <= $attempts; $try++) { rewind($fh); [$status, , $headers] = s3_put_stream($key, $fh, $size, '', $query); if ($status >= 200 && $status < 300) { $etag = $headers['etag'] ?? null; break; } if (!s3_is_transient($status) || $try === $attempts) { break; } usleep(250000 * $try); // 0.25s, then 0.5s } fclose($fh); return $etag; } /** * Finish a multipart upload. $parts is [['n' => int, 'etag' => string], …] in * ascending part order. * * S3 can report failure inside a 200 response here (it streams whitespace while * assembling, then appends the real result), so the body is checked too. * Returns [ok, responseBody] — the body lets the caller distinguish a * NoSuchUpload, which means "already completed", from a real error. */ function s3_mpu_complete(string $key, string $uploadId, array $parts): array { $xml = ''; foreach ($parts as $part) { $xml .= '' . (int)$part['n'] . '' . '' . htmlspecialchars((string)$part['etag'], ENT_XML1) . ''; } $xml .= ''; [$status, $body] = s3_request('POST', $key, ['uploadId' => $uploadId], $xml); $ok = $status >= 200 && $status < 300 && !str_contains($body, ''); return [$ok, $body]; } /** * Abandon a multipart upload and release the parts S3 is storing (and billing) * for it. 404 counts as success: the upload is gone either way. */ function s3_mpu_abort(string $key, string $uploadId): bool { [$status] = s3_request('DELETE', $key, ['uploadId' => $uploadId]); return $status === 204 || $status === 200 || $status === 404; } /** Delete every S3 object referenced by a gallery (originals, thumbs, archive). */ 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']); } } if (!empty($gallery['archive']['key'])) { s3_delete($gallery['archive']['key']); } } /** 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]]; }