浏览代码

adding backup-server functionality

Josef Straßl 1 月之前
父节点
当前提交
b7e6f44187

+ 2 - 1
admin/settings.php

@@ -310,7 +310,8 @@ include __DIR__ . "/../includes/header.php";
     <p>
         S3: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["s3"])); ?> ·
         SFTP: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["sftp"])); ?> ·
-        Custom: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["custom"])); ?>
+        Custom: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["custom"])); ?> ·
+        Managed: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["managed"])); ?>
     </p>
 
     <form method="POST" class="inline-form">

+ 38 - 0
admin/updater.php

@@ -320,6 +320,41 @@ function updaterCopyWithBackup(string $stageDir, string $appRoot, string $backup
     ];
 }
 
+function updaterCleanupOldBackups(string $keepBackupDir): int
+{
+    $backupRoot = rtrim((string) UPDATE_BACKUP_DIR, "/\\");
+    if (!is_dir($backupRoot)) {
+        return 0;
+    }
+
+    $keepRealPath = realpath($keepBackupDir);
+    $backupRootRealPath = realpath($backupRoot);
+    if ($keepRealPath === false || $backupRootRealPath === false) {
+        return 0;
+    }
+
+    $removed = 0;
+    $items = new DirectoryIterator($backupRootRealPath);
+    foreach ($items as $item) {
+        if ($item->isDot() || !$item->isDir()) {
+            continue;
+        }
+
+        $path = $item->getPathname();
+        if (realpath($path) === $keepRealPath) {
+            continue;
+        }
+
+        updaterRemoveDirectory($path);
+        if (is_dir($path)) {
+            throw new RuntimeException("Old backup directory could not be removed: " . $path);
+        }
+        $removed++;
+    }
+
+    return $removed;
+}
+
 function updaterDeploy(array $manifest, string $appRoot): array
 {
     $runId = date("Ymd-His");
@@ -338,11 +373,13 @@ function updaterDeploy(array $manifest, string $appRoot): array
     $result = updaterCopyWithBackup($stageDir, $appRoot, $backupDir);
 
     updaterRemoveDirectory($workDir);
+    $removedBackups = updaterCleanupOldBackups($backupDir);
 
     return [
         "backup_dir" => $backupDir,
         "copied" => $result["copied"],
         "backed_up" => $result["backed_up"],
+        "removed_backups" => $removedBackups,
         "skipped" => $result["skipped"],
     ];
 }
@@ -388,6 +425,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
             $messages[] = "Deployment finished.";
             $messages[] = "Files copied: " . $result["copied"];
             $messages[] = "Files backed up: " . $result["backed_up"];
