s3.php 11 KB

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