array_values($backups)]; } function manageBackupWriteIndex(array $backups): void { manageWriteJsonFile(manageBackupIndexFile(), [ "backups" => array_values($backups), ]); } function manageBackupUpdateIndexRecord(string $instance, string $filename, callable $update): void { $index = manageBackupReadIndex(); foreach ($index["backups"] as $position => $backup) { if ( is_array($backup) && ($backup["instance"] ?? "") === $instance && ($backup["filename"] ?? "") === $filename ) { $index["backups"][$position] = $update($backup); } } manageBackupWriteIndex($index["backups"]); } // Every instance that appears in the backup index, including instances that // were removed from the registry but still have stored history. function manageBackupIndexInstances(): array { $instances = []; foreach (manageBackupReadIndex()["backups"] as $backup) { if (is_array($backup)) { $instance = (string) ($backup["instance"] ?? ""); if ($instance !== "") { $instances[$instance] = true; } } } return array_keys($instances); } function manageBackupListForInstance(string $instance): array { $backups = []; foreach (manageBackupReadIndex()["backups"] as $backup) { if (is_array($backup) && ($backup["instance"] ?? "") === $instance) { $backups[] = $backup; } } usort($backups, static function ($left, $right): int { return strcmp((string) ($right["uploaded_at"] ?? ""), (string) ($left["uploaded_at"] ?? "")); }); return $backups; } function manageBackupGroupByInstance(): array { $grouped = []; foreach (manageBackupReadIndex()["backups"] as $backup) { if (!is_array($backup)) { continue; } $instance = (string) ($backup["instance"] ?? ""); if ($instance === "") { continue; } $grouped[$instance][] = $backup; } foreach ($grouped as $instance => $backups) { usort($backups, static function ($left, $right): int { return strcmp((string) ($right["uploaded_at"] ?? ""), (string) ($left["uploaded_at"] ?? "")); }); $grouped[$instance] = $backups; } ksort($grouped); return $grouped; } function manageBackupFind(string $instance, string $filename): ?array { foreach (manageBackupReadIndex()["backups"] as $backup) { if ( is_array($backup) && ($backup["instance"] ?? "") === $instance && ($backup["filename"] ?? "") === $filename ) { return $backup; } } return null; } function manageBackupValidateFilename(string $filename): string { $filename = trim($filename); if ( $filename === "" || basename($filename) !== $filename || preg_match(manageBackupFilenamePattern(), $filename) !== 1 ) { throw new RuntimeException("Ungültiger Backup-Dateiname."); } return $filename; } // Never overwrites an existing file: a repeated filename gets a -2, -3, ... suffix. function manageBackupChooseFilename(string $clientFilename, string $instanceDir): string { $clientFilename = trim($clientFilename); if ($clientFilename === "") { $filename = "backup-" . gmdate("Ymd-His") . ".zip"; } else { $filename = manageBackupValidateFilename($clientFilename); } $base = substr($filename, 0, -4); $counter = 2; while (is_file($instanceDir . $filename)) { $filename = $base . "-" . $counter . ".zip"; $counter++; } return $filename; } // --------------------------------------------------------------------------- // Settings (UI values take precedence over the config constants) // --------------------------------------------------------------------------- function manageBackupSettings(): array { $settings = manageReadJsonFile(manageSettingsFile()); return [ "retention" => isset($settings["retention"]) ? max(1, (int) $settings["retention"]) : max(1, (int) MANAGE_BACKUP_RETENTION), "s3_retention" => isset($settings["s3_retention"]) ? max(1, (int) $settings["s3_retention"]) : max(1, (int) MANAGE_S3_RETENTION), ]; } function manageBackupWriteSettings(array $settings): void { manageWriteJsonFile(manageSettingsFile(), [ "retention" => max(1, (int) ($settings["retention"] ?? MANAGE_BACKUP_RETENTION)), "s3_retention" => max(1, (int) ($settings["s3_retention"] ?? MANAGE_S3_RETENTION)), ]); } // --------------------------------------------------------------------------- // S3 sync // --------------------------------------------------------------------------- // Uploads every local backup of the instance that is not yet confirmed in S3, // oldest first. Serves both the immediate upload after receiving a backup and // the opportunistic retry of earlier failures. Stops at the first failure // because the endpoint is then most likely unreachable. function manageBackupSyncInstanceS3(string $instance): array { $result = ["uploaded" => 0, "pending" => 0, "error" => null]; if (!manageS3Enabled()) { return $result; } $pending = []; foreach (manageBackupReadIndex()["backups"] as $backup) { if (!is_array($backup) || ($backup["instance"] ?? "") !== $instance) { continue; } if (!empty($backup["s3_uploaded_at"])) { continue; } $filename = basename((string) ($backup["filename"] ?? "")); if ($filename === "" || !is_file(manageBackupPath($instance, $filename))) { continue; } $backup["filename"] = $filename; $pending[] = $backup; } usort($pending, static function ($left, $right): int { return strcmp((string) ($left["uploaded_at"] ?? ""), (string) ($right["uploaded_at"] ?? "")); }); foreach ($pending as $position => $backup) { $filename = (string) $backup["filename"]; $key = (string) ($backup["s3_key"] ?? ""); if ($key === "") { $key = manageS3ObjectKey($instance, $filename); } try { manageS3PutFile(manageBackupPath($instance, $filename), $key); } catch (Throwable $exception) { $result["pending"] = count($pending) - $position; $result["error"] = $exception->getMessage(); manageBackupUpdateIndexRecord($instance, $filename, static function (array $record) use ($key, $exception): array { $record["s3_key"] = $key; $record["s3_last_error"] = $exception->getMessage(); $record["s3_last_attempt_at"] = date(DATE_ATOM); return $record; }); manageLogS3("S3 upload failed", [ "instance" => $instance, "filename" => $filename, "key" => $key, "error" => $exception->getMessage(), ]); return $result; } manageBackupUpdateIndexRecord($instance, $filename, static function (array $record) use ($key): array { $record["s3_key"] = $key; $record["s3_uploaded_at"] = date(DATE_ATOM); unset($record["s3_last_error"], $record["s3_last_attempt_at"], $record["s3_expired"]); return $record; }); $result["uploaded"]++; } return $result; } function manageBackupSyncAllS3(): array { $total = ["uploaded" => 0, "pending" => 0, "error" => null]; foreach (manageBackupIndexInstances() as $instance) { $result = manageBackupSyncInstanceS3($instance); $total["uploaded"] += $result["uploaded"]; $total["pending"] += $result["pending"]; if ($result["error"] !== null && $total["error"] === null) { $total["error"] = $result["error"]; } } return $total; } // --------------------------------------------------------------------------- // Retention // --------------------------------------------------------------------------- // Applies both retention tiers for one instance. S3 keeps the newest // s3_retention archived backups; local keeps the newest retention copies but // never deletes a file whose S3 upload is still pending. function manageBackupApplyRetention(string $instance): void { $index = manageBackupReadIndex(); $settings = manageBackupSettings(); $s3Enabled = manageS3Enabled(); $instanceBackups = []; $otherBackups = []; foreach ($index["backups"] as $backup) { if (!is_array($backup)) { continue; } if (($backup["instance"] ?? "") === $instance) { $instanceBackups[] = $backup; } else { $otherBackups[] = $backup; } } usort($instanceBackups, static function ($left, $right): int { return strcmp((string) ($right["uploaded_at"] ?? ""), (string) ($left["uploaded_at"] ?? "")); }); if ($s3Enabled) { $archivedSeen = 0; foreach ($instanceBackups as $position => $backup) { if (empty($backup["s3_uploaded_at"])) { continue; } $archivedSeen++; if ($archivedSeen <= $settings["s3_retention"]) { continue; } $filename = basename((string) ($backup["filename"] ?? "")); $key = (string) ($backup["s3_key"] ?? ""); if ($key === "" && $filename !== "") { $key = manageS3ObjectKey($instance, $filename); } try { if ($key !== "") { manageS3DeleteObject($key); } } catch (Throwable $exception) { manageLogS3("S3 retention delete failed", [ "instance" => $instance, "filename" => $filename, "key" => $key, "error" => $exception->getMessage(), ]); continue; } unset($backup["s3_uploaded_at"], $backup["s3_key"]); $backup["s3_expired"] = true; $instanceBackups[$position] = $backup; } } $localSeen = 0; $kept = []; foreach ($instanceBackups as $backup) { $filename = basename((string) ($backup["filename"] ?? "")); $path = $filename !== "" ? manageBackupPath($instance, $filename) : ""; $localExists = $path !== "" && is_file($path); $inS3 = !empty($backup["s3_uploaded_at"]); if (!$localExists) { if ($inS3) { $kept[] = $backup; } // Present in neither store: drop the orphaned record. continue; } $localSeen++; if ($localSeen <= $settings["retention"]) { $kept[] = $backup; continue; } if ($inS3) { @unlink($path); $backup["local_deleted_at"] = date(DATE_ATOM); $kept[] = $backup; continue; } if ($s3Enabled && empty($backup["s3_expired"])) { // The only copy lives locally until the S3 upload succeeds. $kept[] = $backup; continue; } // S3 disabled or the backup already aged out of the bucket. @unlink($path); } manageBackupWriteIndex(array_merge($otherBackups, $kept)); } function manageBackupApplyRetentionAll(): void { foreach (manageBackupIndexInstances() as $instance) { manageBackupApplyRetention($instance); } } // --------------------------------------------------------------------------- // Store / delete / download // --------------------------------------------------------------------------- /** * Moves a validated upload into place, indexes it, archives it and applies * retention. $sourcePath must already have passed is_uploaded_file(). */ function manageBackupStoreUpload( string $instance, string $sourcePath, string $clientFilename, string $expectedSha256, array $meta = [], ): array { $instanceDir = manageBackupInstanceDir($instance); manageEnsureDirectory($instanceDir); $filename = manageBackupChooseFilename($clientFilename, $instanceDir); $targetPath = $instanceDir . $filename; if (!move_uploaded_file($sourcePath, $targetPath)) { throw new RuntimeException("Backup konnte nicht gespeichert werden."); } @chmod($targetPath, 0664); $size = filesize($targetPath); $sha256 = strtolower(hash_file("sha256", $targetPath) ?: ""); if ($size === false || $size <= 0 || preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) { @unlink($targetPath); throw new RuntimeException("Gespeichertes Backup konnte nicht verifiziert werden."); } $expectedSha256 = strtolower(trim($expectedSha256)); if ($expectedSha256 !== "" && $expectedSha256 !== $sha256) { @unlink($targetPath); throw new RuntimeException("Prüfsumme des Backups stimmt nicht überein."); } $index = manageBackupReadIndex(); $index["backups"][] = [ "instance" => $instance, "filename" => $filename, "client_filename" => basename($clientFilename !== "" ? $clientFilename : $filename), "size" => $size, "sha256" => $sha256, "uploaded_at" => date(DATE_ATOM), "source_ip" => $_SERVER["REMOTE_ADDR"] ?? "unknown", "trigger" => (string) ($meta["trigger"] ?? ""), "file_count" => (int) ($meta["file_count"] ?? 0), "source_bytes" => (int) ($meta["source_bytes"] ?? 0), "app_version" => (string) ($meta["app_version"] ?? ""), ]; manageBackupWriteIndex($index["backups"]); // S3 problems must never fail the upload: the local copy exists and the // sync is retried on the next upload or from the management UI. $s3Enabled = manageS3Enabled(); $s3Result = ["uploaded" => 0, "pending" => 0, "error" => null]; if ($s3Enabled) { try { $s3Result = manageBackupSyncInstanceS3($instance); } catch (Throwable $exception) { $s3Result = ["uploaded" => 0, "pending" => 1, "error" => $exception->getMessage()]; manageLogS3("S3 sync crashed", [ "instance" => $instance, "error" => $exception->getMessage(), ]); } } manageBackupApplyRetention($instance); $stored = manageBackupListForInstance($instance); manageInstanceTouch($instance, []); try { manageInstanceUpdate($instance, [ "last_backup_at" => date(DATE_ATOM), "backup_count" => count($stored), ]); } catch (Throwable $exception) { // Instance was deleted between authentication and storage; the backup // itself is safe and indexed, so this must not fail the request. manageLogError("Backup status update failed", [ "instance" => $instance, "error" => $exception->getMessage(), ]); } manageLogAccess("Backup received", [ "instance" => $instance, "filename" => $filename, "size" => $size, ]); return [ "filename" => $filename, "size" => $size, "sha256" => $sha256, "retention" => manageBackupSettings()["retention"], "s3" => [ "enabled" => $s3Enabled, "uploaded" => $s3Enabled && $s3Result["pending"] === 0, "pending" => $s3Result["pending"], ], ]; } function manageBackupDelete(string $instance, string $filename): void { $instance = manageInstanceValidateId($instance); $filename = manageBackupValidateFilename($filename); $record = manageBackupFind($instance, $filename); if ($record === null) { throw new RuntimeException("Backup wurde nicht gefunden."); } $path = manageBackupPath($instance, $filename); if (is_file($path)) { @unlink($path); } $key = (string) ($record["s3_key"] ?? ""); if ($key !== "" && !empty($record["s3_uploaded_at"]) && manageS3Enabled()) { try { manageS3DeleteObject($key); } catch (Throwable $exception) { manageLogS3("S3 delete failed", [ "instance" => $instance, "filename" => $filename, "key" => $key, "error" => $exception->getMessage(), ]); throw new RuntimeException( "Lokale Kopie wurde gelöscht, die S3-Kopie jedoch nicht: " . $exception->getMessage(), ); } } $remaining = []; foreach (manageBackupReadIndex()["backups"] as $backup) { if ( is_array($backup) && ($backup["instance"] ?? "") === $instance && ($backup["filename"] ?? "") === $filename ) { continue; } $remaining[] = $backup; } manageBackupWriteIndex($remaining); manageLogAccess("Backup deleted", ["instance" => $instance, "filename" => $filename]); } // Streams a backup to the browser, from local disk when present and otherwise // from S3, so the bucket can stay private. function manageBackupSendDownload(string $instance, string $filename): void { $instance = manageInstanceValidateId($instance); $filename = manageBackupValidateFilename($filename); $record = manageBackupFind($instance, $filename); if ($record === null) { throw new RuntimeException("Backup wurde nicht gefunden."); } $path = manageBackupPath($instance, $filename); if (is_file($path)) { $size = filesize($path); $handle = fopen($path, "rb"); if ($handle === false || $size === false) { throw new RuntimeException("Backup konnte nicht geöffnet werden."); } header("Content-Type: application/zip"); header("Content-Disposition: attachment; filename=\"" . addcslashes($filename, "\"\\") . "\""); header("Content-Length: " . (string) $size); header("Cache-Control: private, no-store"); header("X-Content-Type-Options: nosniff"); fpassthru($handle); fclose($handle); exit; } $key = (string) ($record["s3_key"] ?? ""); if ($key === "" || empty($record["s3_uploaded_at"]) || !manageS3Enabled()) { throw new RuntimeException("Backup-Datei ist weder lokal noch in S3 verfügbar."); } manageS3SendObjectToOutput($key, $filename, (int) ($record["size"] ?? 0)); } // Short label describing where a backup currently lives. function manageBackupStorageLabel(array $backup): string { $instance = (string) ($backup["instance"] ?? ""); $filename = basename((string) ($backup["filename"] ?? "")); $local = $instance !== "" && $filename !== "" && is_file(manageBackupPath($instance, $filename)); $inS3 = !empty($backup["s3_uploaded_at"]); if ($local && $inS3) { return "Lokal + S3"; } if ($local) { return !empty($backup["s3_last_error"]) ? "Nur lokal (S3-Fehler)" : "Nur lokal"; } if ($inS3) { return "Nur S3"; } return "Nicht verfügbar"; }