s3.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  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. * One curl handle per PHP process, reused across requests to the same endpoint.
  162. * curl_reset() clears the options but keeps the handle's live connection, DNS
  163. * and TLS-session caches, so the second PUT of a request (the thumbnail) and
  164. * any retry skip a full TCP + TLS handshake.
  165. */
  166. function s3_curl(): CurlHandle
  167. {
  168. static $ch = null;
  169. if ($ch === null) {
  170. $ch = curl_init();
  171. } else {
  172. curl_reset($ch);
  173. }
  174. return $ch;
  175. }
  176. /**
  177. * Whether an S3 attempt failed in a way that is worth repeating: a curl-level
  178. * failure (status 0), throttling, or a server-side error. 4xx is a real
  179. * rejection (bad key, bad signature) and must not be retried.
  180. */
  181. function s3_is_transient(int $status): bool
  182. {
  183. return $status === 0 || $status === 408 || $status === 429 || $status >= 500;
  184. }
  185. /**
  186. * Canonical query string: keys sorted, both sides percent-encoded. The same
  187. * string goes into the signature and onto the wire, so the two cannot drift.
  188. */
  189. function s3_canonical_query(array $query): string
  190. {
  191. ksort($query);
  192. $parts = [];
  193. foreach ($query as $name => $value) {
  194. $parts[] = rawurlencode((string)$name) . '=' . rawurlencode((string)$value);
  195. }
  196. return implode('&', $parts);
  197. }
  198. /** Full request URL for a key, with an optional canonical query string. */
  199. function s3_url(string $key, array $query = []): string
  200. {
  201. $canonicalQuery = s3_canonical_query($query);
  202. return s3_base_url() . s3_canonical_uri($key) . ($canonicalQuery !== '' ? '?' . $canonicalQuery : '');
  203. }
  204. /**
  205. * SigV4 header authentication — the shared core behind every server-side
  206. * request (PUT, DELETE, GET, and the multipart calls). Presigning stays
  207. * separate (s3_presign_query) because it carries the signature in the query
  208. * string instead, and is verified against the official AWS example vectors.
  209. *
  210. * $signable are extra headers to both sign and send, e.g. the Content-Type and
  211. * Content-Disposition that a multipart create stores on the finished object.
  212. * Host, x-amz-content-sha256 and x-amz-date are always included.
  213. *
  214. * Returns the header lines for CURLOPT_HTTPHEADER.
  215. */
  216. function s3_auth_headers(
  217. string $method,
  218. string $canonicalUri,
  219. string $canonicalQuery,
  220. string $payloadHash,
  221. array $signable = []
  222. ): array {
  223. $amzDate = gmdate('Ymd\THis\Z');
  224. $date = substr($amzDate, 0, 8);
  225. $scope = $date . '/' . config('s3.region') . '/s3/aws4_request';
  226. // Signed headers must be lowercase and sorted; values trimmed.
  227. $headers = array_change_key_case($signable, CASE_LOWER);
  228. $headers['host'] = s3_host();
  229. $headers['x-amz-content-sha256'] = $payloadHash;
  230. $headers['x-amz-date'] = $amzDate;
  231. ksort($headers);
  232. $canonicalHeaders = '';
  233. foreach ($headers as $name => $value) {
  234. $canonicalHeaders .= $name . ':' . trim((string)$value) . "\n";
  235. }
  236. $signedHeaders = implode(';', array_keys($headers));
  237. // $canonicalHeaders already ends in "\n", so implode's separator supplies
  238. // the blank line the canonical request format requires after it.
  239. $canonicalRequest = implode("\n", [
  240. strtoupper($method),
  241. $canonicalUri,
  242. $canonicalQuery,
  243. $canonicalHeaders,
  244. $signedHeaders,
  245. $payloadHash,
  246. ]);
  247. $stringToSign = implode("\n", [
  248. 'AWS4-HMAC-SHA256',
  249. $amzDate,
  250. $scope,
  251. hash('sha256', $canonicalRequest),
  252. ]);
  253. $signature = hash_hmac('sha256', $stringToSign, s3_signing_key($date));
  254. $lines = ['Authorization: AWS4-HMAC-SHA256 Credential=' . config('s3.access_key') . '/' . $scope
  255. . ', SignedHeaders=' . $signedHeaders
  256. . ', Signature=' . $signature];
  257. foreach ($headers as $name => $value) {
  258. if ($name !== 'host') { // curl derives Host from the URL itself
  259. $lines[] = $name . ': ' . $value;
  260. }
  261. }
  262. return $lines;
  263. }
  264. /**
  265. * Collect response headers into $into (lowercased names) as curl receives them.
  266. * Used for the ETag a multipart part upload returns.
  267. */
  268. function s3_header_collector(array &$into): callable
  269. {
  270. return function ($ch, string $line) use (&$into): int {
  271. $parts = explode(':', $line, 2);
  272. if (count($parts) === 2) {
  273. $into[strtolower(trim($parts[0]))] = trim($parts[1]);
  274. }
  275. return strlen($line);
  276. };
  277. }
  278. /**
  279. * Stream a local file to S3 with a signed PUT (header auth). The payload is sent
  280. * as UNSIGNED-PAYLOAD so the body is never hashed or buffered into memory — curl
  281. * streams it straight from the file handle, letting the webhost proxy originals
  282. * far larger than memory_limit. Content-Type is sent but not signed.
  283. *
  284. * Transient failures are retried up to $attempts times with a short backoff; the
  285. * file handle is rewound and the request re-signed for each try, so a dropped
  286. * connection costs one repeat instead of a failed image.
  287. * Returns [httpStatus, responseBody] of the last attempt.
  288. */
  289. function s3_put_file(string $key, string $filePath, string $contentType = 'application/octet-stream', int $attempts = 3): array
  290. {
  291. // Suppressed: a warning printed here would land in front of the JSON body
  292. // the API endpoints emit, and the failure is reported through the return.
  293. $fh = @fopen($filePath, 'rb');
  294. if ($fh === false) {
  295. return [0, 'Cannot open upload for reading'];
  296. }
  297. $size = (int)filesize($filePath);
  298. $status = 0;
  299. $body = '';
  300. for ($try = 1; $try <= $attempts; $try++) {
  301. rewind($fh);
  302. [$status, $body] = s3_put_stream($key, $fh, $size, $contentType);
  303. if (!s3_is_transient($status) || $try === $attempts) {
  304. break;
  305. }
  306. usleep(250000 * $try); // 0.25s, then 0.5s
  307. }
  308. fclose($fh);
  309. return [$status, $body];
  310. }
  311. /**
  312. * One signed PUT attempt streaming from an open, positioned file handle.
  313. *
  314. * $query lets a multipart part upload reuse this exact streaming path
  315. * (?partNumber=N&uploadId=…); $contentType is skipped when empty, because a
  316. * part carries no type of its own.
  317. *
  318. * Returns [httpStatus, responseBody, responseHeaders].
  319. *
  320. * @param resource $fh
  321. */
  322. function s3_put_stream(string $key, $fh, int $size, string $contentType, array $query = []): array
  323. {
  324. $canonicalUri = s3_canonical_uri($key);
  325. $canonicalQuery = s3_canonical_query($query);
  326. $payloadHash = 'UNSIGNED-PAYLOAD';
  327. // Content-Type is sent but deliberately not signed, matching how uploads
  328. // have always been signed here.
  329. $headers = s3_auth_headers('PUT', $canonicalUri, $canonicalQuery, $payloadHash);
  330. if ($contentType !== '') {
  331. $headers[] = 'Content-Type: ' . $contentType;
  332. }
  333. $headers[] = 'Expect:'; // skip 100-continue round-trip
  334. $responseHeaders = [];
  335. $ch = s3_curl();
  336. curl_setopt_array($ch, [
  337. CURLOPT_URL => s3_url($key, $query),
  338. CURLOPT_UPLOAD => true, // sets method to PUT and streams CURLOPT_INFILE
  339. CURLOPT_INFILE => $fh,
  340. CURLOPT_INFILESIZE => $size,
  341. CURLOPT_RETURNTRANSFER => true,
  342. CURLOPT_CONNECTTIMEOUT => 30,
  343. CURLOPT_TIMEOUT => 0, // no cap: originals can be large
  344. // Abort a connection that has stalled below 1 KB/s for two minutes,
  345. // instead of pinning a PHP worker on a dead socket until the web
  346. // server kills it. A retry then gets a fresh connection.
  347. CURLOPT_LOW_SPEED_LIMIT => 1024,
  348. CURLOPT_LOW_SPEED_TIME => 120,
  349. CURLOPT_TCP_NODELAY => true,
  350. CURLOPT_HEADERFUNCTION => s3_header_collector($responseHeaders),
  351. CURLOPT_HTTPHEADER => $headers,
  352. ]);
  353. $body = curl_exec($ch);
  354. $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
  355. return [$status, (string)$body, $responseHeaders];
  356. }
  357. /**
  358. * Server-side signed request with a small (or empty) body: DELETE, HEAD, and
  359. * the multipart create/complete/abort calls.
  360. *
  361. * The body is hashed in full rather than sent as UNSIGNED-PAYLOAD, which S3
  362. * requires for the multipart XML calls; it is only ever a few hundred bytes.
  363. * $signable adds headers that must be signed as well as sent.
  364. *
  365. * Returns [httpStatus, responseBody, responseHeaders].
  366. */
  367. function s3_request(string $method, string $key, array $query = [], string $body = '', array $signable = []): array
  368. {
  369. $canonicalUri = s3_canonical_uri($key);
  370. $canonicalQuery = s3_canonical_query($query);
  371. $payloadHash = hash('sha256', $body);
  372. $headers = s3_auth_headers($method, $canonicalUri, $canonicalQuery, $payloadHash, $signable);
  373. if ($body !== '') {
  374. $headers[] = 'Content-Type: application/xml';
  375. }
  376. $responseHeaders = [];
  377. $ch = s3_curl();
  378. curl_setopt_array($ch, [
  379. CURLOPT_URL => s3_url($key, $query),
  380. CURLOPT_CUSTOMREQUEST => strtoupper($method),
  381. CURLOPT_RETURNTRANSFER => true,
  382. CURLOPT_TIMEOUT => 30,
  383. CURLOPT_NOBODY => strtoupper($method) === 'HEAD',
  384. CURLOPT_HEADERFUNCTION => s3_header_collector($responseHeaders),
  385. CURLOPT_HTTPHEADER => $headers,
  386. ] + ($body !== '' ? [CURLOPT_POSTFIELDS => $body] : []));
  387. $response = curl_exec($ch);
  388. $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
  389. return [$status, (string)$response, $responseHeaders];
  390. }
  391. /** Delete one object. S3 returns 204 for success and for already-gone keys. */
  392. function s3_delete(string $key): bool
  393. {
  394. [$status] = s3_request('DELETE', $key);
  395. return $status === 204 || $status === 200 || $status === 404;
  396. }
  397. /** Object metadata, or null if it does not exist. */
  398. function s3_head(string $key): ?array
  399. {
  400. [$status, , $headers] = s3_request('HEAD', $key);
  401. return $status === 200 ? $headers : null;
  402. }
  403. // ---------------------------------------------------------------------------
  404. // Downloading and multipart uploading — used by the gallery archive builder
  405. // (app/archive.php) to move objects back out of S3 and into a ZIP.
  406. // ---------------------------------------------------------------------------
  407. /**
  408. * GET an object, handing each chunk to $onChunk as it arrives. Nothing is
  409. * buffered, so an object far larger than memory_limit streams through fine.
  410. *
  411. * $onChunk is called only once the response is known to be a success — an error
  412. * response body is XML, and feeding that to the caller would silently corrupt
  413. * whatever it is writing.
  414. *
  415. * One attempt only: a caller that has already written part of the object
  416. * somewhere has to undo that itself before retrying, so the retry decision
  417. * belongs to it. Returns [httpStatus, bytesDelivered].
  418. */
  419. function s3_get_stream(string $key, callable $onChunk): array
  420. {
  421. $bytes = 0;
  422. $ch = s3_curl();
  423. curl_setopt_array($ch, [
  424. CURLOPT_URL => s3_url($key),
  425. CURLOPT_HTTPGET => true,
  426. CURLOPT_CONNECTTIMEOUT => 30,
  427. CURLOPT_TIMEOUT => 0, // no cap: originals can be large
  428. CURLOPT_LOW_SPEED_LIMIT => 1024, // give up on a stalled socket
  429. CURLOPT_LOW_SPEED_TIME => 120,
  430. CURLOPT_TCP_NODELAY => true,
  431. CURLOPT_HTTPHEADER => s3_auth_headers('GET', s3_canonical_uri($key), '', hash('sha256', '')),
  432. CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use ($onChunk, &$bytes): int {
  433. $length = strlen($chunk);
  434. $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
  435. if ($status >= 200 && $status < 300) {
  436. $onChunk($chunk);
  437. $bytes += $length;
  438. }
  439. return $length; // consume the error body too, or curl aborts
  440. },
  441. ]);
  442. curl_exec($ch);
  443. return [(int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $bytes];
  444. }
  445. /**
  446. * Begin a multipart upload. Content-Type and Content-Disposition are signed and
  447. * stored here, on the create — S3 keeps them as the finished object's metadata
  448. * and returns them on GET, which is what makes the presigned archive URL
  449. * download as a named .zip without any extra signing machinery.
  450. *
  451. * Returns the upload id, or null if S3 refused.
  452. */
  453. function s3_mpu_create(string $key, string $contentType, string $disposition): ?string
  454. {
  455. [$status, $body] = s3_request('POST', $key, ['uploads' => ''], '', [
  456. 'content-type' => $contentType,
  457. 'content-disposition' => $disposition,
  458. ]);
  459. if ($status < 200 || $status >= 300) {
  460. return null;
  461. }
  462. // A regex rather than ext-simplexml: this app ships with no dependencies,
  463. // and the response is a fixed, tiny document.
  464. return preg_match('#<UploadId>(.*?)</UploadId>#s', $body, $m) ? $m[1] : null;
  465. }
  466. /**
  467. * Upload one part from a local file. Parts must be at least 5 MB except the
  468. * last one, and a part number may be re-uploaded until the upload is completed
  469. * — which is what makes an interrupted build safe to resume.
  470. *
  471. * Returns the part's ETag (quoted, as S3 sends it), or null on failure.
  472. */
  473. function s3_mpu_upload_part(string $key, string $uploadId, int $partNumber, string $filePath, int $attempts = 3): ?string
  474. {
  475. $fh = @fopen($filePath, 'rb');
  476. if ($fh === false) {
  477. return null;
  478. }
  479. $size = (int)filesize($filePath);
  480. $query = ['partNumber' => (string)$partNumber, 'uploadId' => $uploadId];
  481. $etag = null;
  482. for ($try = 1; $try <= $attempts; $try++) {
  483. rewind($fh);
  484. [$status, , $headers] = s3_put_stream($key, $fh, $size, '', $query);
  485. if ($status >= 200 && $status < 300) {
  486. $etag = $headers['etag'] ?? null;
  487. break;
  488. }
  489. if (!s3_is_transient($status) || $try === $attempts) {
  490. break;
  491. }
  492. usleep(250000 * $try); // 0.25s, then 0.5s
  493. }
  494. fclose($fh);
  495. return $etag;
  496. }
  497. /**
  498. * Finish a multipart upload. $parts is [['n' => int, 'etag' => string], …] in
  499. * ascending part order.
  500. *
  501. * S3 can report failure inside a 200 response here (it streams whitespace while
  502. * assembling, then appends the real result), so the body is checked too.
  503. * Returns [ok, responseBody] — the body lets the caller distinguish a
  504. * NoSuchUpload, which means "already completed", from a real error.
  505. */
  506. function s3_mpu_complete(string $key, string $uploadId, array $parts): array
  507. {
  508. $xml = '<CompleteMultipartUpload>';
  509. foreach ($parts as $part) {
  510. $xml .= '<Part><PartNumber>' . (int)$part['n'] . '</PartNumber>'
  511. . '<ETag>' . htmlspecialchars((string)$part['etag'], ENT_XML1) . '</ETag></Part>';
  512. }
  513. $xml .= '</CompleteMultipartUpload>';
  514. [$status, $body] = s3_request('POST', $key, ['uploadId' => $uploadId], $xml);
  515. $ok = $status >= 200 && $status < 300 && !str_contains($body, '<Error>');
  516. return [$ok, $body];
  517. }
  518. /**
  519. * Abandon a multipart upload and release the parts S3 is storing (and billing)
  520. * for it. 404 counts as success: the upload is gone either way.
  521. */
  522. function s3_mpu_abort(string $key, string $uploadId): bool
  523. {
  524. [$status] = s3_request('DELETE', $key, ['uploadId' => $uploadId]);
  525. return $status === 204 || $status === 200 || $status === 404;
  526. }
  527. /** Delete every S3 object referenced by a gallery (originals, thumbs, archive). */
  528. function s3_delete_gallery_objects(array $gallery): void
  529. {
  530. foreach ($gallery['images'] ?? [] as $img) {
  531. if (!empty($img['key'])) {
  532. s3_delete($img['key']);
  533. }
  534. if (!empty($img['thumb'])) {
  535. s3_delete($img['thumb']);
  536. }
  537. }
  538. if (!empty($gallery['archive']['key'])) {
  539. s3_delete($gallery['archive']['key']);
  540. }
  541. }
  542. /** Human-readable reason for a PHP upload error code. */
  543. function upload_error_message(int $code): string
  544. {
  545. return match ($code) {
  546. UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'file exceeds the server upload size limit',
  547. UPLOAD_ERR_PARTIAL => 'upload was interrupted',
  548. UPLOAD_ERR_NO_FILE => 'no file received',
  549. UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE => 'server cannot store the upload',
  550. default => 'upload error ' . $code,
  551. };
  552. }
  553. /**
  554. * Ingest one uploaded image into a gallery: stream the original (and optional
  555. * browser-generated thumbnail) to S3, then append it to the gallery's JSON file.
  556. *
  557. * Shared by admin/api.php (trusted admin) and upload-api.php (public guest link).
  558. * The browser uploads several images at once, so the gallery entry is appended
  559. * through gallery_append_image(), which re-reads and rewrites the JSON file
  560. * under an exclusive lock — two uploads finishing together cannot drop one
  561. * another's entry. Object keys are generated server-side under the gallery's
  562. * own prefix — never taken from the client.
  563. *
  564. * $original / $thumb are $_FILES entries (or null). When $imagesOnly is true the
  565. * original must have a recognised image extension and decode via getimagesize(),
  566. * so a public link cannot be used to store arbitrary file types.
  567. *
  568. * Returns [int $httpStatus, array $payload] for the caller to hand to
  569. * json_response(); a thumbnail failure is non-fatal (the grid falls back to the
  570. * original key).
  571. */
  572. function gallery_store_s3_upload(array $gallery, ?array $original, ?array $thumb, bool $imagesOnly = false): array
  573. {
  574. if (!is_array($original) || ($original['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
  575. return [400, ['error' => upload_error_message((int)($original['error'] ?? UPLOAD_ERR_NO_FILE))]];
  576. }
  577. if (!is_uploaded_file((string)$original['tmp_name'])) {
  578. return [400, ['error' => 'Invalid upload']];
  579. }
  580. if ($imagesOnly) {
  581. $ext = strtolower(pathinfo((string)($original['name'] ?? ''), PATHINFO_EXTENSION));
  582. if (!in_array($ext, MEDIA_EXTENSIONS, true) || getimagesize((string)$original['tmp_name']) === false) {
  583. return [400, ['error' => 'Only image files are allowed']];
  584. }
  585. }
  586. $slug = $gallery['slug'];
  587. $name = substr(safe_filename((string)($original['name'] ?? '')), 0, 120);
  588. $token = random_token(6);
  589. $base = s3_gallery_prefix($slug);
  590. $key = "$base/originals/$token-$name";
  591. // Stream the original to S3 byte-for-byte from the PHP upload temp file.
  592. $type = (string)($original['type'] ?? '') ?: 'application/octet-stream';
  593. [$status] = s3_put_file($key, (string)$original['tmp_name'], $type);
  594. if ($status < 200 || $status >= 300) {
  595. return [502, ['error' => "S3 rejected the original (HTTP $status)"]];
  596. }
  597. // Optional browser-generated thumbnail. A thumb failure is non-fatal: the
  598. // original stays, and the grid falls back to the original key.
  599. $thumbKey = null;
  600. if (is_array($thumb)
  601. && ($thumb['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK
  602. && is_uploaded_file((string)$thumb['tmp_name'])
  603. ) {
  604. $candidate = "$base/thumbs/$token-$name.jpg";
  605. [$tstatus] = s3_put_file($candidate, (string)$thumb['tmp_name'], 'image/jpeg');
  606. if ($tstatus >= 200 && $tstatus < 300) {
  607. $thumbKey = $candidate;
  608. } else {
  609. s3_delete($candidate);
  610. }
  611. }
  612. // Locked read-modify-write: concurrent uploads append without clobbering.
  613. $count = gallery_append_image($slug, [
  614. 'key' => $key,
  615. 'thumb' => $thumbKey,
  616. 'name' => substr((string)($original['name'] ?? basename($key)), 0, 200),
  617. 'size' => (int)($original['size'] ?? 0),
  618. ]);
  619. // The gallery was deleted while this image was in flight: drop the objects
  620. // we just wrote rather than leaving them unreferenced in the bucket.
  621. if ($count === null) {
  622. s3_delete($key);
  623. if ($thumbKey !== null) {
  624. s3_delete($thumbKey);
  625. }
  626. return [404, ['error' => 'Gallery no longer exists']];
  627. }
  628. return [200, ['ok' => true, 'key' => $key, 'thumb' => $thumbKey, 'count' => $count]];
  629. }