| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615 |
- <?php
- declare(strict_types=1);
- // Server-side backup storage: receiving, indexing, S3 archiving and the two
- // retention tiers. Instances are authenticated against the token registry in
- // includes/instances.php.
- require_once __DIR__ . "/bootstrap.php";
- require_once __DIR__ . "/instances.php";
- require_once __DIR__ . "/s3.php";
- function manageBackupFilenamePattern(): string
- {
- return '/^backup-\d{8}-\d{6}(?:-\d+)?\.zip$/';
- }
- function manageBackupInstanceDir(string $instance): string
- {
- return manageBackupsDir() . $instance . DIRECTORY_SEPARATOR;
- }
- function manageBackupPath(string $instance, string $filename): string
- {
- return manageBackupInstanceDir($instance) . $filename;
- }
- function manageBackupReadIndex(): array
- {
- $index = manageReadJsonFile(manageBackupIndexFile());
- $backups = isset($index["backups"]) && is_array($index["backups"])
- ? $index["backups"]
- : [];
- return ["backups" => 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";
- }
|