|
@@ -0,0 +1,616 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+declare(strict_types=1);
|
|
|
|
|
+
|
|
|
|
|
+// Server-side backup storage: receiving, indexing, S3 archiving and the two
|
|
|
|
|
+// retention tiers. Ported from the PSA order system (backup-server/lib.php),
|
|
|
|
|
+// with the instance allowlist replaced by the token-authenticated 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";
|
|
|
|
|
+}
|