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://// $host = $endpointHost; $canonicalUri = "/" . rawurlencode($config["bucket"]) . "/" . $encodedKey; } else { // https://./ (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 // SignatureDoesNotMatch..., // 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('#(.*?)#s', $body, $matches) === 1) { $parts[] = trim($matches[1]); } if (preg_match('#(.*?)#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; }