s3.php 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  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. * - Presigned PUT → the admin browser uploads directly to S3
  8. * - Signed DELETE → server-side cleanup when images/galleries are removed
  9. *
  10. * Uses path-style URLs: https://<endpoint>/<bucket>/<key>
  11. */
  12. declare(strict_types=1);
  13. /** Percent-encode an object key, keeping the "/" separators. */
  14. function s3_encode_key(string $key): string
  15. {
  16. return implode('/', array_map('rawurlencode', explode('/', $key)));
  17. }
  18. function s3_host(): string
  19. {
  20. return parse_url(config('s3.endpoint'), PHP_URL_HOST);
  21. }
  22. /** HMAC-SHA256 chain producing the SigV4 signing key. */
  23. function s3_signing_key(string $date): string
  24. {
  25. $k = hash_hmac('sha256', $date, 'AWS4' . config('s3.secret_key'), true);
  26. $k = hash_hmac('sha256', config('s3.region'), $k, true);
  27. $k = hash_hmac('sha256', 's3', $k, true);
  28. return hash_hmac('sha256', 'aws4_request', $k, true);
  29. }
  30. /**
  31. * SigV4 query-string signing core. Separated from s3_presign() so the
  32. * algorithm can be verified against the official AWS example vectors.
  33. * Returns the full query string including X-Amz-Signature.
  34. */
  35. function s3_presign_query(
  36. string $method,
  37. string $host,
  38. string $canonicalUri,
  39. string $accessKey,
  40. string $secretKey,
  41. string $region,
  42. int $ttl,
  43. string $amzDate
  44. ): string {
  45. $date = substr($amzDate, 0, 8);
  46. $scope = $date . '/' . $region . '/s3/aws4_request';
  47. $query = [
  48. 'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256',
  49. 'X-Amz-Credential' => $accessKey . '/' . $scope,
  50. 'X-Amz-Date' => $amzDate,
  51. 'X-Amz-Expires' => (string)$ttl,
  52. 'X-Amz-SignedHeaders' => 'host',
  53. ];
  54. ksort($query);
  55. $canonicalQuery = implode('&', array_map(
  56. fn($k, $v) => rawurlencode($k) . '=' . rawurlencode($v),
  57. array_keys($query),
  58. $query
  59. ));
  60. $canonicalRequest = implode("\n", [
  61. strtoupper($method),
  62. $canonicalUri,
  63. $canonicalQuery,
  64. 'host:' . $host,
  65. '',
  66. 'host',
  67. 'UNSIGNED-PAYLOAD',
  68. ]);
  69. $stringToSign = implode("\n", [
  70. 'AWS4-HMAC-SHA256',
  71. $amzDate,
  72. $scope,
  73. hash('sha256', $canonicalRequest),
  74. ]);
  75. $k = hash_hmac('sha256', $date, 'AWS4' . $secretKey, true);
  76. $k = hash_hmac('sha256', $region, $k, true);
  77. $k = hash_hmac('sha256', 's3', $k, true);
  78. $k = hash_hmac('sha256', 'aws4_request', $k, true);
  79. $signature = hash_hmac('sha256', $stringToSign, $k);
  80. return $canonicalQuery . '&X-Amz-Signature=' . $signature;
  81. }
  82. /**
  83. * Build a presigned URL for GET or PUT on an object key.
  84. * Only the Host header is signed, so the browser is free to set its own
  85. * Content-Type on PUT.
  86. */
  87. function s3_presign(string $method, string $key, ?int $ttl = null): string
  88. {
  89. $ttl ??= (int)config('s3.url_ttl', 3600);
  90. $canonicalUri = '/' . rawurlencode(config('s3.bucket')) . '/' . s3_encode_key($key);
  91. $query = s3_presign_query(
  92. $method,
  93. s3_host(),
  94. $canonicalUri,
  95. config('s3.access_key'),
  96. config('s3.secret_key'),
  97. config('s3.region'),
  98. $ttl,
  99. gmdate('Ymd\THis\Z')
  100. );
  101. return config('s3.endpoint') . $canonicalUri . '?' . $query;
  102. }
  103. function s3_presign_get(string $key, ?int $ttl = null): string
  104. {
  105. return s3_presign('GET', $key, $ttl);
  106. }
  107. function s3_presign_put(string $key, int $ttl = 900): string
  108. {
  109. return s3_presign('PUT', $key, $ttl);
  110. }
  111. /**
  112. * Server-side signed request (header auth). Used for DELETE.
  113. * Returns [httpStatus, responseBody].
  114. */
  115. function s3_request(string $method, string $key): array
  116. {
  117. $host = s3_host();
  118. $amzDate = gmdate('Ymd\THis\Z');
  119. $date = substr($amzDate, 0, 8);
  120. $scope = $date . '/' . config('s3.region') . '/s3/aws4_request';
  121. $canonicalUri = '/' . rawurlencode(config('s3.bucket')) . '/' . s3_encode_key($key);
  122. $payloadHash = hash('sha256', '');
  123. $canonicalRequest = implode("\n", [
  124. strtoupper($method),
  125. $canonicalUri,
  126. '', // no query string
  127. 'host:' . $host,
  128. 'x-amz-content-sha256:' . $payloadHash,
  129. 'x-amz-date:' . $amzDate,
  130. '',
  131. 'host;x-amz-content-sha256;x-amz-date',
  132. $payloadHash,
  133. ]);
  134. $stringToSign = implode("\n", [
  135. 'AWS4-HMAC-SHA256',
  136. $amzDate,
  137. $scope,
  138. hash('sha256', $canonicalRequest),
  139. ]);
  140. $signature = hash_hmac('sha256', $stringToSign, s3_signing_key($date));
  141. $authorization = 'AWS4-HMAC-SHA256 Credential=' . config('s3.access_key') . '/' . $scope
  142. . ', SignedHeaders=host;x-amz-content-sha256;x-amz-date'
  143. . ', Signature=' . $signature;
  144. $ch = curl_init(config('s3.endpoint') . $canonicalUri);
  145. curl_setopt_array($ch, [
  146. CURLOPT_CUSTOMREQUEST => strtoupper($method),
  147. CURLOPT_RETURNTRANSFER => true,
  148. CURLOPT_TIMEOUT => 30,
  149. CURLOPT_HTTPHEADER => [
  150. 'Authorization: ' . $authorization,
  151. 'x-amz-content-sha256: ' . $payloadHash,
  152. 'x-amz-date: ' . $amzDate,
  153. ],
  154. ]);
  155. $body = curl_exec($ch);
  156. $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
  157. curl_close($ch);
  158. return [$status, (string)$body];
  159. }
  160. /** Delete one object. S3 returns 204 for success and for already-gone keys. */
  161. function s3_delete(string $key): bool
  162. {
  163. [$status] = s3_request('DELETE', $key);
  164. return $status === 204 || $status === 200 || $status === 404;
  165. }
  166. /** Delete every S3 object referenced by a gallery (originals + thumbs). */
  167. function s3_delete_gallery_objects(array $gallery): void
  168. {
  169. foreach ($gallery['images'] ?? [] as $img) {
  170. if (!empty($img['key'])) {
  171. s3_delete($img['key']);
  172. }
  173. if (!empty($img['thumb'])) {
  174. s3_delete($img['thumb']);
  175. }
  176. }
  177. }