| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343 |
- <?php
- declare(strict_types=1);
- // Dependency-free client for S3-compatible object storage (AWS Signature V4,
- // path-style addressing). Requires the BACKUP_SERVER_S3_* constants defined in
- // lib.php / config.php.
- function backupS3Config(): array
- {
- return [
- "endpoint" => rtrim(trim((string) BACKUP_SERVER_S3_ENDPOINT), "/"),
- "region" => trim((string) BACKUP_SERVER_S3_REGION),
- "bucket" => trim((string) BACKUP_SERVER_S3_BUCKET),
- "prefix" => trim((string) BACKUP_SERVER_S3_PREFIX, "/"),
- "access_key" => trim((string) BACKUP_SERVER_S3_ACCESS_KEY),
- "secret_key" => (string) BACKUP_SERVER_S3_SECRET_KEY,
- "timeout" => max(1, (int) BACKUP_SERVER_S3_TIMEOUT),
- "path_style" => (bool) BACKUP_SERVER_S3_PATH_STYLE,
- ];
- }
- function backupS3Enabled(): bool
- {
- if (BACKUP_SERVER_S3_ENABLED !== true) {
- return false;
- }
- $config = backupS3Config();
- return $config["endpoint"] !== "" &&
- $config["region"] !== "" &&
- $config["bucket"] !== "" &&
- $config["access_key"] !== "" &&
- $config["secret_key"] !== "";
- }
- function backupS3ObjectKey(string $instance, string $filename): string
- {
- $config = backupS3Config();
- $key = $instance . "/" . $filename;
- return $config["prefix"] !== "" ? $config["prefix"] . "/" . $key : $key;
- }
- function backupS3EmptyPayloadHash(): string
- {
- return hash("sha256", "");
- }
- function backupS3HttpStatusFromHeaders(array $headers): int
- {
- $status = 0;
- foreach ($headers as $header) {
- if (preg_match('/^HTTP\/\S+\s+(\d+)/', (string) $header, $matches) === 1) {
- $status = (int) $matches[1];
- }
- }
- return $status;
- }
- // $legacyHeaders must be the caller's $http_response_header, because PHP only
- // populates that variable in the scope where the HTTP call was made.
- function backupS3ResponseHeaders($legacyHeaders): array
- {
- if (function_exists("http_get_last_response_headers")) {
- $lastHeaders = http_get_last_response_headers();
- return is_array($lastHeaders) ? $lastHeaders : [];
- }
- return is_array($legacyHeaders) ? $legacyHeaders : [];
- }
- function backupS3SignRequest(string $method, string $key, string $payloadHash, array $extraHeaders = []): array
- {
- $config = backupS3Config();
- $scheme = parse_url($config["endpoint"], PHP_URL_SCHEME);
- $endpointHost = parse_url($config["endpoint"], PHP_URL_HOST);
- if (!is_string($scheme) || $scheme === "" || !is_string($endpointHost) || $endpointHost === "") {
- throw new RuntimeException("S3 endpoint is invalid.");
- }
- $encodedKey = str_replace("%2F", "/", rawurlencode($key));
- if ($config["path_style"]) {
- // https://<endpoint-host>/<bucket>/<key>
- $host = $endpointHost;
- $canonicalUri = "/" . rawurlencode($config["bucket"]) . "/" . $encodedKey;
- } else {
- // https://<bucket>.<endpoint-host>/<key> (default for Hetzner)
- $host = $config["bucket"] . "." . $endpointHost;
- $canonicalUri = "/" . $encodedKey;
- }
- $port = parse_url($config["endpoint"], PHP_URL_PORT);
- if (is_int($port)) {
- $host .= ":" . $port;
- }
- $url = $scheme . "://" . $host . $canonicalUri;
- $now = gmdate("Ymd\THis\Z");
- $date = substr($now, 0, 8);
- $headers = array_merge($extraHeaders, [
- "host" => $host,
- "x-amz-content-sha256" => $payloadHash,
- "x-amz-date" => $now,
- ]);
- ksort($headers);
- $canonicalHeaders = "";
- foreach ($headers as $name => $value) {
- $canonicalHeaders .= $name . ":" . $value . "\n";
- }
- $signedHeaders = implode(";", array_keys($headers));
- $canonicalRequest =
- $method . "\n" .
- $canonicalUri .
- "\n\n" .
- $canonicalHeaders .
- "\n" .
- $signedHeaders .
- "\n" .
- $payloadHash;
- $scope = $date . "/" . $config["region"] . "/s3/aws4_request";
- $stringToSign =
- "AWS4-HMAC-SHA256\n" .
- $now .
- "\n" .
- $scope .
- "\n" .
- hash("sha256", $canonicalRequest);
- $kDate = hash_hmac("sha256", $date, "AWS4" . $config["secret_key"], true);
- $kRegion = hash_hmac("sha256", $config["region"], $kDate, true);
- $kService = hash_hmac("sha256", "s3", $kRegion, true);
- $kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
- $signature = hash_hmac("sha256", $stringToSign, $kSigning);
- $authorization =
- "AWS4-HMAC-SHA256 Credential=" .
- $config["access_key"] .
- "/" .
- $scope .
- ", SignedHeaders=" .
- $signedHeaders .
- ", Signature=" .
- $signature;
- $headerString = "";
- foreach ($headers as $name => $value) {
- $headerString .= $name . ": " . $value . "\r\n";
- }
- $headerString .= "Authorization: " . $authorization . "\r\n";
- return [
- "url" => $url,
- "headers" => $headerString,
- "timeout" => $config["timeout"],
- ];
- }
- // Builds a human-readable suffix for an error message from an S3 response.
- // S3-compatible endpoints return an XML body like
- // <Error><Code>SignatureDoesNotMatch</Code><Message>...</Message></Error>,
- // which pinpoints why a request was rejected.
- function backupS3ErrorDetail(int $status, $response): string
- {
- $detail = $status > 0 ? " (HTTP " . $status . ")" : "";
- $body = is_string($response) ? trim($response) : "";
- if ($body === "") {
- return $detail . ".";
- }
- $parts = [];
- if (preg_match('#<Code>(.*?)</Code>#s', $body, $matches) === 1) {
- $parts[] = trim($matches[1]);
- }
- if (preg_match('#<Message>(.*?)</Message>#s', $body, $matches) === 1) {
- $parts[] = trim($matches[1]);
- }
- if ($parts === []) {
- $parts[] = substr(preg_replace('/\s+/', " ", $body) ?? "", 0, 300);
- }
- return $detail . ": " . implode(" - ", $parts);
- }
- // Summarizes the response header chain so a failure can be diagnosed from the
- // log: every HTTP status line (reveals redirects), any Location target, and the
- // server's request id. $headers is the raw wrapper header array.
- function backupS3HeaderDiagnostic(array $headers): string
- {
- $statuses = [];
- $location = "";
- $requestId = "";
- foreach ($headers as $header) {
- $header = (string) $header;
- if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) {
- $statuses[] = $matches[1];
- } elseif (preg_match('/^Location:\s*(.+)$/i', $header, $matches) === 1) {
- $location = trim($matches[1]);
- } elseif (preg_match('/^x-amz-request-id:\s*(.+)$/i', $header, $matches) === 1) {
- $requestId = trim($matches[1]);
- }
- }
- $parts = [];
- if ($statuses !== []) {
- $parts[] = "status chain " . implode("->", $statuses);
- }
- if ($location !== "") {
- $parts[] = "redirected to " . $location;
- }
- if ($requestId !== "") {
- $parts[] = "request-id " . $requestId;
- }
- return $parts === [] ? "" : " [" . implode("; ", $parts) . "]";
- }
- function backupS3PutFile(string $localPath, string $key): void
- {
- // The whole file is held in memory for signing; a backup larger than
- // memory_limit fails here, stays local, and is retried later.
- $payload = @file_get_contents($localPath);
- if ($payload === false) {
- throw new RuntimeException("Backup file cannot be read for S3 upload.");
- }
- $request = backupS3SignRequest("PUT", $key, hash("sha256", $payload), [
- "content-type" => "application/zip",
- ]);
- $context = stream_context_create([
- "http" => [
- "method" => "PUT",
- "timeout" => $request["timeout"],
- "ignore_errors" => true,
- // Never chase a redirect: PHP would re-send the body with a
- // signature bound to the original host/path, which the target then
- // rejects. A 3xx must surface so the endpoint config can be fixed.
- "follow_location" => 0,
- "max_redirects" => 1,
- "protocol_version" => 1.1,
- "header" => $request["headers"] . "Content-Length: " . strlen($payload) . "\r\n",
- "content" => $payload,
- ],
- ]);
- $response = @file_get_contents($request["url"], false, $context);
- $headers = backupS3ResponseHeaders($http_response_header ?? null);
- $status = backupS3HttpStatusFromHeaders($headers);
- if ($response === false || $status < 200 || $status >= 300) {
- throw new RuntimeException(
- "S3 upload failed" . backupS3ErrorDetail($status, $response) . backupS3HeaderDiagnostic($headers),
- );
- }
- }
- function backupS3DeleteObject(string $key): void
- {
- $request = backupS3SignRequest("DELETE", $key, backupS3EmptyPayloadHash());
- $context = stream_context_create([
- "http" => [
- "method" => "DELETE",
- "timeout" => $request["timeout"],
- "ignore_errors" => true,
- "follow_location" => 0,
- "max_redirects" => 1,
- "protocol_version" => 1.1,
- "header" => $request["headers"],
- ],
- ]);
- $response = @file_get_contents($request["url"], false, $context);
- $headers = backupS3ResponseHeaders($http_response_header ?? null);
- $status = backupS3HttpStatusFromHeaders($headers);
- // DELETE is idempotent: an already missing object (404) counts as deleted.
- if ($response === false || ($status !== 404 && ($status < 200 || $status >= 300))) {
- throw new RuntimeException(
- "S3 delete failed" . backupS3ErrorDetail($status, $response) . backupS3HeaderDiagnostic($headers),
- );
- }
- }
- function backupS3SendObjectToOutput(string $key, string $downloadName, int $fallbackSize): void
- {
- $request = backupS3SignRequest("GET", $key, backupS3EmptyPayloadHash());
- $context = stream_context_create([
- "http" => [
- "method" => "GET",
- "timeout" => $request["timeout"],
- "ignore_errors" => true,
- "follow_location" => 0,
- "max_redirects" => 1,
- "protocol_version" => 1.1,
- "header" => $request["headers"],
- ],
- ]);
- $handle = @fopen($request["url"], "rb", false, $context);
- if ($handle === false) {
- throw new RuntimeException("S3 download failed (connection error).");
- }
- $meta = stream_get_meta_data($handle);
- $headers = isset($meta["wrapper_data"]) && is_array($meta["wrapper_data"])
- ? $meta["wrapper_data"]
- : [];
- $status = backupS3HttpStatusFromHeaders($headers);
- if ($status < 200 || $status >= 300) {
- $body = stream_get_contents($handle, 2048);
- fclose($handle);
- throw new RuntimeException(
- "S3 download failed" . backupS3ErrorDetail($status, $body) . backupS3HeaderDiagnostic($headers),
- );
- }
- $size = $fallbackSize;
- foreach ($headers as $header) {
- if (preg_match('/^Content-Length:\s*(\d+)/i', (string) $header, $matches) === 1) {
- $size = (int) $matches[1];
- }
- }
- header("Content-Type: application/zip");
- header("Content-Disposition: attachment; filename=\"" . addcslashes($downloadName, "\"\\") . "\"");
- if ($size > 0) {
- header("Content-Length: " . (string) $size);
- }
- header("Cache-Control: private, no-store");
- header("X-Content-Type-Options: nosniff");
- fpassthru($handle);
- fclose($handle);
- exit;
- }
|