// * 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); } /** * 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 { $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]; } /** * 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 = curl_init(s3_base_url() . $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); 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 gallery is re-loaded under a fresh read before appending to reduce lost * updates between concurrent uploads. 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); } } // Append under a fresh load to reduce lost updates between concurrent uploads. $gallery = gallery_load($slug); $gallery['images'][] = [ 'key' => $key, 'thumb' => $thumbKey, 'name' => substr((string)($original['name'] ?? basename($key)), 0, 200), 'size' => (int)($original['size'] ?? 0), ]; gallery_save($gallery); return [200, ['ok' => true, 'key' => $key, 'thumb' => $thumbKey, 'count' => count($gallery['images'])]]; }