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"); }