| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114 |
- <?php
- require_once __DIR__ . "/functions.php";
- if (!defined("BACKUP_DIR")) {
- define("BACKUP_DIR", DATA_DIR . "backups/");
- }
- if (!defined("BACKUP_LOCAL_RETENTION")) {
- define("BACKUP_LOCAL_RETENTION", 4);
- }
- if (!defined("BACKUP_AUTO_INTERVAL_SECONDS")) {
- define("BACKUP_AUTO_INTERVAL_SECONDS", 604800);
- }
- if (!defined("BACKUP_REMOTE_TARGETS")) {
- define("BACKUP_REMOTE_TARGETS", []);
- }
- class BackupRemoteUploadException extends RuntimeException
- {
- private array $debugContext;
- public function __construct(string $message, array $debugContext = [])
- {
- parent::__construct($message);
- $this->debugContext = $debugContext;
- }
- public function getDebugContext(): array
- {
- return $this->debugContext;
- }
- }
- function backupGetDirectory(): string
- {
- return rtrim((string) BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR;
- }
- function backupGetIndexFile(): string
- {
- return backupGetDirectory() . "backup-index.json";
- }
- function backupGetLockFile(): string
- {
- return backupGetDirectory() . ".backup.lock";
- }
- function backupEnsureDirectory(string $dir): void
- {
- if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
- throw new RuntimeException("Backup-Verzeichnis konnte nicht erstellt werden.");
- }
- @chmod($dir, 02775);
- }
- function backupNormalizePath(string $path): string
- {
- return str_replace("\\", "/", $path);
- }
- function backupIsTemporaryFile(string $path): bool
- {
- $name = basename($path);
- return $name === "" ||
- $name[0] === "." ||
- str_ends_with($name, ".tmp") ||
- str_ends_with($name, ".part");
- }
- function backupGetSourceFiles(): array
- {
- $dataDir = rtrim(DATA_DIR, "/\\") . DIRECTORY_SEPARATOR;
- $files = [];
- foreach (glob($dataDir . "*.json") ?: [] as $file) {
- if (is_file($file) && is_readable($file) && !backupIsTemporaryFile($file)) {
- $files[] = [
- "path" => $file,
- "name" => "data/" . basename($file),
- ];
- }
- }
- $uploadsDir = rtrim(UPLOADS_DIR, "/\\") . DIRECTORY_SEPARATOR;
- if (is_dir($uploadsDir)) {
- $items = new RecursiveIteratorIterator(
- new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS),
- RecursiveIteratorIterator::LEAVES_ONLY,
- );
- foreach ($items as $item) {
- if (!$item->isFile() || !$item->isReadable()) {
- continue;
- }
- $path = $item->getPathname();
- if (backupIsTemporaryFile($path)) {
- continue;
- }
- $relative = ltrim(
- backupNormalizePath(substr($path, strlen($uploadsDir))),
- "/",
- );
- if ($relative === "" || str_contains($relative, "\0")) {
- continue;
- }
- $files[] = [
- "path" => $path,
- "name" => "data/uploads/" . $relative,
- ];
- }
- }
- usort($files, function ($left, $right) {
- return strcmp($left["name"], $right["name"]);
- });
- return $files;
- }
- function backupGetDosDateTime(int $timestamp): array
- {
- $parts = getdate($timestamp);
- $year = max(1980, (int) $parts["year"]);
- return [
- (($year - 1980) << 9) | ((int) $parts["mon"] << 5) | (int) $parts["mday"],
- ((int) $parts["hours"] << 11) |
- ((int) $parts["minutes"] << 5) |
- ((int) floor(((int) $parts["seconds"]) / 2)),
- ];
- }
- function backupValidateZipEntryName(string $name): void
- {
- $name = backupNormalizePath($name);
- if (
- $name === "" ||
- str_contains($name, "\0") ||
- str_starts_with($name, "/") ||
- preg_match('/^[A-Za-z]:\//', $name) === 1
- ) {
- throw new RuntimeException("Ungültiger Backup-Pfad: " . $name);
- }
- foreach (explode("/", $name) as $segment) {
- if ($segment === "" || $segment === "." || $segment === "..") {
- throw new RuntimeException("Ungültiger Backup-Pfad: " . $name);
- }
- }
- if (strlen($name) > 65535) {
- throw new RuntimeException("Backup-Pfad ist zu lang: " . $name);
- }
- }
- function backupWriteBytes($handle, string $data): void
- {
- $offset = 0;
- $length = strlen($data);
- while ($offset < $length) {
- $written = fwrite($handle, substr($data, $offset));
- if ($written === false || $written === 0) {
- throw new RuntimeException("Backup-ZIP konnte nicht geschrieben werden.");
- }
- $offset += $written;
- }
- }
- function backupCopyFileToHandle(string $file, $handle): void
- {
- $source = fopen($file, "rb");
- if ($source === false) {
- throw new RuntimeException("Backup-Datei konnte nicht gelesen werden: " . basename($file));
- }
- while (!feof($source)) {
- $chunk = fread($source, 1048576);
- if ($chunk === false) {
- fclose($source);
- throw new RuntimeException("Backup-Datei konnte nicht gelesen werden: " . basename($file));
- }
- if ($chunk !== "") {
- try {
- backupWriteBytes($handle, $chunk);
- } catch (Throwable $exception) {
- fclose($source);
- throw $exception;
- }
- }
- }
- fclose($source);
- }
- function backupWriteZip(string $targetFile, array $files): array
- {
- if (empty($files)) {
- throw new RuntimeException("Keine Daten-Dateien für das Backup gefunden.");
- }
- $handle = fopen($targetFile, "wb");
- if ($handle === false) {
- throw new RuntimeException("Backup-ZIP konnte nicht erstellt werden.");
- }
- $centralDirectory = "";
- $fileCount = 0;
- $sourceBytes = 0;
- try {
- foreach ($files as $file) {
- $path = (string) ($file["path"] ?? "");
- $name = backupNormalizePath((string) ($file["name"] ?? ""));
- backupValidateZipEntryName($name);
- if (!is_file($path) || !is_readable($path)) {
- continue;
- }
- $size = filesize($path);
- if ($size === false) {
- throw new RuntimeException("Backup-Dateigröße konnte nicht ermittelt werden: " . $name);
- }
- if ($size > 0xffffffff) {
- throw new RuntimeException("Datei ist zu groß für dieses Backup-Format: " . $name);
- }
- $offset = ftell($handle);
- if ($offset === false || $offset > 0xffffffff) {
- throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
- }
- $crcHex = hash_file("crc32b", $path);
- if (!is_string($crcHex) || !preg_match('/^[a-f0-9]{8}$/i', $crcHex)) {
- throw new RuntimeException("Prüfsumme konnte nicht berechnet werden: " . $name);
- }
- $crc = (int) hexdec($crcHex);
- [$dosDate, $dosTime] = backupGetDosDateTime((int) (filemtime($path) ?: time()));
- $nameLength = strlen($name);
- backupWriteBytes(
- $handle,
- pack(
- "VvvvvvVVVvv",
- 0x04034b50,
- 10,
- 0,
- 0,
- $dosTime,
- $dosDate,
- $crc,
- $size,
- $size,
- $nameLength,
- 0,
- ) . $name,
- );
- backupCopyFileToHandle($path, $handle);
- $centralDirectory .=
- pack(
- "VvvvvvvVVVvvvvvVV",
- 0x02014b50,
- 0x031e,
- 10,
- 0,
- 0,
- $dosTime,
- $dosDate,
- $crc,
- $size,
- $size,
- $nameLength,
- 0,
- 0,
- 0,
- 0,
- 0,
- $offset,
- ) .
- $name;
- $fileCount++;
- $sourceBytes += $size;
- }
- if ($fileCount < 1) {
- throw new RuntimeException("Keine lesbaren Daten-Dateien für das Backup gefunden.");
- }
- if ($fileCount > 65535) {
- throw new RuntimeException("Zu viele Dateien für dieses Backup-Format.");
- }
- $centralOffset = ftell($handle);
- $centralSize = strlen($centralDirectory);
- if (
- $centralOffset === false ||
- $centralOffset > 0xffffffff ||
- $centralSize > 0xffffffff
- ) {
- throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
- }
- backupWriteBytes($handle, $centralDirectory);
- backupWriteBytes(
- $handle,
- pack(
- "VvvvvVVv",
- 0x06054b50,
- 0,
- 0,
- $fileCount,
- $fileCount,
- $centralSize,
- $centralOffset,
- 0,
- ),
- );
- } catch (Throwable $exception) {
- fclose($handle);
- @unlink($targetFile);
- throw $exception;
- }
- fclose($handle);
- @chmod($targetFile, 0660);
- return [
- "file_count" => $fileCount,
- "source_bytes" => $sourceBytes,
- "archive_bytes" => (int) (filesize($targetFile) ?: 0),
- "sha256" => hash_file("sha256", $targetFile) ?: "",
- ];
- }
- function backupReadIndex(): array
- {
- $index = readJsonFile(backupGetIndexFile());
- $records =
- isset($index["backups"]) && is_array($index["backups"])
- ? $index["backups"]
- : [];
- return ["backups" => array_values($records)];
- }
- function backupWriteIndex(array $records): bool
- {
- return writeJsonFile(backupGetIndexFile(), [
- "backups" => array_values($records),
- ]);
- }
- function backupListBackups(): array
- {
- $records = backupReadIndex()["backups"];
- $dir = backupGetDirectory();
- $existing = [];
- foreach ($records as $record) {
- if (!is_array($record)) {
- continue;
- }
- $filename = basename((string) ($record["filename"] ?? ""));
- if ($filename === "" || !is_file($dir . $filename)) {
- continue;
- }
- $record["filename"] = $filename;
- $record["size"] = (int) (filesize($dir . $filename) ?: ($record["size"] ?? 0));
- $existing[] = $record;
- }
- usort($existing, function ($left, $right) {
- return strcmp((string) ($right["created_at"] ?? ""), (string) ($left["created_at"] ?? ""));
- });
- return $existing;
- }
- function backupFormatBytes(int $bytes): string
- {
- if ($bytes >= 1073741824) {
- return number_format($bytes / 1073741824, 2, ",", ".") . " GB";
- }
- if ($bytes >= 1048576) {
- return number_format($bytes / 1048576, 2, ",", ".") . " MB";
- }
- if ($bytes >= 1024) {
- return number_format($bytes / 1024, 1, ",", ".") . " KB";
- }
- return $bytes . " B";
- }
- function backupGetRetentionLimit(): int
- {
- return max(1, (int) BACKUP_LOCAL_RETENTION);
- }
- function backupApplyRetention(): void
- {
- $records = backupListBackups();
- $keep = backupGetRetentionLimit();
- $dir = backupGetDirectory();
- foreach (array_slice($records, $keep) as $record) {
- $filename = basename((string) ($record["filename"] ?? ""));
- if ($filename !== "" && is_file($dir . $filename)) {
- @unlink($dir . $filename);
- }
- }
- backupWriteIndex(array_slice(backupListBackups(), 0, $keep));
- }
- function backupGetRemoteTargets(): array
- {
- return is_array(BACKUP_REMOTE_TARGETS) ? BACKUP_REMOTE_TARGETS : [];
- }
- function backupGetTargetLabel(array $target, int $index): string
- {
- $name = trim((string) ($target["name"] ?? ""));
- if ($name !== "") {
- return $name;
- }
- $type = trim((string) ($target["type"] ?? "target"));
- return $type . "-" . ($index + 1);
- }
- function backupGetSafeTargetContext(array $target): array
- {
- $safe = [];
- $allowedKeys = [
- "name",
- "type",
- "url",
- "instance",
- "bucket",
- "region",
- "prefix",
- "endpoint",
- "host",
- "port",
- "username",
- "path",
- "file",
- "callback",
- "timeout",
- ];
- foreach ($allowedKeys as $key) {
- if (array_key_exists($key, $target)) {
- $safe[$key] = is_scalar($target[$key]) ? (string) $target[$key] : gettype($target[$key]);
- }
- }
- return $safe;
- }
- function backupGetHttpUserAgent(): string
- {
- $version = defined("APP_VERSION") ? trim((string) APP_VERSION) : "";
- if ($version === "") {
- $version = "unknown";
- }
- return "PSA-Orderform-Backup/" . $version;
- }
- function backupFormatResponseExcerpt($response): string
- {
- if (!is_string($response) || $response === "") {
- return "";
- }
- $response = preg_replace('/\s+/', " ", trim($response));
- if (!is_string($response)) {
- return "";
- }
- return substr($response, 0, 500);
- }
- function backupFormatHeaderExcerpt(array $headers): array
- {
- $result = [];
- foreach ($headers as $header) {
- $header = trim((string) $header);
- if ($header === "") {
- continue;
- }
- $result[] = substr($header, 0, 500);
- if (count($result) >= 20) {
- break;
- }
- }
- return $result;
- }
- function backupGetLastPhpErrorMessage(): string
- {
- $error = error_get_last();
- if (!is_array($error)) {
- return "";
- }
- return substr(trim((string) ($error["message"] ?? "")), 0, 500);
- }
- function backupRemoteCapabilities(): array
- {
- $targets = backupGetRemoteTargets();
- $types = [];
- foreach ($targets as $target) {
- if (is_array($target)) {
- $type = trim((string) ($target["type"] ?? ""));
- if ($type !== "") {
- $types[$type] = true;
- }
- }
- }
- return [
- "s3" => [
- "configured" => !empty($types["s3"]),
- "available" => function_exists("hash_hmac"),
- ],
- "sftp" => [
- "configured" => !empty($types["sftp"]),
- "available" =>
- function_exists("ssh2_connect") &&
- function_exists("ssh2_sftp"),
- ],
- "custom" => [
- "configured" => !empty($types["custom"]),
- "available" => true,
- ],
- "managed" => [
- "configured" => !empty($types["managed"]),
- "available" => true,
- ],
- ];
- }
- function backupGetHttpStatusFromHeaders(array $headers): int
- {
- foreach ($headers as $header) {
- if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) {
- return (int) $matches[1];
- }
- }
- return 0;
- }
- function backupUploadToS3(string $archivePath, array $metadata, array $target): array
- {
- $bucket = trim((string) ($target["bucket"] ?? ""));
- $region = trim((string) ($target["region"] ?? ""));
- $accessKey = trim((string) ($target["access_key"] ?? ""));
- $secretKey = (string) ($target["secret_key"] ?? "");
- $prefix = trim((string) ($target["prefix"] ?? ""), "/");
- $endpoint = rtrim(trim((string) ($target["endpoint"] ?? "")), "/");
- if ($bucket === "" || $region === "" || $accessKey === "" || $secretKey === "") {
- throw new RuntimeException("S3-Ziel ist unvollständig konfiguriert.");
- }
- $filename = basename($archivePath);
- $key = ($prefix !== "" ? $prefix . "/" : "") . $filename;
- $host = $endpoint !== ""
- ? parse_url($endpoint, PHP_URL_HOST)
- : $bucket . ".s3." . $region . ".amazonaws.com";
- if (!is_string($host) || $host === "") {
- throw new RuntimeException("S3-Endpunkt ist ungültig.");
- }
- $url = $endpoint !== ""
- ? $endpoint . "/" . rawurlencode($bucket) . "/" . str_replace("%2F", "/", rawurlencode($key))
- : "https://" . $host . "/" . str_replace("%2F", "/", rawurlencode($key));
- $payload = file_get_contents($archivePath);
- if ($payload === false) {
- throw new RuntimeException("Backup-ZIP konnte für S3 nicht gelesen werden.");
- }
- $now = gmdate("Ymd\THis\Z");
- $date = substr($now, 0, 8);
- $payloadHash = hash("sha256", $payload);
- $canonicalUri = parse_url($url, PHP_URL_PATH);
- $canonicalUri = is_string($canonicalUri) && $canonicalUri !== "" ? $canonicalUri : "/";
- $signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
- $canonicalHeaders =
- "content-type:application/zip\n" .
- "host:" . $host . "\n" .
- "x-amz-content-sha256:" . $payloadHash . "\n" .
- "x-amz-date:" . $now . "\n";
- $canonicalRequest =
- "PUT\n" .
- $canonicalUri .
- "\n\n" .
- $canonicalHeaders .
- "\n" .
- $signedHeaders .
- "\n" .
- $payloadHash;
- $scope = $date . "/" . $region . "/s3/aws4_request";
- $stringToSign =
- "AWS4-HMAC-SHA256\n" .
- $now .
- "\n" .
- $scope .
- "\n" .
- hash("sha256", $canonicalRequest);
- $kDate = hash_hmac("sha256", $date, "AWS4" . $secretKey, true);
- $kRegion = hash_hmac("sha256", $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=" .
- $accessKey .
- "/" .
- $scope .
- ", SignedHeaders=" .
- $signedHeaders .
- ", Signature=" .
- $signature;
- $context = stream_context_create([
- "http" => [
- "method" => "PUT",
- "timeout" => (int) ($target["timeout"] ?? 120),
- "ignore_errors" => true,
- "header" =>
- "Content-Type: application/zip\r\n" .
- "Content-Length: " . strlen($payload) . "\r\n" .
- "Host: " . $host . "\r\n" .
- "X-Amz-Date: " . $now . "\r\n" .
- "X-Amz-Content-Sha256: " . $payloadHash . "\r\n" .
- "Authorization: " . $authorization . "\r\n" .
- "User-Agent: " . backupGetHttpUserAgent() . "\r\n",
- "content" => $payload,
- ],
- ]);
- $response = @file_get_contents($url, false, $context);
- $phpError = $response === false ? backupGetLastPhpErrorMessage() : "";
- if (function_exists("http_get_last_response_headers")) {
- $lastHeaders = http_get_last_response_headers();
- $headers = is_array($lastHeaders) ? $lastHeaders : [];
- } else {
- $legacyHeaders = ${"http_response_header"} ?? [];
- $headers = is_array($legacyHeaders)
- ? $legacyHeaders
- : [];
- }
- $status = backupGetHttpStatusFromHeaders($headers);
- if ($response === false || $status < 200 || $status >= 300) {
- throw new BackupRemoteUploadException(
- "S3-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
- [
- "http_status" => $status,
- "response_excerpt" => backupFormatResponseExcerpt($response),
- "response_headers" => backupFormatHeaderExcerpt($headers),
- "php_error" => $phpError,
- "bucket" => $bucket,
- "region" => $region,
- "key" => $key,
- "endpoint" => $endpoint,
- ],
- );
- }
- return ["remote_path" => "s3://" . $bucket . "/" . $key];
- }
- function backupUploadToSftp(string $archivePath, array $metadata, array $target): array
- {
- if (!function_exists("ssh2_connect") || !function_exists("ssh2_sftp")) {
- throw new RuntimeException("PHP-SSH2-Erweiterung ist nicht verfügbar.");
- }
- $host = trim((string) ($target["host"] ?? ""));
- $username = trim((string) ($target["username"] ?? ""));
- $password = (string) ($target["password"] ?? "");
- $remoteDir = rtrim((string) ($target["path"] ?? ""), "/");
- $port = (int) ($target["port"] ?? 22);
- if ($host === "" || $username === "" || $remoteDir === "") {
- throw new RuntimeException("SFTP-Ziel ist unvollständig konfiguriert.");
- }
- $connection = @ssh2_connect($host, $port > 0 ? $port : 22);
- if ($connection === false) {
- throw new RuntimeException("SFTP-Verbindung konnte nicht hergestellt werden.");
- }
- $authenticated = false;
- $privateKey = trim((string) ($target["private_key"] ?? ""));
- $publicKey = trim((string) ($target["public_key"] ?? ""));
- if (
- $privateKey !== "" &&
- $publicKey !== "" &&
- function_exists("ssh2_auth_pubkey_file")
- ) {
- $authenticated = @ssh2_auth_pubkey_file(
- $connection,
- $username,
- $publicKey,
- $privateKey,
- $password !== "" ? $password : null,
- );
- } elseif (function_exists("ssh2_auth_password")) {
- $authenticated = @ssh2_auth_password($connection, $username, $password);
- }
- if (!$authenticated) {
- throw new RuntimeException("SFTP-Anmeldung fehlgeschlagen.");
- }
- $sftp = @ssh2_sftp($connection);
- if ($sftp === false) {
- throw new RuntimeException("SFTP-Subsystem konnte nicht gestartet werden.");
- }
- $remotePath = $remoteDir . "/" . basename($archivePath);
- $targetStream = @fopen("ssh2.sftp://" . intval($sftp) . $remotePath, "wb");
- if ($targetStream === false) {
- throw new RuntimeException("SFTP-Zieldatei konnte nicht geöffnet werden.");
- }
- $source = fopen($archivePath, "rb");
- if ($source === false) {
- fclose($targetStream);
- throw new RuntimeException("Backup-ZIP konnte für SFTP nicht gelesen werden.");
- }
- $copied = stream_copy_to_stream($source, $targetStream);
- fclose($source);
- fclose($targetStream);
- if ($copied === false) {
- throw new RuntimeException("SFTP-Upload fehlgeschlagen.");
- }
- return ["remote_path" => "sftp://" . $host . $remotePath];
- }
- function backupValidateManagedInstance(string $instance): string
- {
- $instance = trim($instance);
- if (
- $instance === "" ||
- strlen($instance) > 120 ||
- preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $instance) !== 1
- ) {
- throw new RuntimeException("Managed-Backup-Instanz ist ungültig.");
- }
- return $instance;
- }
- function backupBuildMultipartBody(array $fields, string $fileField, string $filePath, string $fileName, string $boundary): string
- {
- $body = "";
- foreach ($fields as $name => $value) {
- $body .= "--" . $boundary . "\r\n";
- $body .= 'Content-Disposition: form-data; name="' . addcslashes((string) $name, "\"\\") . "\"\r\n\r\n";
- $body .= (string) $value . "\r\n";
- }
- $payload = file_get_contents($filePath);
- if ($payload === false) {
- throw new RuntimeException("Backup-ZIP konnte für Managed Upload nicht gelesen werden.");
- }
- $body .= "--" . $boundary . "\r\n";
- $body .=
- 'Content-Disposition: form-data; name="' .
- addcslashes($fileField, "\"\\") .
- '"; filename="' .
- addcslashes($fileName, "\"\\") .
- "\"\r\n";
- $body .= "Content-Type: application/zip\r\n\r\n";
- $body .= $payload . "\r\n";
- $body .= "--" . $boundary . "--\r\n";
- return $body;
- }
- function backupUploadToManaged(string $archivePath, array $metadata, array $target): array
- {
- $url = trim((string) ($target["url"] ?? ""));
- $instance = backupValidateManagedInstance((string) ($target["instance"] ?? ""));
- if (!filter_var($url, FILTER_VALIDATE_URL)) {
- throw new RuntimeException("Managed-Backup-URL ist ungültig.");
- }
- $filename = basename($archivePath);
- $sha256 = trim((string) ($metadata["sha256"] ?? ""));
- if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
- $sha256 = strtolower(hash_file("sha256", $archivePath) ?: "");
- }
- if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
- throw new RuntimeException("Managed-Backup-Prüfsumme konnte nicht berechnet werden.");
- }
- $boundary = "----psa-backup-" . bin2hex(random_bytes(12));
- $body = backupBuildMultipartBody(
- [
- "instance" => $instance,
- "filename" => $filename,
- "sha256" => $sha256,
- ],
- "backup",
- $archivePath,
- $filename,
- $boundary,
- );
- $context = stream_context_create([
- "http" => [
- "method" => "POST",
- "timeout" => (int) ($target["timeout"] ?? 120),
- "ignore_errors" => true,
- "header" =>
- "Content-Type: multipart/form-data; boundary=" .
- $boundary .
- "\r\nAccept: application/json\r\nUser-Agent: " .
- backupGetHttpUserAgent() .
- "\r\nContent-Length: " .
- strlen($body) .
- "\r\n",
- "content" => $body,
- ],
- ]);
- $response = @file_get_contents($url, false, $context);
- $phpError = $response === false ? backupGetLastPhpErrorMessage() : "";
- if (function_exists("http_get_last_response_headers")) {
- $lastHeaders = http_get_last_response_headers();
- $headers = is_array($lastHeaders) ? $lastHeaders : [];
- } else {
- $legacyHeaders = ${"http_response_header"} ?? [];
- $headers = is_array($legacyHeaders)
- ? $legacyHeaders
- : [];
- }
- $status = backupGetHttpStatusFromHeaders($headers);
- if ($response === false || $status < 200 || $status >= 300) {
- throw new BackupRemoteUploadException(
- "Managed-Backup-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
- [
- "http_status" => $status,
- "response_excerpt" => backupFormatResponseExcerpt($response),
- "response_headers" => backupFormatHeaderExcerpt($headers),
- "php_error" => $phpError,
- "url" => $url,
- "instance" => $instance,
- ],
- );
- }
- $decoded = json_decode($response, true);
- if (!is_array($decoded) || empty($decoded["success"])) {
- $error = is_array($decoded) ? trim((string) ($decoded["error"] ?? "")) : "";
- throw new BackupRemoteUploadException(
- "Managed-Backup-Upload wurde abgelehnt" . ($error !== "" ? ": " . $error : "."),
- [
- "http_status" => $status,
- "response_excerpt" => backupFormatResponseExcerpt($response),
- "response_headers" => backupFormatHeaderExcerpt($headers),
- "url" => $url,
- "instance" => $instance,
- "server_error" => $error,
- ],
- );
- }
- return [
- "remote_path" => $url,
- "instance" => $instance,
- "server_filename" => (string) ($decoded["filename"] ?? ""),
- ];
- }
- function backupUploadToCustom(string $archivePath, array $metadata, array $target): array
- {
- $file = trim((string) ($target["file"] ?? ""));
- $callback = $target["callback"] ?? null;
- if ($file !== "") {
- if (!is_file($file)) {
- throw new RuntimeException("Custom-Uploader-Datei wurde nicht gefunden.");
- }
- require_once $file;
- }
- if (!is_callable($callback)) {
- throw new RuntimeException("Custom-Uploader ist nicht aufrufbar.");
- }
- $result = call_user_func($callback, $archivePath, $metadata, $target);
- if ($result === true) {
- return [];
- }
- if (is_array($result)) {
- return $result;
- }
- throw new RuntimeException("Custom-Uploader meldet einen Fehler.");
- }
- function backupUploadRemotes(string $archivePath, array $metadata): array
- {
- $results = [];
- foreach (backupGetRemoteTargets() as $index => $target) {
- if (!is_array($target)) {
- continue;
- }
- $type = trim((string) ($target["type"] ?? ""));
- $label = backupGetTargetLabel($target, (int) $index);
- $startedAt = date("c");
- $safeTargetContext = backupGetSafeTargetContext($target);
- try {
- if ($type === "s3") {
- $extra = backupUploadToS3($archivePath, $metadata, $target);
- } elseif ($type === "sftp") {
- $extra = backupUploadToSftp($archivePath, $metadata, $target);
- } elseif ($type === "custom") {
- $extra = backupUploadToCustom($archivePath, $metadata, $target);
- } elseif ($type === "managed") {
- $extra = backupUploadToManaged($archivePath, $metadata, $target);
- } else {
- throw new RuntimeException("Unbekannter Backup-Zieltyp: " . $type);
- }
- $result = array_merge(
- [
- "target" => $label,
- "type" => $type,
- "success" => true,
- "uploaded_at" => date("c"),
- "started_at" => $startedAt,
- ],
- is_array($extra) ? $extra : [],
- );
- $results[] = $result;
- logAccess("Backup remote upload succeeded", [
- "target" => $label,
- "type" => $type,
- "filename" => $metadata["filename"] ?? basename($archivePath),
- "remote_path" => (string) ($result["remote_path"] ?? ""),
- ]);
- } catch (Throwable $exception) {
- $debugContext = $exception instanceof BackupRemoteUploadException
- ? $exception->getDebugContext()
- : [];
- $result = [
- "target" => $label,
- "type" => $type !== "" ? $type : "unknown",
- "success" => false,
- "started_at" => $startedAt,
- "error" => $exception->getMessage(),
- ];
- if (!empty($debugContext)) {
- $result["debug"] = $debugContext;
- }
- $results[] = $result;
- logError("Backup remote upload failed", [
- "target" => $label,
- "type" => $type !== "" ? $type : "unknown",
- "target_config" => $safeTargetContext,
- "filename" => $metadata["filename"] ?? basename($archivePath),
- "sha256" => $metadata["sha256"] ?? "",
- "error" => $exception->getMessage(),
- "debug" => $debugContext,
- ]);
- }
- }
- return $results;
- }
- function backupGetLastAutomaticAt(): int
- {
- foreach (backupListBackups() as $record) {
- if (($record["trigger"] ?? "") !== "automatic") {
- continue;
- }
- $timestamp = strtotime((string) ($record["created_at"] ?? ""));
- if ($timestamp !== false) {
- return $timestamp;
- }
- }
- return 0;
- }
- function backupIsAutomaticDue(): bool
- {
- $interval = (int) BACKUP_AUTO_INTERVAL_SECONDS;
- if ($interval < 1) {
- return false;
- }
- return time() - backupGetLastAutomaticAt() >= $interval;
- }
- function backupCreate(string $trigger = "manual"): array
- {
- $trigger = $trigger === "automatic" ? "automatic" : "manual";
- $dir = backupGetDirectory();
- backupEnsureDirectory($dir);
- $lockHandle = fopen(backupGetLockFile(), "c+");
- if ($lockHandle === false) {
- throw new RuntimeException("Backup-Sperrdatei konnte nicht geöffnet werden.");
- }
- if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
- fclose($lockHandle);
- throw new RuntimeException("Es läuft bereits ein Backup.");
- }
- try {
- $baseName = "backup-" . date("Ymd-His");
- $filename = $baseName . ".zip";
- $counter = 2;
- while (file_exists($dir . $filename)) {
- $filename = $baseName . "-" . $counter . ".zip";
- $counter++;
- }
- $tmpFile = $dir . "." . $filename . ".tmp";
- $archivePath = $dir . $filename;
- $createdAt = date("c");
- $zipStats = backupWriteZip($tmpFile, backupGetSourceFiles());
- if (!rename($tmpFile, $archivePath)) {
- @unlink($tmpFile);
- throw new RuntimeException("Backup-ZIP konnte nicht finalisiert werden.");
- }
- @chmod($archivePath, 0660);
- $record = [
- "filename" => $filename,
- "created_at" => $createdAt,
- "trigger" => $trigger,
- "size" => (int) (filesize($archivePath) ?: $zipStats["archive_bytes"]),
- "file_count" => $zipStats["file_count"],
- "source_bytes" => $zipStats["source_bytes"],
- "sha256" => $zipStats["sha256"],
- "remote_uploads" => backupUploadRemotes($archivePath, [
- "filename" => $filename,
- "created_at" => $createdAt,
- "trigger" => $trigger,
- "sha256" => $zipStats["sha256"],
- ]),
- ];
- $records = backupListBackups();
- array_unshift($records, $record);
- backupWriteIndex($records);
- backupApplyRetention();
- logAccess("Backup created", [
- "filename" => $filename,
- "trigger" => $trigger,
- "file_count" => $record["file_count"],
- ]);
- return $record;
- } catch (Throwable $exception) {
- logError("Backup failed", [
- "trigger" => $trigger,
- "error" => $exception->getMessage(),
- ]);
- throw $exception;
- } finally {
- flock($lockHandle, LOCK_UN);
- fclose($lockHandle);
- }
- }
- function backupCreateAutomaticIfDue(): ?array
- {
- if (!backupIsAutomaticDue()) {
- return null;
- }
- return backupCreate("automatic");
- }
|