s3.php 11 KB

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