s3.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. <?php
  2. /**
  3. * Minimal S3 client for Hetzner Object Storage (or any S3-compatible store).
  4. * Implements AWS Signature v4 in plain PHP — no SDK, no Composer.
  5. *
  6. * - Presigned GET → visitors load gallery images directly from S3
  7. * - Signed PUT → the webhost streams uploaded originals/thumbs to S3
  8. * - Signed DELETE → server-side cleanup when images/galleries are removed
  9. *
  10. * Addressing style is configurable via s3.path_style:
  11. * path-style (default) → https://<endpoint-host>/<bucket>/<key>
  12. * virtual-hosted-style → https://<bucket>.<endpoint-host>/<key>
  13. * Hetzner Object Storage serves path-style reliably; virtual-hosted-style
  14. * requires the bucket to resolve as a TLS subdomain of the endpoint.
  15. */
  16. declare(strict_types=1);
  17. /** Percent-encode an object key, keeping the "/" separators. */
  18. function s3_encode_key(string $key): string
  19. {
  20. return implode('/', array_map('rawurlencode', explode('/', $key)));
  21. }
  22. /** Bare host of the configured endpoint, e.g. "fsn1.your-objectstorage.com". */
  23. function s3_endpoint_host(): string
  24. {
  25. return (string)parse_url(config('s3.endpoint'), PHP_URL_HOST);
  26. }
  27. /** ":port" suffix when the endpoint pins a non-default port, else "". */
  28. function s3_endpoint_port_suffix(): string
  29. {
  30. $port = parse_url(config('s3.endpoint'), PHP_URL_PORT);
  31. return $port ? ':' . $port : '';
  32. }
  33. /** Whether to address the bucket in the path (true) or as a subdomain (false). */
  34. function s3_use_path_style(): bool
  35. {
  36. return (bool)config('s3.path_style', true);
  37. }
  38. /**
  39. * Request host. Path-style keeps the bare endpoint host; virtual-hosted-style
  40. * prepends the bucket as a DNS label (never percent-encoded).
  41. */
  42. function s3_host(): string
  43. {
  44. $host = s3_endpoint_host();
  45. $host = s3_use_path_style() ? $host : config('s3.bucket') . '.' . $host;
  46. // The signed Host header and the request host must match, port included.
  47. return $host . s3_endpoint_port_suffix();
  48. }
  49. /** Base URL for object requests: scheme + host, no trailing slash. */
  50. function s3_base_url(): string
  51. {
  52. $scheme = parse_url(config('s3.endpoint'), PHP_URL_SCHEME) ?: 'https';
  53. return $scheme . '://' . s3_host();
  54. }
  55. /**
  56. * Canonical (and actual) request path for a key. Path-style prefixes the
  57. * bucket as the first, percent-encoded path segment; virtual-hosted-style
  58. * does not, because the bucket lives in the host instead.
  59. */
  60. function s3_canonical_uri(string $key): string
  61. {
  62. $path = '/' . s3_encode_key($key);
  63. return s3_use_path_style() ? '/' . rawurlencode(config('s3.bucket')) . $path : $path;
  64. }
  65. /**
  66. * Key prefix under which one gallery's objects live: "<prefix>/<slug>".
  67. * The prefix is configurable via s3.prefix (default "galleries"); an empty
  68. * prefix puts galleries at the bucket root.
  69. */
  70. function s3_gallery_prefix(string $slug): string
  71. {
  72. $prefix = trim((string)config('s3.prefix', 'galleries'), '/');
  73. return $prefix !== '' ? "$prefix/$slug" : $slug;
  74. }
  75. /** HMAC-SHA256 chain producing the SigV4 signing key. */
  76. function s3_signing_key(string $date): string
  77. {
  78. $k = hash_hmac('sha256', $date, 'AWS4' . config('s3.secret_key'), true);
  79. $k = hash_hmac('sha256', config('s3.region'), $k, true);
  80. $k = hash_hmac('sha256', 's3', $k, true);
  81. return hash_hmac('sha256', 'aws4_request', $k, true);
  82. }
  83. /**
  84. * SigV4 query-string signing core. Separated from s3_presign() so the
  85. * algorithm can be verified against the official AWS example vectors.
  86. * Returns the full query string including X-Amz-Signature.
  87. */
  88. function s3_presign_query(
  89. string $method,
  90. string $host,
  91. string $canonicalUri,
  92. string $accessKey,
  93. string $secretKey,
  94. string $region,
  95. int $ttl,
  96. string $amzDate
  97. ): string {
  98. $date = substr($amzDate, 0, 8);
  99. $scope = $date . '/' . $region . '/s3/aws4_request';
  100. $query = [
  101. 'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256',
  102. 'X-Amz-Credential' => $accessKey . '/' . $scope,
  103. 'X-Amz-Date' => $amzDate,
  104. 'X-Amz-Expires' => (string)$ttl,
  105. 'X-Amz-SignedHeaders' => 'host',
  106. ];
  107. ksort($query);
  108. $canonicalQuery = implode('&', array_map(
  109. fn($k, $v) => rawurlencode($k) . '=' . rawurlencode($v),
  110. array_keys($query),
  111. $query
  112. ));
  113. $canonicalRequest = implode("\n", [
  114. strtoupper($method),
  115. $canonicalUri,
  116. $canonicalQuery,
  117. 'host:' . $host,
  118. '',
  119. 'host',
  120. 'UNSIGNED-PAYLOAD',
  121. ]);
  122. $stringToSign = implode("\n", [
  123. 'AWS4-HMAC-SHA256',
  124. $amzDate,
  125. $scope,
  126. hash('sha256', $canonicalRequest),
  127. ]);
  128. $k = hash_hmac('sha256', $date, 'AWS4' . $secretKey, true);
  129. $k = hash_hmac('sha256', $region, $k, true);
  130. $k = hash_hmac('sha256', 's3', $k, true);
  131. $k = hash_hmac('sha256', 'aws4_request', $k, true);
  132. $signature = hash_hmac('sha256', $stringToSign, $k);
  133. return $canonicalQuery . '&X-Amz-Signature=' . $signature;
  134. }
  135. /**
  136. * Build a presigned URL for an object key. Only the Host header is signed.
  137. * Used for GET so visitors' browsers can load private images directly; uploads
  138. * go through the webhost (s3_put_file), never a presigned PUT.
  139. */
  140. function s3_presign(string $method, string $key, ?int $ttl = null): string
  141. {
  142. $ttl ??= (int)config('s3.url_ttl', 3600);
  143. $canonicalUri = s3_canonical_uri($key);
  144. $query = s3_presign_query(
  145. $method,
  146. s3_host(),
  147. $canonicalUri,
  148. config('s3.access_key'),
  149. config('s3.secret_key'),
  150. config('s3.region'),
  151. $ttl,
  152. gmdate('Ymd\THis\Z')
  153. );
  154. return s3_base_url() . $canonicalUri . '?' . $query;
  155. }
  156. function s3_presign_get(string $key, ?int $ttl = null): string
  157. {
  158. return s3_presign('GET', $key, $ttl);
  159. }
  160. /**
  161. * Stream a local file to S3 with a signed PUT (header auth). The payload is sent
  162. * as UNSIGNED-PAYLOAD so the body is never hashed or buffered into memory — curl
  163. * streams it straight from the file handle, letting the webhost proxy originals
  164. * far larger than memory_limit. Content-Type is sent but not signed.
  165. * Returns [httpStatus, responseBody].
  166. */
  167. function s3_put_file(string $key, string $filePath, string $contentType = 'application/octet-stream'): array
  168. {
  169. $host = s3_host();
  170. $amzDate = gmdate('Ymd\THis\Z');
  171. $date = substr($amzDate, 0, 8);
  172. $scope = $date . '/' . config('s3.region') . '/s3/aws4_request';
  173. $canonicalUri = s3_canonical_uri($key);
  174. $payloadHash = 'UNSIGNED-PAYLOAD';
  175. $canonicalRequest = implode("\n", [
  176. 'PUT',
  177. $canonicalUri,
  178. '',
  179. 'host:' . $host,
  180. 'x-amz-content-sha256:' . $payloadHash,
  181. 'x-amz-date:' . $amzDate,
  182. '',
  183. 'host;x-amz-content-sha256;x-amz-date',
  184. $payloadHash,
  185. ]);
  186. $stringToSign = implode("\n", [
  187. 'AWS4-HMAC-SHA256',
  188. $amzDate,
  189. $scope,
  190. hash('sha256', $canonicalRequest),
  191. ]);
  192. $signature = hash_hmac('sha256', $stringToSign, s3_signing_key($date));
  193. $authorization = 'AWS4-HMAC-SHA256 Credential=' . config('s3.access_key') . '/' . $scope
  194. . ', SignedHeaders=host;x-amz-content-sha256;x-amz-date'
  195. . ', Signature=' . $signature;
  196. $fh = fopen($filePath, 'rb');
  197. if ($fh === false) {
  198. return [0, 'Cannot open upload for reading'];
  199. }
  200. $ch = curl_init(s3_base_url() . $canonicalUri);
  201. curl_setopt_array($ch, [
  202. CURLOPT_UPLOAD => true, // sets method to PUT and streams CURLOPT_INFILE
  203. CURLOPT_INFILE => $fh,
  204. CURLOPT_INFILESIZE => filesize($filePath),
  205. CURLOPT_RETURNTRANSFER => true,
  206. CURLOPT_CONNECTTIMEOUT => 30,
  207. CURLOPT_TIMEOUT => 0, // no cap: originals can be large
  208. CURLOPT_HTTPHEADER => [
  209. 'Authorization: ' . $authorization,
  210. 'x-amz-content-sha256: ' . $payloadHash,
  211. 'x-amz-date: ' . $amzDate,
  212. 'Content-Type: ' . $contentType,
  213. 'Expect:', // skip 100-continue round-trip
  214. ],
  215. ]);
  216. $body = curl_exec($ch);
  217. $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
  218. fclose($fh);
  219. return [$status, (string)$body];
  220. }
  221. /**
  222. * Server-side signed request (header auth). Used for DELETE.
  223. * Returns [httpStatus, responseBody].
  224. */
  225. function s3_request(string $method, string $key): array
  226. {
  227. $host = s3_host();
  228. $amzDate = gmdate('Ymd\THis\Z');
  229. $date = substr($amzDate, 0, 8);
  230. $scope = $date . '/' . config('s3.region') . '/s3/aws4_request';
  231. $canonicalUri = s3_canonical_uri($key);
  232. $payloadHash = hash('sha256', '');
  233. $canonicalRequest = implode("\n", [
  234. strtoupper($method),
  235. $canonicalUri,
  236. '', // no query string
  237. 'host:' . $host,
  238. 'x-amz-content-sha256:' . $payloadHash,
  239. 'x-amz-date:' . $amzDate,
  240. '',
  241. 'host;x-amz-content-sha256;x-amz-date',
  242. $payloadHash,
  243. ]);
  244. $stringToSign = implode("\n", [
  245. 'AWS4-HMAC-SHA256',
  246. $amzDate,
  247. $scope,
  248. hash('sha256', $canonicalRequest),
  249. ]);
  250. $signature = hash_hmac('sha256', $stringToSign, s3_signing_key($date));
  251. $authorization = 'AWS4-HMAC-SHA256 Credential=' . config('s3.access_key') . '/' . $scope
  252. . ', SignedHeaders=host;x-amz-content-sha256;x-amz-date'
  253. . ', Signature=' . $signature;
  254. $ch = curl_init(s3_base_url() . $canonicalUri);
  255. curl_setopt_array($ch, [
  256. CURLOPT_CUSTOMREQUEST => strtoupper($method),
  257. CURLOPT_RETURNTRANSFER => true,
  258. CURLOPT_TIMEOUT => 30,
  259. CURLOPT_HTTPHEADER => [
  260. 'Authorization: ' . $authorization,
  261. 'x-amz-content-sha256: ' . $payloadHash,
  262. 'x-amz-date: ' . $amzDate,
  263. ],
  264. ]);
  265. $body = curl_exec($ch);
  266. $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
  267. return [$status, (string)$body];
  268. }
  269. /** Delete one object. S3 returns 204 for success and for already-gone keys. */
  270. function s3_delete(string $key): bool
  271. {
  272. [$status] = s3_request('DELETE', $key);
  273. return $status === 204 || $status === 200 || $status === 404;
  274. }
  275. /** Delete every S3 object referenced by a gallery (originals + thumbs). */
  276. function s3_delete_gallery_objects(array $gallery): void
  277. {
  278. foreach ($gallery['images'] ?? [] as $img) {
  279. if (!empty($img['key'])) {
  280. s3_delete($img['key']);
  281. }
  282. if (!empty($img['thumb'])) {
  283. s3_delete($img['thumb']);
  284. }
  285. }
  286. }
  287. /** Human-readable reason for a PHP upload error code. */
  288. function upload_error_message(int $code): string
  289. {
  290. return match ($code) {
  291. UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'file exceeds the server upload size limit',
  292. UPLOAD_ERR_PARTIAL => 'upload was interrupted',
  293. UPLOAD_ERR_NO_FILE => 'no file received',
  294. UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE => 'server cannot store the upload',
  295. default => 'upload error ' . $code,
  296. };
  297. }
  298. /**
  299. * Ingest one uploaded image into a gallery: stream the original (and optional
  300. * browser-generated thumbnail) to S3, then append it to the gallery's JSON file.
  301. *
  302. * Shared by admin/api.php (trusted admin) and upload-api.php (public guest link).
  303. * The gallery is re-loaded under a fresh read before appending to reduce lost
  304. * updates between concurrent uploads. Object keys are generated server-side
  305. * under the gallery's own prefix — never taken from the client.
  306. *
  307. * $original / $thumb are $_FILES entries (or null). When $imagesOnly is true the
  308. * original must have a recognised image extension and decode via getimagesize(),
  309. * so a public link cannot be used to store arbitrary file types.
  310. *
  311. * Returns [int $httpStatus, array $payload] for the caller to hand to
  312. * json_response(); a thumbnail failure is non-fatal (the grid falls back to the
  313. * original key).
  314. */
  315. function gallery_store_s3_upload(array $gallery, ?array $original, ?array $thumb, bool $imagesOnly = false): array
  316. {
  317. if (!is_array($original) || ($original['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
  318. return [400, ['error' => upload_error_message((int)($original['error'] ?? UPLOAD_ERR_NO_FILE))]];
  319. }
  320. if (!is_uploaded_file((string)$original['tmp_name'])) {
  321. return [400, ['error' => 'Invalid upload']];
  322. }
  323. if ($imagesOnly) {
  324. $ext = strtolower(pathinfo((string)($original['name'] ?? ''), PATHINFO_EXTENSION));
  325. if (!in_array($ext, MEDIA_EXTENSIONS, true) || getimagesize((string)$original['tmp_name']) === false) {
  326. return [400, ['error' => 'Only image files are allowed']];
  327. }
  328. }
  329. $slug = $gallery['slug'];
  330. $name = substr(safe_filename((string)($original['name'] ?? '')), 0, 120);
  331. $token = random_token(6);
  332. $base = s3_gallery_prefix($slug);
  333. $key = "$base/originals/$token-$name";
  334. // Stream the original to S3 byte-for-byte from the PHP upload temp file.
  335. $type = (string)($original['type'] ?? '') ?: 'application/octet-stream';
  336. [$status] = s3_put_file($key, (string)$original['tmp_name'], $type);
  337. if ($status < 200 || $status >= 300) {
  338. return [502, ['error' => "S3 rejected the original (HTTP $status)"]];
  339. }
  340. // Optional browser-generated thumbnail. A thumb failure is non-fatal: the
  341. // original stays, and the grid falls back to the original key.
  342. $thumbKey = null;
  343. if (is_array($thumb)
  344. && ($thumb['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK
  345. && is_uploaded_file((string)$thumb['tmp_name'])
  346. ) {
  347. $candidate = "$base/thumbs/$token-$name.jpg";
  348. [$tstatus] = s3_put_file($candidate, (string)$thumb['tmp_name'], 'image/jpeg');
  349. if ($tstatus >= 200 && $tstatus < 300) {
  350. $thumbKey = $candidate;
  351. } else {
  352. s3_delete($candidate);
  353. }
  354. }
  355. // Append under a fresh load to reduce lost updates between concurrent uploads.
  356. $gallery = gallery_load($slug);
  357. $gallery['images'][] = [
  358. 'key' => $key,
  359. 'thumb' => $thumbKey,
  360. 'name' => substr((string)($original['name'] ?? basename($key)), 0, 200),
  361. 'size' => (int)($original['size'] ?? 0),
  362. ];
  363. gallery_save($gallery);
  364. return [200, ['ok' => true, 'key' => $key, 'thumb' => $thumbKey, 'count' => count($gallery['images'])]];
  365. }