+            $messages[] = "Old backup directories removed: " . $result["removed_backups"];
             $messages[] = "Skipped preserved paths: " . $result["skipped"];
             $messages[] = "Backup directory: " . $result["backup_dir"];
         } catch (Throwable $exception) {

+ 14 - 0
backup-server/.htaccess

@@ -0,0 +1,14 @@
+Options -Indexes
+
+<IfModule mod_authz_core.c>
+    <FilesMatch "^(config\.php|.*\.json|.*\.zip)$">
+        Require all denied
+    </FilesMatch>
+</IfModule>
+
+<IfModule !mod_authz_core.c>
+    <FilesMatch "^(config\.php|.*\.json|.*\.zip)$">
+        Order allow,deny
+        Deny from all
+    </FilesMatch>
+</IfModule>

+ 60 - 0
backup-server/README.md

@@ -0,0 +1,60 @@
+# PSA Orderform Backup Server
+
+This folder can be deployed as a central backup server for distributed PSA orderform instances.
+
+## Setup
+
+1. Copy `config.sample.php` to `config.php`.
+2. Change `BACKUP_SERVER_PASSWORD`.
+3. Ensure the `backups/` directory is writable by PHP.
+4. Open `index.php` or `manage.php` and log in with the configured password.
+5. Add every allowed distributed instance in the management UI.
+
+The upload endpoint is `upload.php`. It intentionally does not require authentication, but every upload must include an `instance` identifier that was added in the management UI.
+
+## Client target
+
+Configure a distributed instance with a managed backup target:
+
+```php
+define('BACKUP_REMOTE_TARGETS', [
+    [
+        'name' => 'Managed Backup Server',
+        'type' => 'managed',
+        'url' => 'https://backup.example.org/upload.php',
+        'instance' => 'stadt-freising-prod',
+    ],
+]);
+```
+
+`url` must point directly to `upload.php`. `instance` may contain letters, numbers, dots, underscores, and dashes.
+
+## Retention
+
+The server retains the latest backups per instance. The default is `30`.
+
+Retention can be changed in `config.php` with `BACKUP_SERVER_RETENTION` and in the management UI. The UI value is stored in `backups/settings.json` and takes precedence after it has been saved once.
+
+Retention is applied after every successful upload and after retention changes in the management UI.
+
+## Storage
+
+Backups are stored under:
+
+```text
+backups/<instance>/backup-YYYYmmdd-HHMMSS.zip
+```
+
+Metadata is stored in:
+
+```text
+backups/index.json
+```
+
+Allowed instances and UI retention settings are stored in:
+
+```text
+backups/settings.json
+```
+
+With the included `.htaccess`, ZIP and JSON files are not directly readable through Apache. Downloads should use the authenticated management UI.

+ 2 - 0
backup-server/backups/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitignore

+ 12 - 0
backup-server/config.sample.php

@@ -0,0 +1,12 @@
+<?php
+
+// Copy this file to config.php and change the password before deploying.
+define("BACKUP_SERVER_PASSWORD", "change-me");
+
+// Optional stronger alternative:
+// define("BACKUP_SERVER_PASSWORD_HASH", "$2y$10$replace-this-with-a-precomputed-password-hash");
+
+define("BACKUP_SERVER_RETENTION", 30);
+define("BACKUP_SERVER_BACKUP_DIR", __DIR__ . "/backups/");
+define("BACKUP_SERVER_INDEX_FILE", BACKUP_SERVER_BACKUP_DIR . "index.json");
+define("BACKUP_SERVER_SETTINGS_FILE", BACKUP_SERVER_BACKUP_DIR . "settings.json");

+ 4 - 0
backup-server/index.php

@@ -0,0 +1,4 @@
+<?php
+
+header("Location: manage.php");
+exit;

+ 610 - 0
backup-server/manage.php

@@ -0,0 +1,610 @@
+<?php
+
+declare(strict_types=1);
+
+$baseDir = __DIR__;
+$configFile = $baseDir . "/config.php";
+
+if (is_file($configFile)) {
+    require_once $configFile;
+}
+
+if (!defined("BACKUP_SERVER_RETENTION")) {
+    define("BACKUP_SERVER_RETENTION", 30);
+}
+if (!defined("BACKUP_SERVER_BACKUP_DIR")) {
+    define("BACKUP_SERVER_BACKUP_DIR", $baseDir . "/backups/");
+}
+if (!defined("BACKUP_SERVER_INDEX_FILE")) {
+    define("BACKUP_SERVER_INDEX_FILE", rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . "/index.json");
+}
+if (!defined("BACKUP_SERVER_SETTINGS_FILE")) {
+    define("BACKUP_SERVER_SETTINGS_FILE", rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . "/settings.json");
+}
+
+if (session_status() === PHP_SESSION_NONE) {
+    ini_set("session.use_strict_mode", "1");
+    ini_set("session.cookie_httponly", "1");
+    ini_set("session.cookie_samesite", "Lax");
+    session_start();
+}
+
+$messages = [];
+$errors = [];
+
+function backupManageEscape($value): string
+{
+    return htmlspecialchars((string) $value, ENT_QUOTES, "UTF-8");
+}
+
+function backupManagePasswordConfigured(): bool
+{
+    return defined("BACKUP_SERVER_PASSWORD_HASH") || defined("BACKUP_SERVER_PASSWORD");
+}
+
+function backupManagePasswordMatches(string $password): bool
+{
+    if (defined("BACKUP_SERVER_PASSWORD_HASH")) {
+        return password_verify($password, (string) BACKUP_SERVER_PASSWORD_HASH);
+    }
+
+    if (defined("BACKUP_SERVER_PASSWORD")) {
+        return hash_equals((string) BACKUP_SERVER_PASSWORD, $password);
+    }
+
+    return false;
+}
+
+function backupManageIsLoggedIn(): bool
+{
+    return !empty($_SESSION["backup_server_logged_in"]);
+}
+
+function backupManageCsrfToken(): string
+{
+    if (empty($_SESSION["backup_server_csrf_token"])) {
+        $_SESSION["backup_server_csrf_token"] = bin2hex(random_bytes(32));
+    }
+
+    return $_SESSION["backup_server_csrf_token"];
+}
+
+function backupManageCsrfIsValid(string $token): bool
+{
+    return !empty($_SESSION["backup_server_csrf_token"]) &&
+        hash_equals($_SESSION["backup_server_csrf_token"], $token);
+}
+
+function backupManageEnsureDirectory(string $dir): void
+{
+    if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
+        throw new RuntimeException("Directory cannot be created: " . $dir);
+    }
+
+    @chmod($dir, 02775);
+}
+
+function backupManageReadJsonFile(string $file): array
+{
+    if (!is_file($file)) {
+        return [];
+    }
+
+    $decoded = json_decode((string) file_get_contents($file), true);
+    if (!is_array($decoded)) {
+        throw new RuntimeException("JSON file is invalid: " . basename($file));
+    }
+
+    return $decoded;
+}
+
+function backupManageWriteJsonFile(string $file, array $data): void
+{
+    backupManageEnsureDirectory(dirname($file));
+
+    $json = json_encode(
+        $data,
+        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+    );
+    if ($json === false) {
+        throw new RuntimeException("JSON cannot be encoded.");
+    }
+
+    $tmpFile = $file . ".tmp";
+    if (file_put_contents($tmpFile, $json . PHP_EOL, LOCK_EX) === false) {
+        throw new RuntimeException("JSON cannot be written.");
+    }
+
+    @chmod($tmpFile, 0664);
+    if (!rename($tmpFile, $file)) {
+        @unlink($tmpFile);
+        throw new RuntimeException("JSON cannot be saved.");
+    }
+
+    @chmod($file, 0664);
+}
+
+function backupManageReadIndex(): array
+{
+    $index = backupManageReadJsonFile((string) BACKUP_SERVER_INDEX_FILE);
+    $backups = isset($index["backups"]) && is_array($index["backups"])
+        ? $index["backups"]
+        : [];
+
+    return ["backups" => array_values($backups)];
+}
+
+function backupManageWriteIndex(array $backups): void
+{
+    backupManageWriteJsonFile((string) BACKUP_SERVER_INDEX_FILE, [
+        "backups" => array_values($backups),
+    ]);
+}
+
+function backupManageReadSettings(): array
+{
+    $settings = backupManageReadJsonFile((string) BACKUP_SERVER_SETTINGS_FILE);
+    $retention = isset($settings["retention"])
+        ? max(1, (int) $settings["retention"])
+        : max(1, (int) BACKUP_SERVER_RETENTION);
+    $instances =
+        isset($settings["instances"]) && is_array($settings["instances"])
+            ? $settings["instances"]
+            : [];
+    $allowedInstances = [];
+
+    foreach ($instances as $instance) {
+        try {
+            $allowedInstances[] = backupManageValidateInstance((string) $instance);
+        } catch (Throwable $exception) {
+            continue;
+        }
+    }
+    $allowedInstances = array_values(array_unique($allowedInstances));
+    sort($allowedInstances);
+
+    return [
+        "retention" => $retention,
+        "instances" => $allowedInstances,
+    ];
+}
+
+function backupManageWriteSettings(array $settings): void
+{
+    $instances =
+        isset($settings["instances"]) && is_array($settings["instances"])
+            ? $settings["instances"]
+            : backupManageReadSettings()["instances"];
+    $allowedInstances = [];
+
+    foreach ($instances as $instance) {
+        $allowedInstances[] = backupManageValidateInstance((string) $instance);
+    }
+    $allowedInstances = array_values(array_unique($allowedInstances));
+    sort($allowedInstances);
+
+    backupManageWriteJsonFile((string) BACKUP_SERVER_SETTINGS_FILE, [
+        "retention" => max(1, (int) ($settings["retention"] ?? BACKUP_SERVER_RETENTION)),
+        "instances" => $allowedInstances,
+    ]);
+}
+
+function backupManageInstanceDir(string $instance): string
+{
+    return rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR . $instance;
+}
+
+function backupManageValidateInstance(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("Invalid instance.");
+    }
+
+    return $instance;
+}
+
+function backupManageValidateFilename(string $filename): string
+{
+    $filename = basename(trim($filename));
+    if (preg_match('/^backup-\d{8}-\d{6}(?:-\d+)?\.zip$/', $filename) !== 1) {
+        throw new RuntimeException("Invalid backup filename.");
+    }
+
+    return $filename;
+}
+
+function backupManageFormatBytes(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 backupManageFindBackup(string $instance, string $filename): ?array
+{
+    foreach (backupManageReadIndex()["backups"] as $backup) {
+        if (!is_array($backup)) {
+            continue;
+        }
+        if (($backup["instance"] ?? "") === $instance && ($backup["filename"] ?? "") === $filename) {
+            return $backup;
+        }
+    }
+
+    return null;
+}
+
+function backupManageBackupPath(string $instance, string $filename): string
+{
+    return backupManageInstanceDir($instance) . DIRECTORY_SEPARATOR . $filename;
+}
+
+function backupManageSendDownload(string $instance, string $filename): void
+{
+    $backup = backupManageFindBackup($instance, $filename);
+    if ($backup === null) {
+        throw new RuntimeException("Backup not found.");
+    }
+
+    $path = backupManageBackupPath($instance, $filename);
+    $size = filesize($path);
+    $handle = fopen($path, "rb");
+    if ($size === false || $handle === false) {
+        throw new RuntimeException("Backup cannot be opened.");
+    }
+
+    header("Content-Type: application/zip");
+    header("Content-Disposition: attachment; filename=\"" . addcslashes($instance . "-" . $filename, "\"\\") . "\"");
+    header("Content-Length: " . (string) $size);
+    header("Cache-Control: private, no-store");
+    header("X-Content-Type-Options: nosniff");
+
+    fpassthru($handle);
+    fclose($handle);
+    exit;
+}
+
+function backupManageDeleteBackup(string $instance, string $filename): void
+{
+    $index = backupManageReadIndex();
+    $kept = [];
+    $found = false;
+
+    foreach ($index["backups"] as $backup) {
+        if (
+            is_array($backup) &&
+            ($backup["instance"] ?? "") === $instance &&
+            ($backup["filename"] ?? "") === $filename
+        ) {
+            $found = true;
+            continue;
+        }
+        $kept[] = $backup;
+    }
+
+    if (!$found) {
+        throw new RuntimeException("Backup not found.");
+    }
+
+    $path = backupManageBackupPath($instance, $filename);
+    if (is_file($path)) {
+        unlink($path);
+    }
+
+    backupManageWriteIndex($kept);
+}
+
+function backupManageApplyRetentionForInstance(string $instance): void
+{
+    $index = backupManageReadIndex();
+    $retention = backupManageReadSettings()["retention"];
+    $instanceBackups = [];
+    $otherBackups = [];
+
+    foreach ($index["backups"] as $backup) {
+        if (!is_array($backup)) {
+            continue;
+        }
+        if (($backup["instance"] ?? "") === $instance) {
+            $instanceBackups[] = $backup;
+        } else {
+            $otherBackups[] = $backup;
+        }
+    }
+
+    usort($instanceBackups, function ($left, $right) {
+        return strcmp((string) ($right["uploaded_at"] ?? ""), (string) ($left["uploaded_at"] ?? ""));
+    });
+
+    $keep = array_slice($instanceBackups, 0, $retention);
+    $remove = array_slice($instanceBackups, $retention);
+
+    foreach ($remove as $backup) {
+        $filename = basename((string) ($backup["filename"] ?? ""));
+        if ($filename !== "") {
+            $path = backupManageBackupPath($instance, $filename);
+            if (is_file($path)) {
+                @unlink($path);
+            }
+        }
+    }
+
+    backupManageWriteIndex(array_merge($otherBackups, $keep));
+}
+
+function backupManageApplyRetentionAll(): void
+{
+    $instances = [];
+    foreach (backupManageReadIndex()["backups"] as $backup) {
+        if (is_array($backup)) {
+            $instance = (string) ($backup["instance"] ?? "");
+            if ($instance !== "") {
+                $instances[$instance] = true;
+            }
+        }
+    }
+
+    foreach (array_keys($instances) as $instance) {
+        backupManageApplyRetentionForInstance($instance);
+    }
+}
+
+function backupManageAddInstance(string $instance): void
+{
+    $instance = backupManageValidateInstance($instance);
+    $settings = backupManageReadSettings();
+    $settings["instances"][] = $instance;
+    backupManageWriteSettings($settings);
+}
+
+function backupManageRemoveInstance(string $instance): void
+{
+    $instance = backupManageValidateInstance($instance);
+    $settings = backupManageReadSettings();
+    $settings["instances"] = array_values(
+        array_filter($settings["instances"], function ($existing) use ($instance) {
+            return $existing !== $instance;
+        }),
+    );
+    backupManageWriteSettings($settings);
+}
+
+function backupManageGroupBackupsByInstance(array $backups): array
+{
+    $grouped = [];
+    foreach ($backups as $backup) {
+        if (!is_array($backup)) {
+            continue;
+        }
+        $instance = (string) ($backup["instance"] ?? "");
+        $filename = basename((string) ($backup["filename"] ?? ""));
+        if ($instance === "" || $filename === "" || !is_file(backupManageBackupPath($instance, $filename))) {
+            continue;
+        }
+        $backup["filename"] = $filename;
+        $backup["size"] = (int) (filesize(backupManageBackupPath($instance, $filename)) ?: ($backup["size"] ?? 0));
+        $grouped[$instance][] = $backup;
+    }
+
+    ksort($grouped);
+    foreach ($grouped as &$records) {
+        usort($records, function ($left, $right) {
+            return strcmp((string) ($right["uploaded_at"] ?? ""), (string) ($left["uploaded_at"] ?? ""));
+        });
+    }
+    unset($records);
+
+    return $grouped;
+}
+
+if ($_SERVER["REQUEST_METHOD"] === "POST") {
+    $action = (string) ($_POST["action"] ?? "");
+
+    if ($action === "login") {
+        if (!backupManagePasswordConfigured()) {
+            $errors[] = "No password is configured.";
+        } elseif (backupManagePasswordMatches((string) ($_POST["password"] ?? ""))) {
+            session_regenerate_id(true);
+            $_SESSION["backup_server_logged_in"] = true;
+            $messages[] = "Logged in.";
+        } else {
+            $errors[] = "Wrong password.";
+        }
+    } elseif ($action === "logout") {
+        unset($_SESSION["backup_server_logged_in"], $_SESSION["backup_server_csrf_token"]);
+        $messages[] = "Logged out.";
+    } elseif (!backupManageIsLoggedIn()) {
+        $errors[] = "Login required.";
+    } elseif (!backupManageCsrfIsValid((string) ($_POST["csrf_token"] ?? ""))) {
+        $errors[] = "Invalid token. Please reload the page and try again.";
+    } else {
+        try {
+            if ($action === "update_retention") {
+                $retention = max(1, (int) ($_POST["retention"] ?? BACKUP_SERVER_RETENTION));
+                $settings = backupManageReadSettings();
+                $settings["retention"] = $retention;
+                backupManageWriteSettings($settings);
+                backupManageApplyRetentionAll();
+                $messages[] = "Retention updated.";
+            } elseif ($action === "add_instance") {
+                backupManageAddInstance((string) ($_POST["instance"] ?? ""));
+                $messages[] = "Instance added.";
+            } elseif ($action === "remove_instance") {
+                backupManageRemoveInstance((string) ($_POST["instance"] ?? ""));
+                $messages[] = "Instance removed.";
+            } elseif ($action === "download") {
+                backupManageSendDownload(
+                    backupManageValidateInstance((string) ($_POST["instance"] ?? "")),
+                    backupManageValidateFilename((string) ($_POST["filename"] ?? "")),
+                );
+            } elseif ($action === "delete") {
+                backupManageDeleteBackup(
+                    backupManageValidateInstance((string) ($_POST["instance"] ?? "")),
+                    backupManageValidateFilename((string) ($_POST["filename"] ?? "")),
+                );
+                $messages[] = "Backup deleted.";
+            }
+        } catch (Throwable $exception) {
+            $errors[] = $exception->getMessage();
+        }
+    }
+}
+
+try {
+    $settings = backupManageReadSettings();
+    $groupedBackups = backupManageGroupBackupsByInstance(backupManageReadIndex()["backups"]);
+} catch (Throwable $exception) {
+    $settings = ["retention" => max(1, (int) BACKUP_SERVER_RETENTION)];
+    $groupedBackups = [];
+    $errors[] = $exception->getMessage();
+}
+
+?>
+<!DOCTYPE html>
+<html lang="de">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Backup Management</title>
+</head>
+<body>
+    <h1>Backup Management</h1>
+
+    <?php foreach ($messages as $message): ?>
+        <p><strong><?php echo backupManageEscape($message); ?></strong></p>
+    <?php endforeach; ?>
+
+    <?php foreach ($errors as $error): ?>
+        <p><strong>Error:</strong> <?php echo backupManageEscape($error); ?></p>
+    <?php endforeach; ?>
+
+    <?php if (!backupManageIsLoggedIn()): ?>
+        <form method="POST">
+            <input type="hidden" name="action" value="login">
+            <p>
+                <label for="password">Password</label><br>
+                <input type="password" id="password" name="password" required>
+            </p>
+            <button type="submit">Login</button>
+        </form>
+    <?php else: ?>
+        <form method="POST">
+            <input type="hidden" name="action" value="logout">
+            <button type="submit">Logout</button>
+        </form>
+
+        <h2>Settings</h2>
+        <form method="POST">
+            <input type="hidden" name="action" value="update_retention">
+            <input type="hidden" name="csrf_token" value="<?php echo backupManageEscape(backupManageCsrfToken()); ?>">
+            <p>
+                <label for="retention">Backups retained per instance</label><br>
+                <input type="number" id="retention" name="retention" min="1" required value="<?php echo (int) $settings["retention"]; ?>">
+            </p>
+            <button type="submit">Save retention</button>
+        </form>
+
+        <h2>Upload endpoint</h2>
+        <p>Distributed instances should upload to <code>upload.php</code>.</p>
+
+        <h2>Allowed instances</h2>
+        <form method="POST">
+            <input type="hidden" name="action" value="add_instance">
+            <input type="hidden" name="csrf_token" value="<?php echo backupManageEscape(backupManageCsrfToken()); ?>">
+            <p>
+                <label for="instance">Instance identifier</label><br>
+                <input type="text" id="instance" name="instance" required pattern="[A-Za-z0-9][A-Za-z0-9._-]*" maxlength="120">
+            </p>
+            <button type="submit">Add instance</button>
+        </form>
+
+        <?php if (empty($settings["instances"])): ?>
+            <p>No instances allowed. Uploads will be rejected until an instance is added.</p>
+        <?php else: ?>
+            <table border="1" cellpadding="6" cellspacing="0">
+                <thead>
+                    <tr>
+                        <th>Instance</th>
+                        <th>Actions</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <?php foreach ($settings["instances"] as $instance): ?>
+                        <tr>
+                            <td><?php echo backupManageEscape($instance); ?></td>
+                            <td>
+                                <form method="POST" style="display:inline" onsubmit="return confirm('Remove this allowed instance? Existing backups remain visible.');">
+                                    <input type="hidden" name="action" value="remove_instance">
+                                    <input type="hidden" name="csrf_token" value="<?php echo backupManageEscape(backupManageCsrfToken()); ?>">
+                                    <input type="hidden" name="instance" value="<?php echo backupManageEscape($instance); ?>">
+                                    <button type="submit">Remove</button>
+                                </form>
+                            </td>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+        <?php endif; ?>
+
+        <h2>Backups</h2>
+        <?php if (empty($groupedBackups)): ?>
+            <p>No backups uploaded.</p>
+        <?php else: ?>
+            <?php foreach ($groupedBackups as $instance => $backups): ?>
+                <h3><?php echo backupManageEscape($instance); ?></h3>
+                <table border="1" cellpadding="6" cellspacing="0">
+                    <thead>
+                        <tr>
+                            <th>Uploaded</th>
+                            <th>Filename</th>
+                            <th>Size</th>
+                            <th>SHA-256</th>
+                            <th>Source IP</th>
+                            <th>Actions</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <?php foreach ($backups as $backup): ?>
+                            <tr>
+                                <td><?php echo backupManageEscape($backup["uploaded_at"] ?? ""); ?></td>
+                                <td><?php echo backupManageEscape($backup["filename"] ?? ""); ?></td>
+                                <td><?php echo backupManageEscape(backupManageFormatBytes((int) ($backup["size"] ?? 0))); ?></td>
+                                <td><?php echo backupManageEscape($backup["sha256"] ?? ""); ?></td>
+                                <td><?php echo backupManageEscape($backup["source_ip"] ?? ""); ?></td>
+                                <td>
+                                    <form method="POST" style="display:inline">
+                                        <input type="hidden" name="action" value="download">
+                                        <input type="hidden" name="csrf_token" value="<?php echo backupManageEscape(backupManageCsrfToken()); ?>">
+                                        <input type="hidden" name="instance" value="<?php echo backupManageEscape($instance); ?>">
+                                        <input type="hidden" name="filename" value="<?php echo backupManageEscape($backup["filename"] ?? ""); ?>">
+                                        <button type="submit">Download</button>
+                                    </form>
+                                    <form method="POST" style="display:inline" onsubmit="return confirm('Delete this backup?');">
+                                        <input type="hidden" name="action" value="delete">
+                                        <input type="hidden" name="csrf_token" value="<?php echo backupManageEscape(backupManageCsrfToken()); ?>">
+                                        <input type="hidden" name="instance" value="<?php echo backupManageEscape($instance); ?>">
+                                        <input type="hidden" name="filename" value="<?php echo backupManageEscape($backup["filename"] ?? ""); ?>">
+                                        <button type="submit">Delete</button>
+                                    </form>
+                                </td>
+                            </tr>
+                        <?php endforeach; ?>
+                    </tbody>
+                </table>
+            <?php endforeach; ?>
+        <?php endif; ?>
+    <?php endif; ?>
+</body>
+</html>

+ 315 - 0
backup-server/upload.php

@@ -0,0 +1,315 @@
+<?php
+
+declare(strict_types=1);
+
+$baseDir = __DIR__;
+$configFile = $baseDir . "/config.php";
+
+if (is_file($configFile)) {
+    require_once $configFile;
+}
+
+if (!defined("BACKUP_SERVER_RETENTION")) {
+    define("BACKUP_SERVER_RETENTION", 30);
+}
+if (!defined("BACKUP_SERVER_BACKUP_DIR")) {
+    define("BACKUP_SERVER_BACKUP_DIR", $baseDir . "/backups/");
+}
+if (!defined("BACKUP_SERVER_INDEX_FILE")) {
+    define("BACKUP_SERVER_INDEX_FILE", rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . "/index.json");
+}
+if (!defined("BACKUP_SERVER_SETTINGS_FILE")) {
+    define("BACKUP_SERVER_SETTINGS_FILE", rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . "/settings.json");
+}
+
+header("Content-Type: application/json; charset=utf-8");
+header("Cache-Control: no-store");
+header("X-Content-Type-Options: nosniff");
+
+function backupUploadRespond(int $status, array $payload): void
+{
+    http_response_code($status);
+    echo json_encode(
+        $payload,
+        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+    );
+    exit;
+}
+
+function backupUploadEnsureDirectory(string $dir): void
+{
+    if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
+        throw new RuntimeException("Directory cannot be created.");
+    }
+
+    @chmod($dir, 02775);
+}
+
+function backupUploadNormalizeInstance(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("Invalid instance identifier.");
+    }
+
+    return $instance;
+}
+
+function backupUploadReadJsonFile(string $file): array
+{
+    if (!is_file($file)) {
+        return [];
+    }
+
+    $decoded = json_decode((string) file_get_contents($file), true);
+    return is_array($decoded) ? $decoded : [];
+}
+
+function backupUploadWriteJsonFile(string $file, array $data): void
+{
+    backupUploadEnsureDirectory(dirname($file));
+
+    $json = json_encode(
+        $data,
+        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+    );
+    if ($json === false) {
+        throw new RuntimeException("JSON cannot be encoded.");
+    }
+
+    $tmpFile = $file . ".tmp";
+    if (file_put_contents($tmpFile, $json . PHP_EOL, LOCK_EX) === false) {
+        throw new RuntimeException("JSON cannot be written.");
+    }
+
+    @chmod($tmpFile, 0664);
+    if (!rename($tmpFile, $file)) {
+        @unlink($tmpFile);
+        throw new RuntimeException("JSON cannot be saved.");
+    }
+
+    @chmod($file, 0664);
+}
+
+function backupUploadReadIndex(): array
+{
+    $index = backupUploadReadJsonFile((string) BACKUP_SERVER_INDEX_FILE);
+    $backups = isset($index["backups"]) && is_array($index["backups"])
+        ? $index["backups"]
+        : [];
+
+    return ["backups" => array_values($backups)];
+}
+
+function backupUploadWriteIndex(array $backups): void
+{
+    backupUploadWriteJsonFile((string) BACKUP_SERVER_INDEX_FILE, [
+        "backups" => array_values($backups),
+    ]);
+}
+
+function backupUploadGetRetention(): int
+{
+    $settings = backupUploadReadJsonFile((string) BACKUP_SERVER_SETTINGS_FILE);
+    if (isset($settings["retention"])) {
+        return max(1, (int) $settings["retention"]);
+    }
+
+    return max(1, (int) BACKUP_SERVER_RETENTION);
+}
+
+function backupUploadGetAllowedInstances(): array
+{
+    $settings = backupUploadReadJsonFile((string) BACKUP_SERVER_SETTINGS_FILE);
+    $instances =
+        isset($settings["instances"]) && is_array($settings["instances"])
+            ? $settings["instances"]
+            : [];
+    $allowed = [];
+
+    foreach ($instances as $instance) {
+        $instance = (string) $instance;
+        if (
+            $instance !== "" &&
+            preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $instance) === 1
+        ) {
+            $allowed[$instance] = true;
+        }
+    }
+
+    return $allowed;
+}
+
+function backupUploadInstanceIsAllowed(string $instance): bool
+{
+    $allowed = backupUploadGetAllowedInstances();
+    return isset($allowed[$instance]);
+}
+
+function backupUploadInstanceDir(string $instance): string
+{
+    return rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR . $instance;
+}
+
+function backupUploadIsZipFile(string $path): bool
+{
+    $handle = fopen($path, "rb");
+    if ($handle === false) {
+        return false;
+    }
+
+    $signature = fread($handle, 4);
+    fclose($handle);
+
+    return $signature === "PK\x03\x04" ||
+        $signature === "PK\x05\x06" ||
+        $signature === "PK\x07\x08";
+}
+
+function backupUploadChooseFilename(string $clientFilename, string $instanceDir): string
+{
+    $clientFilename = trim($clientFilename);
+    if ($clientFilename === "") {
+        $filename = "backup-" . gmdate("Ymd-His") . ".zip";
+    } elseif (
+        basename($clientFilename) !== $clientFilename ||
+        preg_match('/^backup-\d{8}-\d{6}(?:-\d+)?\.zip$/', $clientFilename) !== 1
+    ) {
+        throw new RuntimeException("Invalid backup filename.");
+    } else {
+        $filename = $clientFilename;
+    }
+
+    $base = substr($filename, 0, -4);
+    $counter = 2;
+    while (is_file($instanceDir . DIRECTORY_SEPARATOR . $filename)) {
+        $filename = $base . "-" . $counter . ".zip";
+        $counter++;
+    }
+
+    return $filename;
+}
+
+function backupUploadApplyRetention(string $instance): void
+{
+    $index = backupUploadReadIndex();
+    $retention = backupUploadGetRetention();
+    $instanceBackups = [];
+    $otherBackups = [];
+
+    foreach ($index["backups"] as $backup) {
+        if (!is_array($backup)) {
+            continue;
+        }
+        if (($backup["instance"] ?? "") === $instance) {
+            $instanceBackups[] = $backup;
+        } else {
+            $otherBackups[] = $backup;
+        }
+    }
+
+    usort($instanceBackups, function ($left, $right) {
+        return strcmp((string) ($right["uploaded_at"] ?? ""), (string) ($left["uploaded_at"] ?? ""));
+    });
+
+    $keep = array_slice($instanceBackups, 0, $retention);
+    $remove = array_slice($instanceBackups, $retention);
+    $instanceDir = backupUploadInstanceDir($instance);
+
+    foreach ($remove as $backup) {
+        $filename = basename((string) ($backup["filename"] ?? ""));
+        if ($filename !== "" && is_file($instanceDir . DIRECTORY_SEPARATOR . $filename)) {
+            @unlink($instanceDir . DIRECTORY_SEPARATOR . $filename);
+        }
+    }
+
+    backupUploadWriteIndex(array_merge($otherBackups, $keep));
+}
+
+if ($_SERVER["REQUEST_METHOD"] !== "POST") {
+    backupUploadRespond(405, ["success" => false, "error" => "POST required."]);
+}
+
+try {
+    $instance = backupUploadNormalizeInstance((string) ($_POST["instance"] ?? ""));
+    if (!backupUploadInstanceIsAllowed($instance)) {
+        throw new RuntimeException("Instance is not allowed.");
+    }
+
+    $file = $_FILES["backup"] ?? null;
+    if (!is_array($file)) {
+        throw new RuntimeException("Backup file is missing.");
+    }
+
+    if (($file["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+        throw new RuntimeException("Upload failed with error code " . (string) ($file["error"] ?? "unknown") . ".");
+    }
+
+    $tmpName = (string) ($file["tmp_name"] ?? "");
+    if ($tmpName === "" || !is_uploaded_file($tmpName)) {
+        throw new RuntimeException("Upload is invalid.");
+    }
+
+    if (!backupUploadIsZipFile($tmpName)) {
+        throw new RuntimeException("Uploaded file must be a ZIP file.");
+    }
+
+    $instanceDir = backupUploadInstanceDir($instance);
+    backupUploadEnsureDirectory($instanceDir);
+
+    $requestedFilename = (string) ($_POST["filename"] ?? "");
+    $clientFilename = $requestedFilename !== "" ? $requestedFilename : (string) ($file["name"] ?? "");
+    $filename = backupUploadChooseFilename($requestedFilename, $instanceDir);
+    $targetPath = $instanceDir . DIRECTORY_SEPARATOR . $filename;
+
+    if (!move_uploaded_file($tmpName, $targetPath)) {
+        throw new RuntimeException("Uploaded backup cannot be stored.");
+    }
+
+    @chmod($targetPath, 0664);
+
+    $size = filesize($targetPath);
+    $sha256 = strtolower(hash_file("sha256", $targetPath) ?: "");
+    if ($size === false || $size <= 0 || !preg_match('/^[a-f0-9]{64}$/', $sha256)) {
+        @unlink($targetPath);
+        throw new RuntimeException("Stored backup could not be verified.");
+    }
+
+    $postedSha256 = strtolower(trim((string) ($_POST["sha256"] ?? "")));
+    if ($postedSha256 !== "" && (!preg_match('/^[a-f0-9]{64}$/', $postedSha256) || $postedSha256 !== $sha256)) {
+        @unlink($targetPath);
+        throw new RuntimeException("Backup checksum mismatch.");
+    }
+
+    $index = backupUploadReadIndex();
+    $record = [
+        "instance" => $instance,
+        "filename" => $filename,
+        "client_filename" => basename($clientFilename),
+        "size" => $size,
+        "sha256" => $sha256,
+        "uploaded_at" => date(DATE_ATOM),
+        "source_ip" => $_SERVER["REMOTE_ADDR"] ?? "unknown",
+    ];
+    $index["backups"][] = $record;
+    backupUploadWriteIndex($index["backups"]);
+    backupUploadApplyRetention($instance);
+
+    backupUploadRespond(200, [
+        "success" => true,
+        "instance" => $instance,
+        "filename" => $filename,
+        "size" => $size,
+        "sha256" => $sha256,
+        "retention" => backupUploadGetRetention(),
+    ]);
+} catch (Throwable $exception) {
+    backupUploadRespond(400, [
+        "success" => false,
+        "error" => $exception->getMessage(),
+    ]);
+}

+ 6 - 0
config.sample.php

@@ -92,6 +92,12 @@ define('BACKUP_REMOTE_TARGETS', [
     //     'file' => __DIR__ . '/custom-backup-uploader.php',
     //     'callback' => 'uploadPsaOrderformBackup',
     // ],
+    // [
+    //     'name' => 'Managed Backup Server',
+    //     'type' => 'managed',
+    //     'url' => 'https://backup.example.org/upload.php',
+    //     'instance' => 'stadt-freising-prod',
+    // ],
 ]);
 
 // Session settings

+ 33 - 0
docs/BACKUP_CONFIGURATION.md

@@ -185,6 +185,39 @@ define('BACKUP_REMOTE_TARGETS', [
 
 Jedes Ziel wird unabhängig versucht.
 
+## Managed Backup Server
+
+Der Managed Backup Server ist ein separates Verzeichnis `backup-server/`, das auf einem zentralen Webspace bereitgestellt werden kann.
+
+Server-Setup:
+
+1. `backup-server/config.sample.php` nach `backup-server/config.php` kopieren.
+2. `BACKUP_SERVER_PASSWORD` ändern.
+3. Schreibrechte für `backup-server/backups/` sicherstellen.
+4. `backup-server/manage.php` öffnen und einloggen.
+5. Jede erlaubte verteilte Instanz in der Management-Oberfläche anlegen.
+
+Der Upload-Endpunkt `backup-server/upload.php` benötigt keine Anmeldung. Jede verteilte Instanz muss aber einen eindeutigen `instance`-Wert senden, der serverseitig in der Management-Oberfläche erlaubt wurde.
+
+Client-Konfiguration:
+
+```php
+define('BACKUP_REMOTE_TARGETS', [
+    [
+        'name' => 'Managed Backup Server',
+        'type' => 'managed',
+        'url' => 'https://backup.example.org/upload.php',
+        'instance' => 'stadt-freising-prod',
+    ],
+]);
+```
+
+`url` ist die vollständige URL zu `upload.php`. `instance` darf Buchstaben, Zahlen, Punkte, Unterstriche und Bindestriche enthalten.
+
+Die Server-Retention gilt pro Instanz. Standard ist `30`; der Wert kann in `backup-server/config.php` und in der Management-Oberfläche geändert werden.
+
+Uploads von unbekannten Instanzen werden abgelehnt. Entfernte Instanzen können keine neuen Backups mehr senden; bereits gespeicherte Backups bleiben in der Management-Oberfläche sichtbar.
+
 ## Betriebshinweise
 
 - Remote-Zugangsdaten gehören in `config.php`, nicht in `data/settings.json`.

+ 1 - 1
docs/CONFIG_REFERENCE.md

@@ -41,7 +41,7 @@
 | `BACKUP_DIR` | Lokales Verzeichnis für Daten-Backups (Standard: `DATA_DIR . 'backups/'`) |
 | `BACKUP_LOCAL_RETENTION` | Anzahl lokal aufzubewahrender Backup-ZIPs (Standard: 4) |
 | `BACKUP_AUTO_INTERVAL_SECONDS` | Intervall für Backups durch Admin-Aktivität (Standard: 604800 = wöchentlich; 0 deaktiviert) |
-| `BACKUP_REMOTE_TARGETS` | Optionale Remote-Ziele für Backup-Uploads (`s3`, `sftp`, `custom`) |
+| `BACKUP_REMOTE_TARGETS` | Optionale Remote-Ziele für Backup-Uploads (`s3`, `sftp`, `custom`, `managed`) |
 
 ## Runtime (`data/settings.json`)
 

+ 145 - 8
includes/backup.php

@@ -450,9 +450,24 @@ function backupRemoteCapabilities(): array
             "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"] ?? ""));
@@ -544,16 +559,16 @@ function backupUploadToS3(string $archivePath, array $metadata, array $target):
     ]);
 
     $response = @file_get_contents($url, false, $context);
