s3.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. <?php
  2. declare(strict_types=1);
  3. // Dependency-free client for S3-compatible object storage (AWS Signature V4).
  4. // Ported from the PSA order system (backup-server/s3.php) with the constant
  5. // prefix changed to MANAGE_S3_*. No SDK and no cURL are required; plain PHP
  6. // HTTPS streams are enough.
  7. require_once __DIR__ . "/bootstrap.php";
  8. function manageS3Config(): array
  9. {
  10. return [
  11. "endpoint" => rtrim(trim((string) MANAGE_S3_ENDPOINT), "/"),
  12. "region" => trim((string) MANAGE_S3_REGION),
  13. "bucket" => trim((string) MANAGE_S3_BUCKET),
  14. "prefix" => trim((string) MANAGE_S3_PREFIX, "/"),
  15. "access_key" => trim((string) MANAGE_S3_ACCESS_KEY),
  16. "secret_key" => (string) MANAGE_S3_SECRET_KEY,
  17. "timeout" => max(1, (int) MANAGE_S3_TIMEOUT),
  18. "path_style" => (bool) MANAGE_S3_PATH_STYLE,
  19. ];
  20. }
  21. function manageS3Enabled(): bool
  22. {
  23. if (MANAGE_S3_ENABLED !== true) {
  24. return false;
  25. }
  26. $config = manageS3Config();
  27. return $config["endpoint"] !== "" &&
  28. $config["region"] !== "" &&
  29. $config["bucket"] !== "" &&
  30. $config["access_key"] !== "" &&
  31. $config["secret_key"] !== "";
  32. }
  33. function manageS3ObjectKey(string $instance, string $filename): string
  34. {
  35. $config = manageS3Config();
  36. $key = $instance . "/" . $filename;
  37. return $config["prefix"] !== "" ? $config["prefix"] . "/" . $key : $key;
  38. }
  39. function manageS3EmptyPayloadHash(): string
  40. {
  41. return hash("sha256", "");
  42. }
  43. function manageS3HttpStatusFromHeaders(array $headers): int
  44. {
  45. $status = 0;
  46. foreach ($headers as $header) {
  47. if (preg_match('/^HTTP\/\S+\s+(\d+)/', (string) $header, $matches) === 1) {
  48. $status = (int) $matches[1];
  49. }
  50. }
  51. return $status;
  52. }
  53. // $legacyHeaders must be the caller's $http_response_header, because PHP only
  54. // populates that variable in the scope where the HTTP call was made.
  55. function manageS3ResponseHeaders($legacyHeaders): array
  56. {
  57. if (function_exists("http_get_last_response_headers")) {
  58. $lastHeaders = http_get_last_response_headers();
  59. return is_array($lastHeaders) ? $lastHeaders : [];
  60. }
  61. return is_array($legacyHeaders) ? $legacyHeaders : [];
  62. }
  63. function manageS3SignRequest(string $method, string $key, string $payloadHash, array $extraHeaders = []): array
  64. {
  65. $config = manageS3Config();
  66. $scheme = parse_url($config["endpoint"], PHP_URL_SCHEME);
  67. $endpointHost = parse_url($config["endpoint"], PHP_URL_HOST);
  68. if (!is_string($scheme) || $scheme === "" || !is_string($endpointHost) || $endpointHost === "") {
  69. throw new RuntimeException("S3-Endpunkt ist ungültig.");
  70. }
  71. $encodedKey = str_replace("%2F", "/", rawurlencode($key));
  72. if ($config["path_style"]) {
  73. // https://<endpoint-host>/<bucket>/<key>
  74. $host = $endpointHost;
  75. $canonicalUri = "/" . rawurlencode($config["bucket"]) . "/" . $encodedKey;
  76. } else {
  77. // https://<bucket>.<endpoint-host>/<key> (default for Hetzner and most)
  78. $host = $config["bucket"] . "." . $endpointHost;
  79. $canonicalUri = "/" . $encodedKey;
  80. }
  81. $port = parse_url($config["endpoint"], PHP_URL_PORT);
  82. if (is_int($port)) {
  83. $host .= ":" . $port;
  84. }
  85. $url = $scheme . "://" . $host . $canonicalUri;
  86. $now = gmdate("Ymd\THis\Z");
  87. $date = substr($now, 0, 8);
  88. $headers = array_merge($extraHeaders, [
  89. "host" => $host,
  90. "x-amz-content-sha256" => $payloadHash,
  91. "x-amz-date" => $now,
  92. ]);
  93. ksort($headers);
  94. $canonicalHeaders = "";
  95. foreach ($headers as $name => $value) {
  96. $canonicalHeaders .= $name . ":" . $value . "\n";
  97. }
  98. $signedHeaders = implode(";", array_keys($headers));
  99. $canonicalRequest =
  100. $method . "\n" .
  101. $canonicalUri .
  102. "\n\n" .
  103. $canonicalHeaders .
  104. "\n" .
  105. $signedHeaders .
  106. "\n" .
  107. $payloadHash;
  108. $scope = $date . "/" . $config["region"] . "/s3/aws4_request";
  109. $stringToSign =
  110. "AWS4-HMAC-SHA256\n" .
  111. $now .
  112. "\n" .
  113. $scope .
  114. "\n" .
  115. hash("sha256", $canonicalRequest);
  116. $kDate = hash_hmac("sha256", $date, "AWS4" . $config["secret_key"], true);
  117. $kRegion = hash_hmac("sha256", $config["region"], $kDate, true);
  118. $kService = hash_hmac("sha256", "s3", $kRegion, true);
  119. $kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
  120. $signature = hash_hmac("sha256", $stringToSign, $kSigning);
  121. $authorization =
  122. "AWS4-HMAC-SHA256 Credential=" .
  123. $config["access_key"] .
  124. "/" .
  125. $scope .
  126. ", SignedHeaders=" .
  127. $signedHeaders .
  128. ", Signature=" .
  129. $signature;
  130. $headerString = "";
  131. foreach ($headers as $name => $value) {
  132. $headerString .= $name . ": " . $value . "\r\n";
  133. }
  134. $headerString .= "Authorization: " . $authorization . "\r\n";
  135. return [
  136. "url" => $url,
  137. "headers" => $headerString,
  138. "timeout" => $config["timeout"],
  139. ];
  140. }
  141. // Builds a human-readable suffix for an error message from an S3 response.
  142. // S3-compatible endpoints return an XML body like
  143. // <Error><Code>SignatureDoesNotMatch</Code><Message>...</Message></Error>,
  144. // which pinpoints why a request was rejected.
  145. function manageS3ErrorDetail(int $status, $response): string
  146. {
  147. $detail = $status > 0 ? " (HTTP " . $status . ")" : "";
  148. $body = is_string($response) ? trim($response) : "";
  149. if ($body === "") {
  150. return $detail . ".";
  151. }
  152. $parts = [];
  153. if (preg_match('#<Code>(.*?)</Code>#s', $body, $matches) === 1) {
  154. $parts[] = trim($matches[1]);
  155. }
  156. if (preg_match('#<Message>(.*?)</Message>#s', $body, $matches) === 1) {
  157. $parts[] = trim($matches[1]);
  158. }
  159. if ($parts === []) {
  160. $parts[] = substr(preg_replace('/\s+/', " ", $body) ?? "", 0, 300);
  161. }
  162. return $detail . ": " . implode(" - ", $parts);
  163. }
  164. // Summarizes the response header chain so a failure can be diagnosed from the
  165. // log: every HTTP status line (reveals redirects), any Location target, and the
  166. // server's request id.
  167. function manageS3HeaderDiagnostic(array $headers): string
  168. {
  169. $statuses = [];
  170. $location = "";
  171. $requestId = "";
  172. foreach ($headers as $header) {
  173. $header = (string) $header;
  174. if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) {
  175. $statuses[] = $matches[1];
  176. } elseif (preg_match('/^Location:\s*(.+)$/i', $header, $matches) === 1) {
  177. $location = trim($matches[1]);
  178. } elseif (preg_match('/^x-amz-request-id:\s*(.+)$/i', $header, $matches) === 1) {
  179. $requestId = trim($matches[1]);
  180. }
  181. }
  182. $parts = [];
  183. if ($statuses !== []) {
  184. $parts[] = "status chain " . implode("->", $statuses);
  185. }
  186. if ($location !== "") {
  187. $parts[] = "redirected to " . $location;
  188. }
  189. if ($requestId !== "") {
  190. $parts[] = "request-id " . $requestId;
  191. }
  192. return $parts === [] ? "" : " [" . implode("; ", $parts) . "]";
  193. }
  194. function manageS3PutFile(string $localPath, string $key): void
  195. {
  196. // The whole file is held in memory for signing; a backup larger than
  197. // memory_limit fails here, stays local, and is retried later.
  198. $payload = @file_get_contents($localPath);
  199. if ($payload === false) {
  200. throw new RuntimeException("Backup-Datei konnte für den S3-Upload nicht gelesen werden.");
  201. }
  202. $request = manageS3SignRequest("PUT", $key, hash("sha256", $payload), [
  203. "content-type" => "application/zip",
  204. ]);
  205. $context = stream_context_create([
  206. "http" => [
  207. "method" => "PUT",
  208. "timeout" => $request["timeout"],
  209. "ignore_errors" => true,
  210. // Never chase a redirect: PHP would re-send the body with a
  211. // signature bound to the original host/path, which the target then
  212. // rejects. A 3xx must surface so the endpoint config can be fixed.
  213. "follow_location" => 0,
  214. "max_redirects" => 1,
  215. "protocol_version" => 1.1,
  216. "header" => $request["headers"] . "Content-Length: " . strlen($payload) . "\r\n",
  217. "content" => $payload,
  218. ],
  219. ]);
  220. $response = @file_get_contents($request["url"], false, $context);
  221. $headers = manageS3ResponseHeaders($http_response_header ?? null);
  222. $status = manageS3HttpStatusFromHeaders($headers);
  223. if ($response === false || $status < 200 || $status >= 300) {
  224. throw new RuntimeException(
  225. "S3-Upload fehlgeschlagen" . manageS3ErrorDetail($status, $response) . manageS3HeaderDiagnostic($headers),
  226. );
  227. }
  228. }
  229. function manageS3DeleteObject(string $key): void
  230. {
  231. $request = manageS3SignRequest("DELETE", $key, manageS3EmptyPayloadHash());
  232. $context = stream_context_create([
  233. "http" => [
  234. "method" => "DELETE",
  235. "timeout" => $request["timeout"],
  236. "ignore_errors" => true,
  237. "follow_location" => 0,
  238. "max_redirects" => 1,
  239. "protocol_version" => 1.1,
  240. "header" => $request["headers"],
  241. ],
  242. ]);
  243. $response = @file_get_contents($request["url"], false, $context);
  244. $headers = manageS3ResponseHeaders($http_response_header ?? null);
  245. $status = manageS3HttpStatusFromHeaders($headers);
  246. // DELETE is idempotent: an already missing object (404) counts as deleted.
  247. if ($response === false || ($status !== 404 && ($status < 200 || $status >= 300))) {
  248. throw new RuntimeException(
  249. "S3-Löschung fehlgeschlagen" . manageS3ErrorDetail($status, $response) . manageS3HeaderDiagnostic($headers),
  250. );
  251. }
  252. }
  253. function manageS3SendObjectToOutput(string $key, string $downloadName, int $fallbackSize): void
  254. {
  255. $request = manageS3SignRequest("GET", $key, manageS3EmptyPayloadHash());
  256. $context = stream_context_create([
  257. "http" => [
  258. "method" => "GET",
  259. "timeout" => $request["timeout"],
  260. "ignore_errors" => true,
  261. "follow_location" => 0,
  262. "max_redirects" => 1,
  263. "protocol_version" => 1.1,
  264. "header" => $request["headers"],
  265. ],
  266. ]);
  267. $handle = @fopen($request["url"], "rb", false, $context);
  268. if ($handle === false) {
  269. throw new RuntimeException("S3-Download fehlgeschlagen (Verbindungsfehler).");
  270. }
  271. $meta = stream_get_meta_data($handle);
  272. $headers = isset($meta["wrapper_data"]) && is_array($meta["wrapper_data"])
  273. ? $meta["wrapper_data"]
  274. : [];
  275. $status = manageS3HttpStatusFromHeaders($headers);
  276. if ($status < 200 || $status >= 300) {
  277. $body = stream_get_contents($handle, 2048);
  278. fclose($handle);
  279. throw new RuntimeException(
  280. "S3-Download fehlgeschlagen" . manageS3ErrorDetail($status, $body) . manageS3HeaderDiagnostic($headers),
  281. );
  282. }
  283. $size = $fallbackSize;
  284. foreach ($headers as $header) {
  285. if (preg_match('/^Content-Length:\s*(\d+)/i', (string) $header, $matches) === 1) {
  286. $size = (int) $matches[1];
  287. }
  288. }
  289. header("Content-Type: application/zip");
  290. header("Content-Disposition: attachment; filename=\"" . addcslashes($downloadName, "\"\\") . "\"");
  291. if ($size > 0) {
  292. header("Content-Length: " . (string) $size);
  293. }
  294. header("Cache-Control: private, no-store");
  295. header("X-Content-Type-Options: nosniff");
  296. fpassthru($handle);
  297. fclose($handle);
  298. exit;
  299. }