-    $status = 0;
     if (function_exists("http_get_last_response_headers")) {
-        $headers = http_get_last_response_headers();
-        foreach (is_array($headers) ? $headers : [] as $header) {
-            if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) {
-                $status = (int) $matches[1];
-                break;
-            }
-        }
+        $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 RuntimeException(
@@ -636,6 +651,126 @@ function backupUploadToSftp(string $archivePath, array $metadata, array $target)
     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\nContent-Length: " .
+                strlen($body) .
+                "\r\n",
+            "content" => $body,
+        ],
+    ]);
+
+    $response = @file_get_contents($url, false, $context);
+    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 RuntimeException(
+            "Managed-Backup-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
+        );
+    }
+
+    $decoded = json_decode($response, true);
+    if (!is_array($decoded) || empty($decoded["success"])) {
+        $error = is_array($decoded) ? trim((string) ($decoded["error"] ?? "")) : "";
+        throw new RuntimeException(
+            "Managed-Backup-Upload wurde abgelehnt" . ($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"] ?? ""));
@@ -683,6 +818,8 @@ function backupUploadRemotes(string $archivePath, array $metadata): array
                 $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);
             }

+ 1 - 1
scripts/create-update-zip.sh

@@ -12,7 +12,7 @@ require_command() {
 
 is_excluded_path() {
     case "$1" in
-        .gitignore|config.php|data|data/*|update-server|update-server/*|.codex|.codex/*|build|build/*|scripts|scripts/*)
+        .gitignore|config.php|data|data/*|update-server|update-server/*|.codex|.codex/*|build|build/*|scripts|scripts/*|backup-server|backup-server/*)
             return 0
             ;;
     esac