1
0

3 Ревизии f84596d926 ... 6ae3fb52ff

Автор SHA1 Съобщение Дата
  Medowar 6ae3fb52ff configuring non-cron deployment, bump version to 1.4.0 преди 1 месец
  Medowar 6c2b98e749 second batch преди 1 месец
  Medowar dfad35cc16 migration to manage-client from legacy config преди 1 месец
променени са 52 файла, в които са добавени 4235 реда и са изтрити 4663 реда
  1. 2 1
      .gitignore
  2. 6 2
      README.md
  3. 7 19
      admin/index.php
  4. 15 0
      admin/manage.php
  5. 24 246
      admin/settings.php
  6. 0 503
      admin/updater.php
  7. 0 14
      backup-server/.htaccess
  8. 0 98
      backup-server/README.md
  9. 0 2
      backup-server/backups/.gitignore
  10. 0 34
      backup-server/config.sample.php
  11. 0 8
      backup-server/index.php
  12. 0 429
      backup-server/lib.php
  13. 0 557
      backup-server/manage.php
  14. 0 347
      backup-server/s3.php
  15. 0 173
      backup-server/upload.php
  16. 4 50
      config.sample.php
  17. 1 1
      docs/ADMIN_BUSINESS_LOGIC.md
  18. 1 0
      docs/ADMIN_SYSTEM.md
  19. 0 233
      docs/BACKUP_CONFIGURATION.md
  20. 3 11
      docs/CONFIG_REFERENCE.md
  21. 1 1
      docs/SHOP_LOGIC.md
  22. 172 0
      docs/UPDATE_AND_BACKUP.md
  23. 31 0
      includes/after-update.php
  24. 0 1114
      includes/backup.php
  25. 119 0
      includes/manage-activity.php
  26. 1 1
      includes/version.php
  27. 21 0
      manage-client/.htaccess
  28. 296 0
      manage-client/bin/manage-client.php
  29. 114 0
      manage-client/config.sample.php
  30. 518 0
      manage-client/lib/backup.php
  31. 545 0
      manage-client/lib/client.php
  32. 94 0
      manage-client/lib/heartbeat.php
  33. 309 0
      manage-client/lib/hooks.php
  34. 235 0
      manage-client/lib/mysql.php
  35. 366 0
      manage-client/lib/remote.php
  36. 423 0
      manage-client/lib/updater.php
  37. 328 0
      manage-client/lib/zip.php
  38. 350 0
      manage-client/ui/panel.php
  39. 60 0
      manage-client/ui/status-partial.php
  40. 0 0
      migrations/.gitkeep
  41. 189 0
      scripts/create-release-zip.sh
  42. 0 129
      scripts/create-update-zip.sh
  43. 0 14
      update-server/.htaccess
  44. 0 41
      update-server/README.md
  45. 0 11
      update-server/config.sample.php
  46. 0 8
      update-server/index.php
  47. 0 405
      update-server/manage.php
  48. 0 4
      update-server/manifest.json
  49. 0 115
      update-server/manifest.php
  50. 0 89
      update-server/package.php
  51. 0 2
      update-server/packages/.gitignore
  52. 0 1
      update-server/packages/.gitkeep

+ 2 - 1
.gitignore

@@ -1,8 +1,9 @@
 config.php
+manage-client/config.php
 .codex
 data/reservations.php
 data/logs/
-data/updates/
+data/manage/
 data/backups/
 data/orders.json
 build/

+ 6 - 2
README.md

@@ -29,7 +29,8 @@ Dieses Projekt ist ein internes Bestellsystem für persönliche Schutzausrüstun
 - Systemeinstellungen: `data/settings.json`
 - Produkte: `data/products.json`
 - Produktbilder: `data/uploads/`
-- Backups: `data/backups/` (wird automatisch erstellt, nicht öffentlich)
+- Update- und Backup-Client: `manage-client/` (Konfiguration in `manage-client/config.php`)
+- Laufzeitdaten des Clients: `data/manage/` (Backups, Update-Sicherungen, Log; nicht öffentlich)
 
 ## Einrichtung
 
@@ -37,12 +38,15 @@ Dieses Projekt ist ein internes Bestellsystem für persönliche Schutzausrüstun
 2. Schreibrechte auf `data/` und `data/ratelimit/` (für Rate-Limits) sicherstellen.
 3. Statische Dateien bereitstellen: `favicon.png` (Document Root), `assets/branding/`, `assets/fonts/`, `assets/no-image.jpg`.
 4. Adminzugänge in `data/admins.json` auf dem Server pflegen (nicht aus dem Repo übernehmen).
-5. Empfängeradresse, PDF-Anhang und Backups im Admin unter `Einstellungen` prüfen.
+5. Empfängeradresse und PDF-Anhang im Admin unter `Einstellungen` prüfen.
 6. Kategorien, FAQ und Organisationen im Admin unter `Einstellungen` pflegen.
 7. Apache: `.htaccess` aktiv (schützt `config.php` und JSON unter `data/`).
+8. `manage-client/config.sample.php` nach `manage-client/config.php` kopieren und Instanz + Token des Manage-Servers eintragen (siehe [docs/UPDATE_AND_BACKUP.md](docs/UPDATE_AND_BACKUP.md)).
 
 Weitere Konstanten: [docs/CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md).
 
+Updates und Backups: [docs/UPDATE_AND_BACKUP.md](docs/UPDATE_AND_BACKUP.md).
+
 Technische Dokumentation im Browser: [docs/index.php](docs/index.php) (Markdown wird mit [marked](https://marked.js.org/) gerendert).
 
 ## Hinweise

+ 7 - 19
admin/index.php

@@ -1,7 +1,7 @@
 <?php
 require_once __DIR__ . '/../config.php';
 require_once __DIR__ . '/../includes/functions.php';
-require_once __DIR__ . '/../includes/backup.php';
+require_once __DIR__ . '/../includes/manage-activity.php';
 
 if (empty($_SESSION['admin_logged_in'])) {
     header('Location: login.php');
@@ -9,24 +9,12 @@ if (empty($_SESSION['admin_logged_in'])) {
 }
 
 $pageTitle = 'Admin Dashboard';
-$backupAutoMessage = '';
-$backupAutoMessageType = '';
-
-try {
-    $backup = backupCreateAutomaticIfDue();
-    if ($backup !== null) {
-        $backupAutoMessage =
-            'Automatisches Backup wurde erstellt: ' .
-            $backup['filename'] .
-            '.';
-        $backupAutoMessageType = 'success';
-    }
-} catch (Throwable $exception) {
-    $backupAutoMessage =
-        'Automatisches Backup konnte nicht erstellt werden: ' .
-        $exception->getMessage();
-    $backupAutoMessageType = 'warning';
-}
+
+// No cron on this deployment: the periodic manage-client jobs run here, on
+// admin activity. See includes/manage-activity.php.
+$manageActivity = manageActivityRunDueTasks();
+$backupAutoMessage = $manageActivity['message'];
+$backupAutoMessageType = $manageActivity['type'];
 $orders = getOrders();
 
 $backorderGroups = getBackorderGroups();

+ 15 - 0
admin/manage.php

@@ -0,0 +1,15 @@
+<?php
+
+// Update and backup for this installation. The page itself holds no logic:
+// manage-client/ui/panel.php calls the same functions as the CLI
+// (manage-client/bin/manage-client.php).
+
+require_once __DIR__ . "/../config.php";
+require_once __DIR__ . "/../includes/functions.php";
+
+if (empty($_SESSION["admin_logged_in"])) {
+    header("Location: login.php");
+    exit();
+}
+
+require __DIR__ . "/../manage-client/ui/panel.php";

+ 24 - 246
admin/settings.php

@@ -2,79 +2,7 @@
 require_once __DIR__ . "/../config.php";
 require_once __DIR__ . "/../includes/functions.php";
 require_once __DIR__ . "/../includes/version.php";
-require_once __DIR__ . "/../includes/backup.php";
-
-if (!defined("UPDATE_MANIFEST_URL")) {
-    define("UPDATE_MANIFEST_URL", "");
-}
-
-function settingsUpdaterVersionCompareValue(string $version): string
-{
-    return ltrim(trim($version), "vV");
-}
-
-function settingsGetUpdaterStatus(): array
-{
-    $manifestUrl = trim((string) UPDATE_MANIFEST_URL);
-    if ($manifestUrl === "") {
-        return [
-            "label" => "Update-Ziel ist nicht konfiguriert.",
-            "available" => false,
-            "version" => "",
-        ];
-    }
-
-    $context = stream_context_create([
-        "http" => [
-            "method" => "GET",
-            "timeout" => 3,
-            "ignore_errors" => true,
-            "header" => "User-Agent: PSA-Orderform-Settings/" . APP_VERSION . "\r\n",
-        ],
-    ]);
-
-    $body = @file_get_contents($manifestUrl, false, $context);
-    if ($body === false) {
-        return [
-            "label" => "Update-Status konnte nicht geladen werden.",
-            "available" => false,
-            "version" => "",
-        ];
-    }
-
-    $manifest = json_decode($body, true);
-    if (!is_array($manifest)) {
-        return [
-            "label" => "Update-Status ist ungültig.",
-            "available" => false,
-            "version" => "",
-        ];
-    }
-
-    $version = trim((string) ($manifest["version"] ?? $manifest["latest"] ?? ""));
-    if (!preg_match('/^v\d+\.\d+\.\d+$/', $version)) {
-        return [
-            "label" => "Update-Version ist ungültig.",
-            "available" => false,
-            "version" => "",
-        ];
-    }
-
-    $available =
-        version_compare(
-            settingsUpdaterVersionCompareValue($version),
-            settingsUpdaterVersionCompareValue(APP_VERSION),
-            ">",
-        );
-
-    return [
-        "label" => $available
-            ? "Update verfügbar: " . $version
-            : "Kein Update verfügbar.",
-        "available" => $available,
-        "version" => $version,
-    ];
-}
+require_once __DIR__ . "/../manage-client/lib/client.php";
 
 function settingsFormatBackupDate(string $date): string
 {
@@ -86,43 +14,6 @@ function settingsFormatBackupDate(string $date): string
     return date("d.m.Y H:i", $timestamp);
 }
 
-function settingsGetBackupUploadLabel(array $backup): string
-{
-    $uploads =
-        isset($backup["remote_uploads"]) && is_array($backup["remote_uploads"])
-            ? $backup["remote_uploads"]
-            : [];
-
-    if (empty($uploads)) {
-        return "Nur lokal";
-    }
-
-    $successful = 0;
-    foreach ($uploads as $upload) {
-        if (is_array($upload) && !empty($upload["success"])) {
-            $successful++;
-        }
-    }
-
-    if ($successful === count($uploads)) {
-        return "Remote erfolgreich (" . $successful . ")";
-    }
-    if ($successful > 0) {
-        return "Teilweise erfolgreich (" . $successful . "/" . count($uploads) . ")";
-    }
-
-    return "Remote fehlgeschlagen";
-}
-
-function settingsGetBackupCapabilityLabel(array $capability): string
-{
-    if (empty($capability["configured"])) {
-        return "nicht konfiguriert";
-    }
-
-    return !empty($capability["available"]) ? "bereit" : "nicht verfügbar";
-}
-
 function settingsIsSuperAdmin(): bool
 {
     return normalizeAdminUsername($_SESSION['admin_username'] ?? "") === "admin";
@@ -190,47 +81,6 @@ function settingsWriteLocalConfig(string $content): void
     }
 }
 
-function settingsFindBackupByFilename(string $filename): ?array
-{
-    if ($filename === "" || basename($filename) !== $filename) {
-        return null;
-    }
-
-    foreach (backupListBackups() as $backup) {
-        if (($backup["filename"] ?? "") === $filename) {
-            return $backup;
-        }
-    }
-
-    return null;
-}
-
-function settingsSendBackupDownload(array $backup): void
-{
-    $filename = basename((string) ($backup["filename"] ?? ""));
-    $path = backupGetDirectory() . $filename;
-
-    if ($filename === "" || !is_file($path) || !is_readable($path)) {
-        throw new RuntimeException("Backup-Datei wurde nicht gefunden.");
-    }
-    $size = filesize($path);
-    if ($size === false) {
-        throw new RuntimeException("Backup-Dateigröße konnte nicht gelesen werden.");
-    }
-
-    logAccess("Backup downloaded", [
-        "filename" => $filename,
-    ]);
-
-    header("Content-Type: application/zip");
-    header('Content-Disposition: attachment; filename="' . $filename . '"');
-    header("Content-Length: " . (string) $size);
-    header("X-Content-Type-Options: nosniff");
-
-    readfile($path);
-    exit();
-}
-
 if (empty($_SESSION['admin_logged_in'])) {
     header("Location: login.php");
     exit();
@@ -264,41 +114,6 @@ if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_settings'])) {
             $messageType = "error";
         }
     }
-} elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['create_backup'])) {
-    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
-        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
-        $messageType = "error";
-    } else {
-        try {
-            $backup = backupCreate("manual");
-            $message =
-                "Backup wurde erstellt: " .
-                $backup["filename"] .
-                " (" .
-                backupFormatBytes((int) $backup["size"]) .
-                ").";
-            $messageType = "success";
-        } catch (Throwable $exception) {
-            $message = "Backup konnte nicht erstellt werden: " . $exception->getMessage();
-            $messageType = "error";
-        }
-    }
-} elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['download_backup'])) {
-    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
-        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
-        $messageType = "error";
-    } else {
-        try {
-            $backup = settingsFindBackupByFilename((string) ($_POST["backup_filename"] ?? ""));
-            if ($backup === null) {
-                throw new RuntimeException("Backup wurde nicht gefunden.");
-            }
-            settingsSendBackupDownload($backup);
-        } catch (Throwable $exception) {
-            $message = "Backup konnte nicht heruntergeladen werden: " . $exception->getMessage();
-            $messageType = "error";
-        }
-    }
 } elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_local_config'])) {
     if (!$isSuperAdmin) {
         http_response_code(403);
@@ -567,9 +382,7 @@ if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_settings'])) {
 }
 
 $settings = getSystemSettings();
-$updaterStatus = settingsGetUpdaterStatus();
-$backupCapabilities = backupRemoteCapabilities();
-$backups = backupListBackups();
+$manageStatus = manageClientStatus();
 $localConfigContent = $isSuperAdmin ? settingsReadLocalConfig() : "";
 
 $categories = getCategories();
@@ -855,66 +668,31 @@ include __DIR__ . "/../includes/header.php";
     </form>
 </div>
 
-<div class="panel panel-lg mt-4" id="backups">
-    <h3>Backups</h3>
-    <p>Lokale Aufbewahrung: <?php echo (int) backupGetRetentionLimit(); ?> Backups</p>
-    <p>Automatisches Intervall: <?php echo (int) floor(((int) BACKUP_AUTO_INTERVAL_SECONDS) / 86400); ?> Tage</p>
+<div class="panel panel-lg mt-4" id="update-backup">
+    <h3>Update &amp; Backup</h3>
+    <p>Installierte Version: <?php echo escape($manageStatus["version"] !== "" ? $manageStatus["version"] : "unbekannt"); ?></p>
+    <?php if (!$manageStatus["configured"]): ?>
+        <p>Der Manage-Client ist nicht konfiguriert. <code>manage-client/config.php</code> fehlt oder ist unvollständig.</p>
+    <?php elseif ($manageStatus["update"] !== null && $manageStatus["update"]["available"]): ?>
+        <p>Update verfügbar: <?php echo escape($manageStatus["update"]["latest"]); ?></p>
+    <?php elseif ($manageStatus["update"] !== null): ?>
+        <p>Kein Update verfügbar.</p>
+    <?php else: ?>
+        <p>Update-Status konnte nicht geladen werden<?php echo $manageStatus["update_error"] !== null
+            ? ": " . escape($manageStatus["update_error"])
+            : "."; ?></p>
+    <?php endif; ?>
     <p>
-        S3: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["s3"])); ?> ·
-        SFTP: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["sftp"])); ?> ·
-        Custom: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["custom"])); ?> ·
-        Managed: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["managed"])); ?>
+        Letztes Backup:
+        <?php echo $manageStatus["last_backup_at"] !== null
+            ? escape(settingsFormatBackupDate($manageStatus["last_backup_at"]))
+            : "noch keines"; ?>
+        (<?php echo count($manageStatus["backups"]); ?> lokal)
     </p>
-
-    <form method="POST" action="settings.php#backups" class="inline-form">
-        <?php echo csrfField(); ?>
-        <button type="submit" name="create_backup" class="btn">Backup erstellen</button>
-    </form>
-
-    <h4 class="mt-4">Letzte Backups</h4>
-    <?php if (empty($backups)): ?>
-        <p>Es wurden noch keine Backups erstellt.</p>
-    <?php else: ?>
-        <div class="table-responsive">
-            <table class="responsive-table">
-                <thead>
-                    <tr>
-                        <th>Erstellt</th>
-                        <th>Auslöser</th>
-                        <th>Größe</th>
-                        <th>Dateien</th>
-                        <th>Remote</th>
-                        <th>Aktionen</th>
-                    </tr>
-                </thead>
-                <tbody>
-                    <?php foreach ($backups as $backup): ?>
-                        <tr>
-                            <td data-label="Erstellt"><?php echo escape(settingsFormatBackupDate((string) ($backup["created_at"] ?? ""))); ?></td>
-                            <td data-label="Auslöser"><?php echo (($backup["trigger"] ?? "") === "automatic") ? "Automatisch" : "Manuell"; ?></td>
-                            <td data-label="Größe"><?php echo escape(backupFormatBytes((int) ($backup["size"] ?? 0))); ?></td>
-                            <td data-label="Dateien"><?php echo (int) ($backup["file_count"] ?? 0); ?></td>
-                            <td data-label="Remote"><?php echo escape(settingsGetBackupUploadLabel($backup)); ?></td>
-                            <td data-label="Aktionen">
-                                <form method="POST" action="settings.php#backups" class="inline-form">
-                                    <?php echo csrfField(); ?>
-                                    <input type="hidden" name="backup_filename" value="<?php echo escape($backup["filename"] ?? ""); ?>">
-                                    <button type="submit" name="download_backup" class="btn btn-secondary btn-small">Download</button>
-                                </form>
-                            </td>
-                        </tr>
-                    <?php endforeach; ?>
-                </tbody>
-            </table>
-        </div>
+    <?php if ($manageStatus["pending_migrations"] !== []): ?>
+        <p><?php echo count($manageStatus["pending_migrations"]); ?> offene Migration(en).</p>
     <?php endif; ?>
-</div>
-
-<div class="panel panel-lg mt-4">
-    <h3>Updater</h3>
-    <p>Installierte Version: <?php echo escape(APP_VERSION); ?></p>
-    <p>Update-Status: <?php echo escape($updaterStatus["label"]); ?></p>
-    <p><a href="updater.php" class="btn btn-secondary">Updater öffnen</a></p>
+    <p><a href="manage.php" class="btn btn-secondary">Update &amp; Backup öffnen</a></p>
 </div>
 
 <?php if ($isSuperAdmin): ?>

+ 0 - 503
admin/updater.php

@@ -1,503 +0,0 @@
-<?php
-
-require_once __DIR__ . "/../config.php";
-require_once __DIR__ . "/../includes/version.php";
-
-if (empty($_SESSION["admin_logged_in"])) {
-    header("Location: login.php");
-    exit();
-}
-
-if (!defined("UPDATE_MANIFEST_URL")) {
-    define("UPDATE_MANIFEST_URL", "");
-}
-if (!defined("UPDATE_WORK_DIR")) {
-    define("UPDATE_WORK_DIR", DATA_DIR . "updates/work/");
-}
-if (!defined("UPDATE_BACKUP_DIR")) {
-    define("UPDATE_BACKUP_DIR", DATA_DIR . "updates/backups/");
-}
-
-$appRoot = realpath(__DIR__ . "/..");
-$messages = [];
-$errors = [];
-
-function updaterEscape($value): string
-{
-    return htmlspecialchars((string) $value, ENT_QUOTES, "UTF-8");
-}
-
-function updaterCsrfToken(): string
-{
-    if (empty($_SESSION["updater_csrf_token"])) {
-        $_SESSION["updater_csrf_token"] = bin2hex(random_bytes(32));
-    }
-
-    return $_SESSION["updater_csrf_token"];
-}
-
-function updaterValidateCsrfToken(string $token): bool
-{
-    return !empty($_SESSION["updater_csrf_token"]) &&
-        hash_equals($_SESSION["updater_csrf_token"], $token);
-}
-
-function updaterVersionToCompare(string $version): string
-{
-    return ltrim(trim($version), "vV");
-}
-
-function updaterIsVersion(string $version): bool
-{
-    return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
-}
-
-function updaterEnsureDirectory(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 updaterRemoveDirectory(string $dir): void
-{
-    if (!is_dir($dir)) {
-        return;
-    }
-
-    $items = new RecursiveIteratorIterator(
-        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
-        RecursiveIteratorIterator::CHILD_FIRST,
-    );
-
-    foreach ($items as $item) {
-        if ($item->isDir()) {
-            rmdir($item->getPathname());
-        } else {
-            unlink($item->getPathname());
-        }
-    }
-
-    rmdir($dir);
-}
-
-function updaterHttpGet(string $url, int $timeout = 30): string
-{
-    if (!filter_var($url, FILTER_VALIDATE_URL)) {
-        throw new RuntimeException("Invalid URL: " . $url);
-    }
-
-    $context = stream_context_create([
-        "http" => [
-            "method" => "GET",
-            "timeout" => $timeout,
-            "ignore_errors" => true,
-            "header" => "User-Agent: PSA-Orderform-Updater/" . APP_VERSION . "\r\n",
-        ],
-    ]);
-
-    $body = @file_get_contents($url, false, $context);
-    $status = 0;
-    $responseHeaders = function_exists("http_get_last_response_headers")
-        ? http_get_last_response_headers()
-        : [];
-    if (is_array($responseHeaders)) {
-        foreach ($responseHeaders as $header) {
-            if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches)) {
-                $status = (int) $matches[1];
-            }
-        }
-    }
-
-    if ($body === false || ($status >= 400 && $status < 600)) {
-        throw new RuntimeException(
-            "HTTP request failed" . ($status > 0 ? " with status " . $status : "") . ".",
-        );
-    }
-
-    return $body;
-}
-
-function updaterFetchManifest(): array
-{
-    $url = trim((string) UPDATE_MANIFEST_URL);
-    if ($url === "") {
-        throw new RuntimeException("UPDATE_MANIFEST_URL is not configured.");
-    }
-
-    $body = updaterHttpGet($url, 15);
-    $manifest = json_decode($body, true);
-    if (!is_array($manifest)) {
-        throw new RuntimeException("Manifest response is not valid JSON.");
-    }
-
-    $version = trim((string) ($manifest["version"] ?? $manifest["latest"] ?? ""));
-    $packageUrl = trim((string) ($manifest["package_url"] ?? ""));
-    $sha256 = strtolower(trim((string) ($manifest["sha256"] ?? "")));
-    $size = isset($manifest["size"]) ? (int) $manifest["size"] : 0;
-    $publishedAt = trim((string) ($manifest["published_at"] ?? ""));
-
-    if (!updaterIsVersion($version)) {
-        throw new RuntimeException("Manifest version is invalid.");
-    }
-    if (!filter_var($packageUrl, FILTER_VALIDATE_URL)) {
-        throw new RuntimeException("Manifest package URL is invalid.");
-    }
-    if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
-        throw new RuntimeException("Manifest checksum is invalid.");
-    }
-
-    return [
-        "version" => $version,
-        "package_url" => $packageUrl,
-        "sha256" => $sha256,
-        "size" => $size,
-        "published_at" => $publishedAt,
-    ];
-}
-
-function updaterDownloadPackage(array $manifest, string $targetFile): void
-{
-    updaterEnsureDirectory(dirname($targetFile));
-
-    $data = updaterHttpGet($manifest["package_url"], 120);
-    if ($data === "") {
-        throw new RuntimeException("Downloaded package is empty.");
-    }
-
-    if (file_put_contents($targetFile, $data, LOCK_EX) === false) {
-        throw new RuntimeException("Downloaded package cannot be written.");
-    }
-
-    if ($manifest["size"] > 0 && filesize($targetFile) !== $manifest["size"]) {
-        unlink($targetFile);
-        throw new RuntimeException("Downloaded package size mismatch.");
-    }
-
-    $actualHash = strtolower(hash_file("sha256", $targetFile) ?: "");
-    if ($actualHash !== $manifest["sha256"]) {
-        unlink($targetFile);
-        throw new RuntimeException("Package checksum mismatch.");
-    }
-}
-
-function updaterValidateZipEntry(string $entry): bool
-{
-    $entry = str_replace("\\", "/", $entry);
-    $normalized = trim($entry, "/");
-
-    if (
-        $normalized === "" ||
-        str_contains($entry, "\0") ||
-        str_starts_with($entry, "/") ||
-        preg_match('/^[A-Za-z]:\//', $entry)
-    ) {
-        return false;
-    }
-
-    foreach (explode("/", $normalized) as $segment) {
-        if ($segment === "" || $segment === "." || $segment === "..") {
-            return false;
-        }
-    }
-
-    return true;
-}
-
-function updaterExtractPackage(string $zipFile, string $stageDir): void
-{
-    if (!class_exists("ZipArchive")) {
-        throw new RuntimeException("PHP ZipArchive extension is not available.");
-    }
-
-    updaterRemoveDirectory($stageDir);
-    updaterEnsureDirectory($stageDir);
-
-    $zip = new ZipArchive();
-    if ($zip->open($zipFile) !== true) {
-        throw new RuntimeException("Downloaded package is not a readable ZIP file.");
-    }
-
-    $hasAppFile = false;
-    for ($i = 0; $i < $zip->numFiles; $i++) {
-        $name = (string) $zip->getNameIndex($i);
-        if (!updaterValidateZipEntry($name)) {
-            $zip->close();
-            throw new RuntimeException("ZIP contains an unsafe path: " . $name);
-        }
-
-        if (
-            $name === "index.php" ||
-            str_starts_with($name, "admin/") ||
-            str_starts_with($name, "includes/")
-        ) {
-            $hasAppFile = true;
-        }
-    }
-
-    if (!$hasAppFile) {
-        $zip->close();
-        throw new RuntimeException("ZIP does not look like an app-root release package.");
-    }
-
-    if (!$zip->extractTo($stageDir)) {
-        $zip->close();
-        throw new RuntimeException("ZIP package cannot be extracted.");
-    }
-
-    $zip->close();
-}
-
-function updaterRelativePath(string $path, string $baseDir): string
-{
-    return ltrim(str_replace("\\", "/", substr($path, strlen($baseDir))), "/");
-}
-
-function updaterShouldSkipPath(string $relativePath): bool
-{
-    $relativePath = trim(str_replace("\\", "/", $relativePath), "/");
-
-    return $relativePath === "" ||
-        $relativePath === "config.php" ||
-        $relativePath === "data" ||
-        str_starts_with($relativePath, "data/") ||
-        $relativePath === ".git" ||
-        str_starts_with($relativePath, ".git/");
-}
-
-function updaterCopyWithBackup(string $stageDir, string $appRoot, string $backupDir): array
-{
-    updaterEnsureDirectory($backupDir);
-
-    $copied = 0;
-    $backedUp = 0;
-    $skipped = 0;
-
-    $items = new RecursiveIteratorIterator(
-        new RecursiveDirectoryIterator($stageDir, FilesystemIterator::SKIP_DOTS),
-        RecursiveIteratorIterator::SELF_FIRST,
-    );
-
-    foreach ($items as $item) {
-        $relativePath = updaterRelativePath($item->getPathname(), $stageDir);
-        if (updaterShouldSkipPath($relativePath)) {
-            $skipped++;
-            continue;
-        }
-
-        $targetPath = $appRoot . DIRECTORY_SEPARATOR . $relativePath;
-
-        if ($item->isDir()) {
-            updaterEnsureDirectory($targetPath);
-            continue;
-        }
-
-        updaterEnsureDirectory(dirname($targetPath));
-
-        if (file_exists($targetPath)) {
-            $backupPath = $backupDir . DIRECTORY_SEPARATOR . $relativePath;
-            updaterEnsureDirectory(dirname($backupPath));
-            if (!copy($targetPath, $backupPath)) {
-                throw new RuntimeException("Cannot back up file: " . $relativePath);
-            }
-            $backedUp++;
-        }
-
-        if (!copy($item->getPathname(), $targetPath)) {
-            throw new RuntimeException("Cannot deploy file: " . $relativePath);
-        }
-
-        @chmod($targetPath, fileperms($item->getPathname()) & 0777);
-        $copied++;
-    }
-
-    return [
-        "copied" => $copied,
-        "backed_up" => $backedUp,
-        "skipped" => $skipped,
-    ];
-}
-
-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");
-    $workDir = rtrim((string) UPDATE_WORK_DIR, "/\\") . DIRECTORY_SEPARATOR . $runId;
-    $stageDir = $workDir . DIRECTORY_SEPARATOR . "stage";
-    $zipFile = $workDir . DIRECTORY_SEPARATOR . "package.zip";
-    $backupDir = rtrim((string) UPDATE_BACKUP_DIR, "/\\") .
-        DIRECTORY_SEPARATOR .
-        $runId .
-        "-" .
-        $manifest["version"];
-
-    updaterEnsureDirectory($workDir);
-    updaterDownloadPackage($manifest, $zipFile);
-    updaterExtractPackage($zipFile, $stageDir);
-    $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"],
-    ];
-}
-
-$manifest = null;
-$updateAvailable = false;
-
-try {
-    $manifest = updaterFetchManifest();
-    $updateAvailable =
-        version_compare(
-            updaterVersionToCompare($manifest["version"]),
-            updaterVersionToCompare(APP_VERSION),
-            ">",
-        );
-} catch (Throwable $exception) {
-    $errors[] = $exception->getMessage();
-}
-
-if ($_SERVER["REQUEST_METHOD"] === "POST") {
-    if (!updaterValidateCsrfToken((string) ($_POST["csrf_token"] ?? ""))) {
-        $errors[] = "Invalid token. Please reload the page and try again.";
-    } elseif ($appRoot === false) {
-        $errors[] = "Application root cannot be resolved.";
-    } else {
-        try {
-            $manifest = updaterFetchManifest();
-            $force = !empty($_POST["force_redeploy"]);
-            $updateAvailable =
-                version_compare(
-                    updaterVersionToCompare($manifest["version"]),
-                    updaterVersionToCompare(APP_VERSION),
-                    ">",
-                );
-
-            if (!$updateAvailable && !$force) {
-                throw new RuntimeException(
-                    "No newer update is available. Enable force redeployment to deploy this package anyway.",
-                );
-            }
-
-            $result = updaterDeploy($manifest, $appRoot);
-            $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) {
-            $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>Updater</title>
-</head>
-<body>
-    <h1>Updater</h1>
-
-    <p><a href="settings.php">Back to settings</a></p>
-
-    <?php foreach ($messages as $message): ?>
-        <p><strong><?php echo updaterEscape($message); ?></strong></p>
-    <?php endforeach; ?>
-
-    <?php foreach ($errors as $error): ?>
-        <p><strong>Error:</strong> <?php echo updaterEscape($error); ?></p>
-    <?php endforeach; ?>
-
-    <table border="1" cellpadding="6" cellspacing="0">
-        <tbody>
-            <tr>
-                <th align="left">Installed version</th>
-                <td><?php echo updaterEscape(APP_VERSION); ?></td>
-            </tr>
-            <tr>
-                <th align="left">Update target URL</th>
-                <td><?php echo updaterEscape(UPDATE_MANIFEST_URL); ?></td>
-            </tr>
-            <tr>
-                <th align="left">Available version</th>
-                <td><?php echo updaterEscape($manifest["version"] ?? "Unavailable"); ?></td>
-            </tr>
-            <tr>
-                <th align="left">Package URL</th>
-                <td><?php echo updaterEscape($manifest["package_url"] ?? "Unavailable"); ?></td>
-            </tr>
-            <tr>
-                <th align="left">SHA-256</th>
-                <td><?php echo updaterEscape($manifest["sha256"] ?? "Unavailable"); ?></td>
-            </tr>
-            <tr>
-                <th align="left">Published at</th>
-                <td><?php echo updaterEscape($manifest["published_at"] ?? "Unavailable"); ?></td>
-            </tr>
-            <tr>
-                <th align="left">Update available</th>
-                <td><?php echo $updateAvailable ? "Yes" : "No"; ?></td>
-            </tr>
-        </tbody>
-    </table>
-
-    <h2>Manual deployment</h2>
-    <form method="POST">
-        <input type="hidden" name="csrf_token" value="<?php echo updaterEscape(updaterCsrfToken()); ?>">
-        <p>
-            <label>
-                <input type="checkbox" name="force_redeploy" value="1">
-                Force redeployment
-            </label>
-        </p>
-        <button type="submit" name="deploy_update" value="1">Deploy update</button>
-    </form>
-</body>
-</html>

+ 0 - 14
backup-server/.htaccess

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

+ 0 - 98
backup-server/README.md

@@ -1,98 +0,0 @@
-# PSA Orderform Backup Server
-
-> **DEPRECATED** — This component is deprecated and no longer maintained. It is
-> kept in the repository for reference only and will be removed in a future
-> release. Do not deploy it for new installations. Existing deployments keep
-> working, but no fixes or features are planned.
-
-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.
-
-With S3 enabled (see below) this value controls only the **local** copies. The minimum is `1`, so at least the newest backup always stays on local disk.
-
-## S3 archive (optional)
-
-The server can additionally archive every backup to an S3-compatible object storage (e.g. Hetzner Object Storage, MinIO). Local disk then acts as a small hot cache with the newest backups, while the bucket holds the complete archive.
-
-Enable it in `config.php`:
-
-```php
-define("BACKUP_SERVER_S3_ENABLED", true);
-define("BACKUP_SERVER_S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
-define("BACKUP_SERVER_S3_REGION", "fsn1");
-define("BACKUP_SERVER_S3_BUCKET", "my-backup-bucket");
-define("BACKUP_SERVER_S3_PREFIX", "psa-backups");
-define("BACKUP_SERVER_S3_ACCESS_KEY", "...");
-define("BACKUP_SERVER_S3_SECRET_KEY", "...");
-```
-
-By default the server uses **virtual-hosted-style** addressing (`https://<bucket>.<endpoint>/<key>`), which Hetzner and most S3-compatible providers expect. If your provider requires **path-style** (`https://<endpoint>/<bucket>/<key>`), set `BACKUP_SERVER_S3_PATH_STYLE` to `true`.
-
-Behavior:
-
-- Every received backup is stored locally first and then uploaded to S3 (AWS Signature V4, no SDK required). Objects are stored as `<prefix>/<instance>/<filename>`.
-- A local copy is only deleted after it fell out of the local retention window **and** its S3 copy is confirmed. While S3 is unreachable, local copies accumulate beyond the retention setting instead of being deleted.
-- S3 failures never fail a client upload. They are logged to `backups/s3.log` and shown in the management UI; failed uploads are retried on the next upload for that instance or via the "Retry S3 uploads now" button.
-- S3 has its own count-based retention ("S3 backups retained per instance", default `BACKUP_SERVER_S3_RETENTION` = 365). Backups that age out of S3 are deleted from the bucket.
-- S3-only backups remain listed in the management UI and are downloaded through the server, so the bucket can (and should) stay private.
-- When enabling S3 on an installation with existing backups, use "Retry S3 uploads now" once to backfill the archive; otherwise the first client upload per instance flushes the whole backlog within that request.
-
-Limitations: uploads to S3 hold the whole file in memory, so a single backup must fit into PHP's `memory_limit`. Downloads from S3 are streamed and have no such limit. Concurrent uploads for the same instance may race on `index.json` (pre-existing limitation).
-
-Troubleshooting: S3 errors are written to `backups/s3.log` with the provider's error code and, on a rejected request, a diagnostic showing the HTTP status chain, any redirect target, and the request id. `AccessDenied` or a redirect in the status chain usually means the addressing style is wrong — try flipping `BACKUP_SERVER_S3_PATH_STYLE`. `SignatureDoesNotMatch` usually means a wrong region or secret key. The server never follows S3 redirects, so a `3xx` in the log is reported rather than silently retried against the wrong host.
-
-## 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, JSON, and log files as well as the internal includes (`lib.php`, `s3.php`) are not directly readable through Apache. Downloads should use the authenticated management UI.

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

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

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

@@ -1,34 +0,0 @@
-<?php
-
-// DEPRECATED: The bundled managed backup server is deprecated and unmaintained.
-// It is kept for reference only and will be removed in a future release.
-// Do not deploy it for new installations.
-
-// 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");
-
-// S3-compatible object storage (optional). Leave disabled for local-only behavior.
-// When enabled, every upload is archived to S3 and only the newest local copies
-// are kept on disk. All five connection values must be filled in.
-define("BACKUP_SERVER_S3_ENABLED", false);
-define("BACKUP_SERVER_S3_ENDPOINT", "https://fsn1.your-objectstorage.com"); // e.g. Hetzner
-define("BACKUP_SERVER_S3_REGION", "fsn1");
-define("BACKUP_SERVER_S3_BUCKET", "");
-define("BACKUP_SERVER_S3_PREFIX", "psa-backups"); // key prefix inside the bucket, may be ""
-define("BACKUP_SERVER_S3_ACCESS_KEY", "");
-define("BACKUP_SERVER_S3_SECRET_KEY", "");
-// Addressing style. false = virtual-hosted (https://<bucket>.<endpoint>/<key>),
-// which Hetzner and most providers expect. Set true only if your provider
-// requires path-style (https://<endpoint>/<bucket>/<key>).
-define("BACKUP_SERVER_S3_PATH_STYLE", false);
-define("BACKUP_SERVER_S3_TIMEOUT", 120);   // seconds per HTTP request
-define("BACKUP_SERVER_S3_RETENTION", 365); // default S3 backups kept per instance
-define("BACKUP_SERVER_LOG_FILE", BACKUP_SERVER_BACKUP_DIR . "s3.log");

+ 0 - 8
backup-server/index.php

@@ -1,8 +0,0 @@
-<?php
-
-// DEPRECATED: The bundled managed backup server is deprecated and unmaintained.
-// It is kept for reference only and will be removed in a future release.
-// Do not deploy it for new installations.
-
-header("Location: manage.php");
-exit;

+ 0 - 429
backup-server/lib.php

@@ -1,429 +0,0 @@
-<?php
-
-// DEPRECATED: The bundled managed backup server is deprecated and unmaintained.
-// It is kept for reference only and will be removed in a future release.
-// Do not deploy it for new installations.
-
-declare(strict_types=1);
-
-// Shared helpers for the backup server. Included by upload.php and manage.php.
-
-$backupServerConfigFile = __DIR__ . "/config.php";
-if (is_file($backupServerConfigFile)) {
-    require_once $backupServerConfigFile;
-}
-
-if (!defined("BACKUP_SERVER_RETENTION")) {
-    define("BACKUP_SERVER_RETENTION", 30);
-}
-if (!defined("BACKUP_SERVER_BACKUP_DIR")) {
-    define("BACKUP_SERVER_BACKUP_DIR", __DIR__ . "/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 (!defined("BACKUP_SERVER_S3_ENABLED")) {
-    define("BACKUP_SERVER_S3_ENABLED", false);
-}
-if (!defined("BACKUP_SERVER_S3_ENDPOINT")) {
-    define("BACKUP_SERVER_S3_ENDPOINT", "");
-}
-if (!defined("BACKUP_SERVER_S3_REGION")) {
-    define("BACKUP_SERVER_S3_REGION", "");
-}
-if (!defined("BACKUP_SERVER_S3_BUCKET")) {
-    define("BACKUP_SERVER_S3_BUCKET", "");
-}
-if (!defined("BACKUP_SERVER_S3_PREFIX")) {
-    define("BACKUP_SERVER_S3_PREFIX", "");
-}
-if (!defined("BACKUP_SERVER_S3_ACCESS_KEY")) {
-    define("BACKUP_SERVER_S3_ACCESS_KEY", "");
-}
-if (!defined("BACKUP_SERVER_S3_SECRET_KEY")) {
-    define("BACKUP_SERVER_S3_SECRET_KEY", "");
-}
-if (!defined("BACKUP_SERVER_S3_PATH_STYLE")) {
-    define("BACKUP_SERVER_S3_PATH_STYLE", false);
-}
-if (!defined("BACKUP_SERVER_S3_TIMEOUT")) {
-    define("BACKUP_SERVER_S3_TIMEOUT", 120);
-}
-if (!defined("BACKUP_SERVER_S3_RETENTION")) {
-    define("BACKUP_SERVER_S3_RETENTION", 365);
-}
-if (!defined("BACKUP_SERVER_LOG_FILE")) {
-    define("BACKUP_SERVER_LOG_FILE", rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . "/s3.log");
-}
-
-require_once __DIR__ . "/s3.php";
-
-function backupServerEnsureDirectory(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 backupServerReadJsonFile(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 backupServerWriteJsonFile(string $file, array $data): void
-{
-    backupServerEnsureDirectory(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 backupServerReadIndex(): array
-{
-    $index = backupServerReadJsonFile((string) BACKUP_SERVER_INDEX_FILE);
-    $backups = isset($index["backups"]) && is_array($index["backups"])
-        ? $index["backups"]
-        : [];
-
-    return ["backups" => array_values($backups)];
-}
-
-function backupServerWriteIndex(array $backups): void
-{
-    backupServerWriteJsonFile((string) BACKUP_SERVER_INDEX_FILE, [
-        "backups" => array_values($backups),
-    ]);
-}
-
-function backupServerValidateInstance(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 backupServerInstanceDir(string $instance): string
-{
-    return rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR . $instance;
-}
-
-function backupServerBackupPath(string $instance, string $filename): string
-{
-    return backupServerInstanceDir($instance) . DIRECTORY_SEPARATOR . $filename;
-}
-
-function backupServerGetSettings(): array
-{
-    $settings = backupServerReadJsonFile((string) BACKUP_SERVER_SETTINGS_FILE);
-    $retention = isset($settings["retention"])
-        ? max(1, (int) $settings["retention"])
-        : max(1, (int) BACKUP_SERVER_RETENTION);
-    $s3Retention = isset($settings["s3_retention"])
-        ? max(1, (int) $settings["s3_retention"])
-        : max(1, (int) BACKUP_SERVER_S3_RETENTION);
-    $instances =
-        isset($settings["instances"]) && is_array($settings["instances"])
-            ? $settings["instances"]
-            : [];
-    $allowedInstances = [];
-
-    foreach ($instances as $instance) {
-        try {
-            $allowedInstances[] = backupServerValidateInstance((string) $instance);
-        } catch (Throwable $exception) {
-            continue;
-        }
-    }
-    $allowedInstances = array_values(array_unique($allowedInstances));
-    sort($allowedInstances);
-
-    return [
-        "retention" => $retention,
-        "s3_retention" => $s3Retention,
-        "instances" => $allowedInstances,
-    ];
-}
-
-function backupServerWriteSettings(array $settings): void
-{
-    $instances =
-        isset($settings["instances"]) && is_array($settings["instances"])
-            ? $settings["instances"]
-            : backupServerGetSettings()["instances"];
-    $allowedInstances = [];
-
-    foreach ($instances as $instance) {
-        $allowedInstances[] = backupServerValidateInstance((string) $instance);
-    }
-    $allowedInstances = array_values(array_unique($allowedInstances));
-    sort($allowedInstances);
-
-    backupServerWriteJsonFile((string) BACKUP_SERVER_SETTINGS_FILE, [
-        "retention" => max(1, (int) ($settings["retention"] ?? BACKUP_SERVER_RETENTION)),
-        "s3_retention" => max(1, (int) ($settings["s3_retention"] ?? BACKUP_SERVER_S3_RETENTION)),
-        "instances" => $allowedInstances,
-    ]);
-}
-
-function backupServerLog(string $message, array $context = []): void
-{
-    $line = date(DATE_ATOM) . " " . $message;
-    $encoded = @json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
-    if (is_string($encoded) && $encoded !== "[]") {
-        $line .= " " . $encoded;
-    }
-
-    @file_put_contents((string) BACKUP_SERVER_LOG_FILE, $line . PHP_EOL, FILE_APPEND | LOCK_EX);
-}
-
-function backupServerUpdateIndexRecord(string $instance, string $filename, callable $update): void
-{
-    $index = backupServerReadIndex();
-    foreach ($index["backups"] as $position => $backup) {
-        if (
-            is_array($backup) &&
-            ($backup["instance"] ?? "") === $instance &&
-            ($backup["filename"] ?? "") === $filename
-        ) {
-            $index["backups"][$position] = $update($backup);
-        }
-    }
-
-    backupServerWriteIndex($index["backups"]);
-}
-
-function backupServerIndexInstances(): array
-{
-    $instances = [];
-    foreach (backupServerReadIndex()["backups"] as $backup) {
-        if (is_array($backup)) {
-            $instance = (string) ($backup["instance"] ?? "");
-            if ($instance !== "") {
-                $instances[$instance] = true;
-            }
-        }
-    }
-
-    return array_keys($instances);
-}
-
-// 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 backupServerSyncInstanceS3(string $instance): array
-{
-    $result = ["uploaded" => 0, "pending" => 0, "error" => null];
-    if (!backupS3Enabled()) {
-        return $result;
-    }
-
-    $pending = [];
-    foreach (backupServerReadIndex()["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(backupServerBackupPath($instance, $filename))) {
-            continue;
-        }
-        $backup["filename"] = $filename;
-        $pending[] = $backup;
-    }
-
-    usort($pending, function ($left, $right) {
-        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 = backupS3ObjectKey($instance, $filename);
-        }
-
-        try {
-            backupS3PutFile(backupServerBackupPath($instance, $filename), $key);
-        } catch (Throwable $exception) {
-            $result["pending"] = count($pending) - $position;
-            $result["error"] = $exception->getMessage();
-            backupServerUpdateIndexRecord($instance, $filename, function (array $record) use ($key, $exception) {
-                $record["s3_key"] = $key;
-                $record["s3_last_error"] = $exception->getMessage();
-                $record["s3_last_attempt_at"] = date(DATE_ATOM);
-                return $record;
-            });
-            backupServerLog("S3 upload failed", [
-                "instance" => $instance,
-                "filename" => $filename,
-                "key" => $key,
-                "error" => $exception->getMessage(),
-            ]);
-            return $result;
-        }
-
-        backupServerUpdateIndexRecord($instance, $filename, function (array $record) use ($key) {
-            $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;
-}
-
-// 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 backupServerApplyRetention(string $instance): void
-{
-    $index = backupServerReadIndex();
-    $settings = backupServerGetSettings();
-    $s3Enabled = backupS3Enabled();
-    $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"] ?? ""));
-    });
-
-    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 = backupS3ObjectKey($instance, $filename);
-            }
-
-            try {
-                if ($key !== "") {
-                    backupS3DeleteObject($key);
-                }
-            } catch (Throwable $exception) {
-                backupServerLog("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 !== "" ? backupServerBackupPath($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 (legacy behavior) or the backup already aged out of S3.
-        @unlink($path);
-    }
-
-    backupServerWriteIndex(array_merge($otherBackups, $kept));
-}
-
-function backupServerApplyRetentionAll(): void
-{
-    foreach (backupServerIndexInstances() as $instance) {
-        backupServerApplyRetention($instance);
-    }
-}

+ 0 - 557
backup-server/manage.php

@@ -1,557 +0,0 @@
-<?php
-
-// DEPRECATED: The bundled managed backup server is deprecated and unmaintained.
-// It is kept for reference only and will be removed in a future release.
-// Do not deploy it for new installations.
-
-declare(strict_types=1);
-
-require_once __DIR__ . "/lib.php";
-
-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 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 (backupServerReadIndex()["backups"] as $backup) {
-        if (!is_array($backup)) {
-            continue;
-        }
-        if (($backup["instance"] ?? "") === $instance && ($backup["filename"] ?? "") === $filename) {
-            return $backup;
-        }
-    }
-
-    return null;
-}
-
-function backupManageSendDownload(string $instance, string $filename): void
-{
-    $backup = backupManageFindBackup($instance, $filename);
-    if ($backup === null) {
-        throw new RuntimeException("Backup not found.");
-    }
-
-    $path = backupServerBackupPath($instance, $filename);
-    if (is_file($path)) {
-        $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;
-    }
-
-    if (!empty($backup["s3_uploaded_at"])) {
-        if (!backupS3Enabled()) {
-            throw new RuntimeException("Backup is stored in S3, but S3 is not configured. See config.php.");
-        }
-
-        $key = (string) ($backup["s3_key"] ?? "");
-        if ($key === "") {
-            $key = backupS3ObjectKey($instance, $filename);
-        }
-
-        backupS3SendObjectToOutput($key, $instance . "-" . $filename, (int) ($backup["size"] ?? 0));
-    }
-
-    throw new RuntimeException("Backup not found in any store.");
-}
-
-function backupManageDeleteBackup(string $instance, string $filename): void
-{
-    $index = backupServerReadIndex();
-    $kept = [];
-    $found = null;
-
-    foreach ($index["backups"] as $backup) {
-        if (
-            is_array($backup) &&
-            ($backup["instance"] ?? "") === $instance &&
-            ($backup["filename"] ?? "") === $filename
-        ) {
-            $found = $backup;
-            continue;
-        }
-        $kept[] = $backup;
-    }
-
-    if ($found === null) {
-        throw new RuntimeException("Backup not found.");
-    }
-
-    // Delete the S3 object first: if that fails, nothing is changed, so no
-    // object is ever stranded in the bucket without an index record.
-    if (!empty($found["s3_uploaded_at"])) {
-        if (!backupS3Enabled()) {
-            throw new RuntimeException("Backup has an S3 copy, but S3 is not configured. See config.php.");
-        }
-
-        $key = (string) ($found["s3_key"] ?? "");
-        if ($key === "") {
-            $key = backupS3ObjectKey($instance, $filename);
-        }
-        backupS3DeleteObject($key);
-    }
-
-    $path = backupServerBackupPath($instance, $filename);
-    if (is_file($path)) {
-        unlink($path);
-    }
-
-    backupServerWriteIndex($kept);
-}
-
-function backupManageAddInstance(string $instance): void
-{
-    $instance = backupServerValidateInstance($instance);
-    $settings = backupServerGetSettings();
-    $settings["instances"][] = $instance;
-    backupServerWriteSettings($settings);
-}
-
-function backupManageRemoveInstance(string $instance): void
-{
-    $instance = backupServerValidateInstance($instance);
-    $settings = backupServerGetSettings();
-    $settings["instances"] = array_values(
-        array_filter($settings["instances"], function ($existing) use ($instance) {
-            return $existing !== $instance;
-        }),
-    );
-    backupServerWriteSettings($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 === "") {
-            continue;
-        }
-        $backup["filename"] = $filename;
-        $path = backupServerBackupPath($instance, $filename);
-        $backup["local_exists"] = is_file($path);
-        $backup["size"] = $backup["local_exists"]
-            ? (int) (filesize($path) ?: ($backup["size"] ?? 0))
-            : (int) ($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;
-}
-
-function backupManageStorageLabel(array $backup): string
-{
-    $local = !empty($backup["local_exists"]);
-    $inS3 = !empty($backup["s3_uploaded_at"]);
-
-    if ($local && $inS3) {
-        return "Local + S3";
-    }
-    if ($local && backupS3Enabled() && empty($backup["s3_expired"])) {
-        return "Local (S3 pending)";
-    }
-    if ($local) {
-        return "Local";
-    }
-    if ($inS3) {
-        return "S3 only";
-    }
-
-    return "Missing";
-}
-
-function backupManageLogTail(int $lines): array
-{
-    $file = (string) BACKUP_SERVER_LOG_FILE;
-    if (!is_file($file)) {
-        return [];
-    }
-
-    $content = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
-    if (!is_array($content)) {
-        return [];
-    }
-
-    return array_slice($content, -$lines);
-}
-
-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") {
-                $settings = backupServerGetSettings();
-                $settings["retention"] = max(1, (int) ($_POST["retention"] ?? BACKUP_SERVER_RETENTION));
-                if (isset($_POST["s3_retention"])) {
-                    $settings["s3_retention"] = max(1, (int) $_POST["s3_retention"]);
-                }
-                backupServerWriteSettings($settings);
-                // May issue S3 deletes for backups that now age out of the archive.
-                backupServerApplyRetentionAll();
-                $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(
-                    backupServerValidateInstance((string) ($_POST["instance"] ?? "")),
-                    backupManageValidateFilename((string) ($_POST["filename"] ?? "")),
-                );
-            } elseif ($action === "delete") {
-                backupManageDeleteBackup(
-                    backupServerValidateInstance((string) ($_POST["instance"] ?? "")),
-                    backupManageValidateFilename((string) ($_POST["filename"] ?? "")),
-                );
-                $messages[] = "Backup deleted.";
-            } elseif ($action === "s3_sync") {
-                if (!backupS3Enabled()) {
-                    throw new RuntimeException("S3 is not configured. See config.php.");
-                }
-                $uploaded = 0;
-                $pending = 0;
-                foreach (backupServerIndexInstances() as $syncInstance) {
-                    $result = backupServerSyncInstanceS3($syncInstance);
-                    $uploaded += $result["uploaded"];
-                    $pending += $result["pending"];
-                    if ($result["error"] !== null) {
-                        $errors[] = "S3 upload for " . $syncInstance . " failed: " . $result["error"];
-                    }
-                    backupServerApplyRetention($syncInstance);
-                }
-                $messages[] = "S3 sync finished: " . $uploaded . " uploaded, " . $pending . " still pending.";
-            }
-        } catch (Throwable $exception) {
-            $errors[] = $exception->getMessage();
-        }
-    }
-}
-
-try {
-    $settings = backupServerGetSettings();
-    $groupedBackups = backupManageGroupBackupsByInstance(backupServerReadIndex()["backups"]);
-} catch (Throwable $exception) {
-    $settings = [
-        "retention" => max(1, (int) BACKUP_SERVER_RETENTION),
-        "s3_retention" => max(1, (int) BACKUP_SERVER_S3_RETENTION),
-        "instances" => [],
-    ];
-    $groupedBackups = [];
-    $errors[] = $exception->getMessage();
-}
-
-$s3Enabled = backupS3Enabled();
-$s3PendingCount = 0;
-$s3LastErrors = [];
-if ($s3Enabled) {
-    foreach ($groupedBackups as $instanceBackups) {
-        foreach ($instanceBackups as $backup) {
-            if (!empty($backup["local_exists"]) && empty($backup["s3_uploaded_at"])) {
-                $s3PendingCount++;
-            }
-            if (!empty($backup["s3_last_error"]) && count($s3LastErrors) < 5) {
-                $s3LastErrors[] = ($backup["instance"] ?? "") . "/" . ($backup["filename"] ?? "") .
-                    ": " . $backup["s3_last_error"];
-            }
-        }
-    }
-}
-$s3LogTail = backupManageLogTail(20);
-
-?>
-<!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>
-    <p><strong>Deprecated:</strong> This backup server is no longer maintained and will be removed in a future release.</p>
-
-    <?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">Local backups retained per instance</label><br>
-                <input type="number" id="retention" name="retention" min="1" required value="<?php echo (int) $settings["retention"]; ?>">
-            </p>
-            <?php if ($s3Enabled): ?>
-                <p>
-                    <label for="s3_retention">S3 backups retained per instance</label><br>
-                    <input type="number" id="s3_retention" name="s3_retention" min="1" required value="<?php echo (int) $settings["s3_retention"]; ?>">
-                </p>
-            <?php endif; ?>
-            <button type="submit">Save retention</button>
-        </form>
-
-        <h2>S3 archive</h2>
-        <?php if (!$s3Enabled): ?>
-            <p>S3 storage is not configured. Set the <code>BACKUP_SERVER_S3_*</code> constants in <code>config.php</code> to enable it.</p>
-        <?php else: ?>
-            <p>
-                Endpoint: <code><?php echo backupManageEscape(BACKUP_SERVER_S3_ENDPOINT); ?></code>,
-                Bucket: <code><?php echo backupManageEscape(BACKUP_SERVER_S3_BUCKET); ?></code>
-                <?php if (trim((string) BACKUP_SERVER_S3_PREFIX, "/") !== ""): ?>
-                    , Prefix: <code><?php echo backupManageEscape(trim((string) BACKUP_SERVER_S3_PREFIX, "/")); ?></code>
-                <?php endif; ?>
-            </p>
-            <p>Pending uploads: <?php echo (int) $s3PendingCount; ?></p>
-            <form method="POST">
-                <input type="hidden" name="action" value="s3_sync">
-                <input type="hidden" name="csrf_token" value="<?php echo backupManageEscape(backupManageCsrfToken()); ?>">
-                <button type="submit">Retry S3 uploads now</button>
-            </form>
-            <?php if (!empty($s3LastErrors)): ?>
-                <p><strong>Recent upload errors:</strong></p>
-                <ul>
-                    <?php foreach ($s3LastErrors as $s3LastError): ?>
-                        <li><?php echo backupManageEscape($s3LastError); ?></li>
-                    <?php endforeach; ?>
-                </ul>
-            <?php endif; ?>
-            <?php if (!empty($s3LogTail)): ?>
-                <details>
-                    <summary>S3 log (last <?php echo count($s3LogTail); ?> lines)</summary>
-                    <pre><?php echo backupManageEscape(implode("\n", $s3LogTail)); ?></pre>
-                </details>
-            <?php endif; ?>
-        <?php endif; ?>
-
-        <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; S3 objects remain until retention or manual delete.');">
-                                    <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>Storage</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 title="<?php echo backupManageEscape($backup["s3_last_error"] ?? ""); ?>"><?php echo backupManageEscape(backupManageStorageLabel($backup)); ?></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 from all stores?');">
-                                        <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>

+ 0 - 347
backup-server/s3.php

@@ -1,347 +0,0 @@
-<?php
-
-// DEPRECATED: The bundled managed backup server is deprecated and unmaintained.
-// It is kept for reference only and will be removed in a future release.
-// Do not deploy it for new installations.
-
-declare(strict_types=1);
-
-// Dependency-free client for S3-compatible object storage (AWS Signature V4,
-// path-style addressing). Requires the BACKUP_SERVER_S3_* constants defined in
-// lib.php / config.php.
-
-function backupS3Config(): array
-{
-    return [
-        "endpoint" => rtrim(trim((string) BACKUP_SERVER_S3_ENDPOINT), "/"),
-        "region" => trim((string) BACKUP_SERVER_S3_REGION),
-        "bucket" => trim((string) BACKUP_SERVER_S3_BUCKET),
-        "prefix" => trim((string) BACKUP_SERVER_S3_PREFIX, "/"),
-        "access_key" => trim((string) BACKUP_SERVER_S3_ACCESS_KEY),
-        "secret_key" => (string) BACKUP_SERVER_S3_SECRET_KEY,
-        "timeout" => max(1, (int) BACKUP_SERVER_S3_TIMEOUT),
-        "path_style" => (bool) BACKUP_SERVER_S3_PATH_STYLE,
-    ];
-}
-
-function backupS3Enabled(): bool
-{
-    if (BACKUP_SERVER_S3_ENABLED !== true) {
-        return false;
-    }
-
-    $config = backupS3Config();
-
-    return $config["endpoint"] !== "" &&
-        $config["region"] !== "" &&
-        $config["bucket"] !== "" &&
-        $config["access_key"] !== "" &&
-        $config["secret_key"] !== "";
-}
-
-function backupS3ObjectKey(string $instance, string $filename): string
-{
-    $config = backupS3Config();
-    $key = $instance . "/" . $filename;
-
-    return $config["prefix"] !== "" ? $config["prefix"] . "/" . $key : $key;
-}
-
-function backupS3EmptyPayloadHash(): string
-{
-    return hash("sha256", "");
-}
-
-function backupS3HttpStatusFromHeaders(array $headers): int
-{
-    $status = 0;
-    foreach ($headers as $header) {
-        if (preg_match('/^HTTP\/\S+\s+(\d+)/', (string) $header, $matches) === 1) {
-            $status = (int) $matches[1];
-        }
-    }
-
-    return $status;
-}
-
-// $legacyHeaders must be the caller's $http_response_header, because PHP only
-// populates that variable in the scope where the HTTP call was made.
-function backupS3ResponseHeaders($legacyHeaders): array
-{
-    if (function_exists("http_get_last_response_headers")) {
-        $lastHeaders = http_get_last_response_headers();
-        return is_array($lastHeaders) ? $lastHeaders : [];
-    }
-
-    return is_array($legacyHeaders) ? $legacyHeaders : [];
-}
-
-function backupS3SignRequest(string $method, string $key, string $payloadHash, array $extraHeaders = []): array
-{
-    $config = backupS3Config();
-
-    $scheme = parse_url($config["endpoint"], PHP_URL_SCHEME);
-    $endpointHost = parse_url($config["endpoint"], PHP_URL_HOST);
-    if (!is_string($scheme) || $scheme === "" || !is_string($endpointHost) || $endpointHost === "") {
-        throw new RuntimeException("S3 endpoint is invalid.");
-    }
-
-    $encodedKey = str_replace("%2F", "/", rawurlencode($key));
-    if ($config["path_style"]) {
-        // https://<endpoint-host>/<bucket>/<key>
-        $host = $endpointHost;
-        $canonicalUri = "/" . rawurlencode($config["bucket"]) . "/" . $encodedKey;
-    } else {
-        // https://<bucket>.<endpoint-host>/<key> (default for Hetzner)
-        $host = $config["bucket"] . "." . $endpointHost;
-        $canonicalUri = "/" . $encodedKey;
-    }
-
-    $port = parse_url($config["endpoint"], PHP_URL_PORT);
-    if (is_int($port)) {
-        $host .= ":" . $port;
-    }
-
-    $url = $scheme . "://" . $host . $canonicalUri;
-
-    $now = gmdate("Ymd\THis\Z");
-    $date = substr($now, 0, 8);
-
-    $headers = array_merge($extraHeaders, [
-        "host" => $host,
-        "x-amz-content-sha256" => $payloadHash,
-        "x-amz-date" => $now,
-    ]);
-    ksort($headers);
-
-    $canonicalHeaders = "";
-    foreach ($headers as $name => $value) {
-        $canonicalHeaders .= $name . ":" . $value . "\n";
-    }
-    $signedHeaders = implode(";", array_keys($headers));
-
-    $canonicalRequest =
-        $method . "\n" .
-        $canonicalUri .
-        "\n\n" .
-        $canonicalHeaders .
-        "\n" .
-        $signedHeaders .
-        "\n" .
-        $payloadHash;
-    $scope = $date . "/" . $config["region"] . "/s3/aws4_request";
-    $stringToSign =
-        "AWS4-HMAC-SHA256\n" .
-        $now .
-        "\n" .
-        $scope .
-        "\n" .
-        hash("sha256", $canonicalRequest);
-    $kDate = hash_hmac("sha256", $date, "AWS4" . $config["secret_key"], true);
-    $kRegion = hash_hmac("sha256", $config["region"], $kDate, true);
-    $kService = hash_hmac("sha256", "s3", $kRegion, true);
-    $kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
-    $signature = hash_hmac("sha256", $stringToSign, $kSigning);
-    $authorization =
-        "AWS4-HMAC-SHA256 Credential=" .
-        $config["access_key"] .
-        "/" .
-        $scope .
-        ", SignedHeaders=" .
-        $signedHeaders .
-        ", Signature=" .
-        $signature;
-
-    $headerString = "";
-    foreach ($headers as $name => $value) {
-        $headerString .= $name . ": " . $value . "\r\n";
-    }
-    $headerString .= "Authorization: " . $authorization . "\r\n";
-
-    return [
-        "url" => $url,
-        "headers" => $headerString,
-        "timeout" => $config["timeout"],
-    ];
-}
-
-// Builds a human-readable suffix for an error message from an S3 response.
-// S3-compatible endpoints return an XML body like
-// <Error><Code>SignatureDoesNotMatch</Code><Message>...</Message></Error>,
-// which pinpoints why a request was rejected.
-function backupS3ErrorDetail(int $status, $response): string
-{
-    $detail = $status > 0 ? " (HTTP " . $status . ")" : "";
-    $body = is_string($response) ? trim($response) : "";
-    if ($body === "") {
-        return $detail . ".";
-    }
-
-    $parts = [];
-    if (preg_match('#<Code>(.*?)</Code>#s', $body, $matches) === 1) {
-        $parts[] = trim($matches[1]);
-    }
-    if (preg_match('#<Message>(.*?)</Message>#s', $body, $matches) === 1) {
-        $parts[] = trim($matches[1]);
-    }
-    if ($parts === []) {
-        $parts[] = substr(preg_replace('/\s+/', " ", $body) ?? "", 0, 300);
-    }
-
-    return $detail . ": " . implode(" - ", $parts);
-}
-
-// Summarizes the response header chain so a failure can be diagnosed from the
-// log: every HTTP status line (reveals redirects), any Location target, and the
-// server's request id. $headers is the raw wrapper header array.
-function backupS3HeaderDiagnostic(array $headers): string
-{
-    $statuses = [];
-    $location = "";
-    $requestId = "";
-    foreach ($headers as $header) {
-        $header = (string) $header;
-        if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) {
-            $statuses[] = $matches[1];
-        } elseif (preg_match('/^Location:\s*(.+)$/i', $header, $matches) === 1) {
-            $location = trim($matches[1]);
-        } elseif (preg_match('/^x-amz-request-id:\s*(.+)$/i', $header, $matches) === 1) {
-            $requestId = trim($matches[1]);
-        }
-    }
-
-    $parts = [];
-    if ($statuses !== []) {
-        $parts[] = "status chain " . implode("->", $statuses);
-    }
-    if ($location !== "") {
-        $parts[] = "redirected to " . $location;
-    }
-    if ($requestId !== "") {
-        $parts[] = "request-id " . $requestId;
-    }
-
-    return $parts === [] ? "" : " [" . implode("; ", $parts) . "]";
-}
-
-function backupS3PutFile(string $localPath, string $key): void
-{
-    // The whole file is held in memory for signing; a backup larger than
-    // memory_limit fails here, stays local, and is retried later.
-    $payload = @file_get_contents($localPath);
-    if ($payload === false) {
-        throw new RuntimeException("Backup file cannot be read for S3 upload.");
-    }
-
-    $request = backupS3SignRequest("PUT", $key, hash("sha256", $payload), [
-        "content-type" => "application/zip",
-    ]);
-
-    $context = stream_context_create([
-        "http" => [
-            "method" => "PUT",
-            "timeout" => $request["timeout"],
-            "ignore_errors" => true,
-            // Never chase a redirect: PHP would re-send the body with a
-            // signature bound to the original host/path, which the target then
-            // rejects. A 3xx must surface so the endpoint config can be fixed.
-            "follow_location" => 0,
-            "max_redirects" => 1,
-            "protocol_version" => 1.1,
-            "header" => $request["headers"] . "Content-Length: " . strlen($payload) . "\r\n",
-            "content" => $payload,
-        ],
-    ]);
-
-    $response = @file_get_contents($request["url"], false, $context);
-    $headers = backupS3ResponseHeaders($http_response_header ?? null);
-    $status = backupS3HttpStatusFromHeaders($headers);
-
-    if ($response === false || $status < 200 || $status >= 300) {
-        throw new RuntimeException(
-            "S3 upload failed" . backupS3ErrorDetail($status, $response) . backupS3HeaderDiagnostic($headers),
-        );
-    }
-}
-
-function backupS3DeleteObject(string $key): void
-{
-    $request = backupS3SignRequest("DELETE", $key, backupS3EmptyPayloadHash());
-
-    $context = stream_context_create([
-        "http" => [
-            "method" => "DELETE",
-            "timeout" => $request["timeout"],
-            "ignore_errors" => true,
-            "follow_location" => 0,
-            "max_redirects" => 1,
-            "protocol_version" => 1.1,
-            "header" => $request["headers"],
-        ],
-    ]);
-
-    $response = @file_get_contents($request["url"], false, $context);
-    $headers = backupS3ResponseHeaders($http_response_header ?? null);
-    $status = backupS3HttpStatusFromHeaders($headers);
-
-    // DELETE is idempotent: an already missing object (404) counts as deleted.
-    if ($response === false || ($status !== 404 && ($status < 200 || $status >= 300))) {
-        throw new RuntimeException(
-            "S3 delete failed" . backupS3ErrorDetail($status, $response) . backupS3HeaderDiagnostic($headers),
-        );
-    }
-}
-
-function backupS3SendObjectToOutput(string $key, string $downloadName, int $fallbackSize): void
-{
-    $request = backupS3SignRequest("GET", $key, backupS3EmptyPayloadHash());
-
-    $context = stream_context_create([
-        "http" => [
-            "method" => "GET",
-            "timeout" => $request["timeout"],
-            "ignore_errors" => true,
-            "follow_location" => 0,
-            "max_redirects" => 1,
-            "protocol_version" => 1.1,
-            "header" => $request["headers"],
-        ],
-    ]);
-
-    $handle = @fopen($request["url"], "rb", false, $context);
-    if ($handle === false) {
-        throw new RuntimeException("S3 download failed (connection error).");
-    }
-
-    $meta = stream_get_meta_data($handle);
-    $headers = isset($meta["wrapper_data"]) && is_array($meta["wrapper_data"])
-        ? $meta["wrapper_data"]
-        : [];
-    $status = backupS3HttpStatusFromHeaders($headers);
-    if ($status < 200 || $status >= 300) {
-        $body = stream_get_contents($handle, 2048);
-        fclose($handle);
-        throw new RuntimeException(
-            "S3 download failed" . backupS3ErrorDetail($status, $body) . backupS3HeaderDiagnostic($headers),
-        );
-    }
-
-    $size = $fallbackSize;
-    foreach ($headers as $header) {
-        if (preg_match('/^Content-Length:\s*(\d+)/i', (string) $header, $matches) === 1) {
-            $size = (int) $matches[1];
-        }
-    }
-
-    header("Content-Type: application/zip");
-    header("Content-Disposition: attachment; filename=\"" . addcslashes($downloadName, "\"\\") . "\"");
-    if ($size > 0) {
-        header("Content-Length: " . (string) $size);
-    }
-    header("Cache-Control: private, no-store");
-    header("X-Content-Type-Options: nosniff");
-
-    fpassthru($handle);
-    fclose($handle);
-    exit;
-}

+ 0 - 173
backup-server/upload.php

@@ -1,173 +0,0 @@
-<?php
-
-// DEPRECATED: The bundled managed backup server is deprecated and unmaintained.
-// It is kept for reference only and will be removed in a future release.
-// Do not deploy it for new installations.
-
-declare(strict_types=1);
-
-require_once __DIR__ . "/lib.php";
-
-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 backupUploadInstanceIsAllowed(string $instance): bool
-{
-    return in_array($instance, backupServerGetSettings()["instances"], true);
-}
-
-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;
-}
-
-if ($_SERVER["REQUEST_METHOD"] !== "POST") {
-    backupUploadRespond(405, ["success" => false, "error" => "POST required."]);
-}
-
-try {
-    $instance = backupServerValidateInstance((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 = backupServerInstanceDir($instance);
-    backupServerEnsureDirectory($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 = backupServerReadIndex();
-    $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;
-    backupServerWriteIndex($index["backups"]);
-
-    // S3 problems must never fail the upload: the local copy exists, and the
-    // sync is retried on the next upload or via the management UI.
-    $s3Enabled = backupS3Enabled();
-    $s3Result = ["uploaded" => 0, "pending" => 0, "error" => null];
-    if ($s3Enabled) {
-        try {
-            $s3Result = backupServerSyncInstanceS3($instance);
-        } catch (Throwable $exception) {
-            $s3Result = ["uploaded" => 0, "pending" => 1, "error" => $exception->getMessage()];
-            backupServerLog("S3 sync crashed", [
-                "instance" => $instance,
-                "error" => $exception->getMessage(),
-            ]);
-        }
-    }
-
-    backupServerApplyRetention($instance);
-
-    backupUploadRespond(200, [
-        "success" => true,
-        "instance" => $instance,
-        "filename" => $filename,
-        "size" => $size,
-        "sha256" => $sha256,
-        "retention" => backupServerGetSettings()["retention"],
-        "s3" => [
-            "enabled" => $s3Enabled,
-            "uploaded" => $s3Enabled && $s3Result["pending"] === 0,
-            "pending" => $s3Result["pending"],
-        ],
-    ]);
-} catch (Throwable $exception) {
-    backupUploadRespond(400, [
-        "success" => false,
-        "error" => $exception->getMessage(),
-    ]);
-}

+ 4 - 50
config.sample.php

@@ -55,56 +55,10 @@ define('CATEGORIES_FILE', DATA_DIR . 'categories.json');
 define('FAQ_FILE', DATA_DIR . 'faq.json');
 define('UPLOADS_URL', SITE_URL . '/data/uploads');
 
-// Manual update settings
-// Point this to the central update server's manifest.php endpoint.
-// Note: the bundled `update-server/` component is deprecated and unmaintained.
-define('UPDATE_MANIFEST_URL', 'https://dev.med0.de/psa/updater/manifest.php');
-define('UPDATE_WORK_DIR', DATA_DIR . 'updates/work/');
-define('UPDATE_BACKUP_DIR', DATA_DIR . 'updates/backups/');
-
-// Data backup settings
-define('BACKUP_DIR', DATA_DIR . 'backups/');
-define('BACKUP_LOCAL_RETENTION', 4);
-define('BACKUP_AUTO_INTERVAL_SECONDS', 604800);
-define('BACKUP_REMOTE_TARGETS', [
-    // [
-    //     'name' => 'S3 Backup',
-    //     'type' => 's3',
-    //     'bucket' => 'example-bucket',
-    //     'region' => 'eu-central-1',
-    //     'prefix' => 'psa-orderform',
-    //     'access_key' => 'AKIA...',
-    //     'secret_key' => '...',
-    //     // Optional for S3-compatible storage:
-    //     // 'endpoint' => 'https://s3.example.org',
-    // ],
-    // [
-    //     'name' => 'SFTP Backup',
-    //     'type' => 'sftp',
-    //     'host' => 'backup.example.org',
-    //     'port' => 22,
-    //     'username' => 'backup-user',
-    //     'password' => '...',
-    //     'path' => '/backups/psa-orderform',
-    // ],
-    // [
-    //     'name' => 'Custom Backup',
-    //     'type' => 'custom',
-    //     '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',
-    // ],
-]);
-
-// Log retention settings
-define('LOG_MAX_BYTES', 1048576);
-define('LOG_KEEP_FILES', 5);
-define('LOG_MAX_AGE_SECONDS', 2592000);
+// Update and backup
+// Handled by the manage client in manage-client/. Its settings - server URL,
+// instance, token, backup sources - live in manage-client/config.php, not here.
+// See docs/UPDATE_AND_BACKUP.md.
 
 // Session settings
 if (session_status() === PHP_SESSION_NONE) {

+ 1 - 1
docs/ADMIN_BUSINESS_LOGIC.md

@@ -292,7 +292,7 @@ Unter **Einstellungen** (`admin/settings.php`) im Abschnitt **Allgemein**:
 - **PDF an interne Bestell-E-Mails anhängen**
 - **Artikelname für Namensschilder**
 
-Die Seite ist in Abschnitte gegliedert (in dieser Reihenfolge): **Kategorien**, **FAQ**, **Organisationen**, **Allgemein**, **Backups**, **Updater** und — nur für das Konto `admin` — **Lokale Konfiguration**. Kategorien, FAQ und Organisationen haben keine eigenen Seiten mehr.
+Die Seite ist in Abschnitte gegliedert (in dieser Reihenfolge): **Kategorien**, **FAQ**, **Organisationen**, **Allgemein**, **Update & Backup** und — nur für das Konto `admin` — **Lokale Konfiguration**. Kategorien, FAQ und Organisationen haben keine eigenen Seiten mehr. Der Abschnitt **Update & Backup** zeigt nur den Status und verlinkt auf `admin/manage.php`; die Aktionen selbst liegen dort (siehe [UPDATE_AND_BACKUP.md](UPDATE_AND_BACKUP.md)).
 
 ---
 

+ 1 - 0
docs/ADMIN_SYSTEM.md

@@ -7,6 +7,7 @@ Das Admin-System nutzt einen klassischen Session-Login für den Bereich unter `a
 - Login-Seite: `admin/login.php`
 - Admin-Dashboard: `admin/index.php`
 - Admin-Verwaltung: `admin/admins.php`
+- Update & Backup: `admin/manage.php` (bindet `manage-client/ui/panel.php` ein, siehe [UPDATE_AND_BACKUP.md](UPDATE_AND_BACKUP.md))
 - Backend-Helfer: `includes/functions.php`
 - Persistenz: `data/admins.json`
 

+ 0 - 233
docs/BACKUP_CONFIGURATION.md

@@ -1,233 +0,0 @@
-# Backup-Konfiguration
-
-Backups werden in `config.php` konfiguriert. Ohne Remote-Konfiguration funktionieren sie sofort: Ein Admin kann unter **Einstellungen** lokale ZIP-Backups erstellen.
-
-## Inhalt des Backups
-
-Das Backup-ZIP enthält nur Betriebsdaten:
-
-- `data/*.json`
-- `data/uploads/**`
-
-Nicht enthalten sind App-Dateien, `config.php`, bestehende Backups, Updater-Arbeitsdateien, Logs, Rate-Limit-Daten und temporäre Dateien.
-
-Lokale Backup-ZIPs liegen in `data/backups/`. Mit der Standard-`.htaccess` ist dieses Verzeichnis nicht öffentlich lesbar.
-
-## Lokale Grundkonfiguration
-
-Diese Defaults sind bereits in `includes/backup.php` hinterlegt. Wenn eine Installation explizite Werte benötigt, werden sie in `config.php` gesetzt:
-
-```php
-define('BACKUP_DIR', DATA_DIR . 'backups/');
-define('BACKUP_LOCAL_RETENTION', 4);
-define('BACKUP_AUTO_INTERVAL_SECONDS', 604800);
-define('BACKUP_REMOTE_TARGETS', []);
-```
-
-`BACKUP_LOCAL_RETENTION` legt fest, wie viele lokale ZIP-Dateien behalten werden. Ältere Backups werden nach einem erfolgreichen neuen Backup gelöscht.
-
-`BACKUP_AUTO_INTERVAL_SECONDS` steuert automatische Backups durch Admin-Aktivität. Der Standard ist wöchentlich (`604800`). Mit `0` werden automatische Backups deaktiviert.
-
-Es gibt keinen Cron-Endpunkt. Automatische Backups laufen nur, wenn ein Admin die Einstellungsseite öffnet und das Intervall abgelaufen ist.
-
-## Bedienung im Admin
-
-Pfad: **Admin > Einstellungen > Backups**.
-
-- **Backup erstellen** erstellt sofort ein manuelles Backup.
-- **Letzte Backups** zeigt lokale Backups mit Erstellzeit, Auslöser, Größe, Dateianzahl, Remote-Status und Download-Aktion.
-- Fehlgeschlagene Remote-Uploads machen das lokale Backup nicht ungültig. Der Fehler wird in den Backup-Metadaten gespeichert.
-
-## S3-Ziel
-
-S3-Uploads nutzen eingebaute PHP-HTTPS-Streams und AWS Signature V4. Es ist keine Bibliothek erforderlich.
-
-```php
-define('BACKUP_REMOTE_TARGETS', [
-    [
-        'name' => 'S3 Backup',
-        'type' => 's3',
-        'bucket' => 'example-bucket',
-        'region' => 'eu-central-1',
-        'prefix' => 'psa-orderform',
-        'access_key' => 'AKIA...',
-        'secret_key' => '...',
-    ],
-]);
-```
-
-Für S3-kompatiblen Speicher kann `endpoint` ergänzt werden:
-
-```php
-'endpoint' => 'https://s3.example.org',
-```
-
-Der Objektpfad besteht aus `prefix` plus generiertem ZIP-Dateinamen.
-
-## SFTP-Ziel
-
-SFTP benötigt die optionale PHP-SSH2-Erweiterung. Wenn die Erweiterung fehlt, funktionieren lokale Backups weiterhin; der SFTP-Upload wird als nicht verfügbar beziehungsweise fehlgeschlagen gemeldet.
-
-Passwort-Anmeldung:
-
-```php
-define('BACKUP_REMOTE_TARGETS', [
-    [
-        'name' => 'SFTP Backup',
-        'type' => 'sftp',
-        'host' => 'backup.example.org',
-        'port' => 22,
-        'username' => 'backup-user',
-        'password' => '...',
-        'path' => '/backups/psa-orderform',
-    ],
-]);
-```
-
-Key-Anmeldung:
-
-```php
-define('BACKUP_REMOTE_TARGETS', [
-    [
-        'name' => 'SFTP Backup',
-        'type' => 'sftp',
-        'host' => 'backup.example.org',
-        'port' => 22,
-        'username' => 'backup-user',
-        'public_key' => __DIR__ . '/keys/backup.pub',
-        'private_key' => __DIR__ . '/keys/backup',
-        'password' => '',
-        'path' => '/backups/psa-orderform',
-    ],
-]);
-```
-
-Das Remote-Verzeichnis muss bereits existieren und für den konfigurierten Benutzer beschreibbar sein.
-
-## Custom-Uploader
-
-Ein Custom-Uploader ist ein PHP-Callback, der in `config.php` konfiguriert wird.
-
-```php
-define('BACKUP_REMOTE_TARGETS', [
-    [
-        'name' => 'Custom Backup',
-        'type' => 'custom',
-        'file' => __DIR__ . '/custom-backup-uploader.php',
-        'callback' => 'uploadPsaOrderformBackup',
-    ],
-]);
-```
-
-Beispiel-Callback:
-
-```php
-<?php
-
-function uploadPsaOrderformBackup(string $archivePath, array $metadata, array $target)
-{
-    $targetDir = __DIR__ . '/external-backups';
-    if (!is_dir($targetDir) && !mkdir($targetDir, 02775, true) && !is_dir($targetDir)) {
-        return [
-            'success' => false,
-            'error' => 'Target directory cannot be created.',
-        ];
-    }
-
-    $remotePath = $targetDir . '/' . basename($archivePath);
-    if (!copy($archivePath, $remotePath)) {
-        return [
-            'success' => false,
-            'error' => 'Copy failed.',
-        ];
-    }
-
-    return [
-        'remote_path' => $remotePath,
-    ];
-}
-```
-
-Der Callback erhält:
-
-- `$archivePath`: absoluter Pfad zur lokalen ZIP-Datei.
-- `$metadata`: Dateiname, Erstellzeitpunkt, Auslöser und Prüfsumme.
-- `$target`: das konfigurierte Ziel-Array.
-
-Für Erfolg kann `true` oder ein Array zurückgegeben werden. Für Fehler kann eine Exception geworfen werden. Ein Array mit `'success' => false` wird als fehlgeschlagener Remote-Upload protokolliert.
-
-## Mehrere Remote-Ziele
-
-Remote-Ziele können kombiniert werden:
-
-```php
-define('BACKUP_REMOTE_TARGETS', [
-    [
-        'name' => 'Primary S3',
-        'type' => 's3',
-        'bucket' => 'example-bucket',
-        'region' => 'eu-central-1',
-        'prefix' => 'psa-orderform',
-        'access_key' => 'AKIA...',
-        'secret_key' => '...',
-    ],
-    [
-        'name' => 'Secondary SFTP',
-        'type' => 'sftp',
-        'host' => 'backup.example.org',
-        'port' => 22,
-        'username' => 'backup-user',
-        'password' => '...',
-        'path' => '/backups/psa-orderform',
-    ],
-]);
-```
-
-Jedes Ziel wird unabhängig versucht.
-
-## Managed Backup Server (veraltet)
-
-> **Veraltet** — Der mitgelieferte Managed Backup Server (`backup-server/`) wird nicht mehr
-> gepflegt und in einer künftigen Version entfernt. Für neue Installationen sollte er nicht
-> mehr eingerichtet werden; bestehende Deployments funktionieren weiter, erhalten aber keine
-> Korrekturen oder Erweiterungen mehr.
-
-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`.
-- `data/` muss für PHP beschreibbar sein.
-- Auf Apache-Hosting muss `.htaccess` aktiv bleiben, damit `data/backups/` nicht direkt erreichbar ist.
-- Remote-Ziele sollten nach Konfigurationsänderungen mit einem manuellen Backup getestet werden.
-- Remote-Backup-Erfolge werden knapp in `data/logs/access.log` protokolliert; Fehler mit Debug-Kontext in `data/logs/error.log`. Die App-Logs rotieren nach `LOG_MAX_BYTES` und entfernen alte rotierte Logs nach `LOG_MAX_AGE_SECONDS`.
-- Lokale Backups sollten nur über den authentifizierten Adminbereich heruntergeladen werden.

+ 3 - 11
docs/CONFIG_REFERENCE.md

@@ -38,10 +38,6 @@
 | `CATEGORIES_FILE` | JSON-Datei für Kategorien |
 | `FAQ_FILE` | JSON-Datei für FAQ-Inhalte |
 | `MANUAL_BACKORDERS_FILE` | JSON-Datei für manuelle Nachbestell-Einträge (ohne Bestellbezug) |
-| `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`, `managed`) |
 
 ## Bearbeitung im Admin
 
@@ -66,14 +62,10 @@ Der Startseiten-Introtext wird unter **Einstellungen > FAQ** gepflegt (`startpag
 - Zugriffs- und Fehlerprotokolle: `data/logs/` (siehe `logAccess` / `logError` in `includes/functions.php`).
 - Logs werden ab `LOG_MAX_BYTES` rotiert, es bleiben `LOG_KEEP_FILES` rotierte Dateien erhalten, und rotierte Logs älter als `LOG_MAX_AGE_SECONDS` werden entfernt.
 
-## Backups
+## Update und Backup
 
-- Manuelle Backups werden im Admin unter **Einstellungen** erstellt.
-- Automatische Backups werden nur durch Admin-Aktivität auf der Einstellungsseite ausgelöst, wenn das konfigurierte Intervall abgelaufen ist.
-- Backups enthalten `data/*.json` und `data/uploads/**`; App-Dateien, Logs, Updates, Rate-Limits und bestehende Backups werden ausgeschlossen.
-- SFTP benötigt die optionale PHP-SSH2-Erweiterung. Ohne Erweiterung bleibt das lokale Backup gültig, der Remote-Upload wird als fehlgeschlagen protokolliert.
-- Zugangsdaten für Remote-Ziele gehören in `config.php`, nicht in `data/settings.json`.
-- Details und Beispiele: [Backup-Konfiguration](BACKUP_CONFIGURATION.md).
+Update- und Backup-Konstanten stehen **nicht** in `config.php`, sondern in
+`manage-client/config.php`. Details: [Update und Backup](UPDATE_AND_BACKUP.md).
 
 ## Hinweis
 

+ 1 - 1
docs/SHOP_LOGIC.md

@@ -187,7 +187,7 @@ Unter **Einstellungen** können Sie anpassen:
 - **FAQ** — FAQ-Inhalt (Markdown) und Text auf der Startseite
 - **Organisationen** — auswählbare Organisationen inkl. Sortierung und Aktiv-Status
 - **Allgemein** — Empfängeradresse für interne Bestellmails, PDF-Anhang ja/nein, Artikelname für Namensschilder
-- **Backups**, **Updater** und (nur für das Konto `admin`) **Lokale Konfiguration**
+- **Update & Backup** und (nur für das Konto `admin`) **Lokale Konfiguration**
 
 ---
 

+ 172 - 0
docs/UPDATE_AND_BACKUP.md

@@ -0,0 +1,172 @@
+# Update und Backup
+
+## Überblick
+
+Updates und Backups laufen über den **Manage-Client** in `manage-client/`. Der
+Client holt Releases von einem zentralen Manage-Server, rollt sie über die
+Installation aus und lädt Backups der Betriebsdaten dorthin hoch.
+
+Der Client ist ein fertiges Paket und wird pro Projekt gepflegt. Seine
+vollständige Dokumentation liegt unter <https://manage.med0.de/client-docs/>
+(maschinenlesbar: `llms.php`); dieses Dokument beschreibt nur, wie er in diesem
+Projekt eingebunden ist.
+
+Er ersetzt die früheren Komponenten `includes/backup.php`, `admin/updater.php`,
+`backup-server/` und `update-server/`, die vollständig entfernt wurden.
+
+## Bestandteile im Projekt
+
+| Pfad | Zweck |
+|---|---|
+| `manage-client/lib/` | die Bibliothek; `lib/client.php` ist der einzige Einstiegspunkt |
+| `manage-client/bin/manage-client.php` | Kommandozeile für Cron und Shell |
+| `manage-client/ui/panel.php` | die Oberfläche, eingebunden von `admin/manage.php` |
+| `manage-client/config.php` | Serveradresse, Instanz, Token, Backup-Quellen — **nicht** im Repository, **nicht** im Release-Paket |
+| `manage-client/config.sample.php` | Vorlage dafür |
+| `admin/manage.php` | Adminseite „Update & Backup“ (prüft den Login, bindet dann das Panel ein) |
+| `includes/after-update.php` | Post-Update-Hook (`psaAfterUpdate`) |
+| `includes/manage-activity.php` | Cron-Ersatz: Backup und Statusmeldung durch Adminaktivität |
+| `migrations/` | einmalige Migrationsskripte, die mit einem Release ausgeliefert werden |
+| `scripts/create-release-zip.sh` | baut das Release-ZIP |
+
+Laufzeitdaten liegen unter `data/manage/` (`backups/`, `updates/`, `work/`,
+`migrations.json`, `manage-client.log`, `activity.json`) und sind über die `.htaccess` im
+Projektwurzelverzeichnis nicht über das Web erreichbar.
+
+## Konfiguration (`manage-client/config.php`)
+
+Die drei Verbindungswerte stammen aus dem Manage-Server; der Token wird beim
+Anlegen der Instanz **einmalig** angezeigt:
+
+```php
+define("MANAGE_SERVER_URL", "https://manage.med0.de");
+define("MANAGE_INSTANCE",   "psa-...");
+define("MANAGE_TOKEN",      "...");
+```
+
+Projektspezifisch gesetzt sind außerdem:
+
+| Konstante | Wert in diesem Projekt |
+|---|---|
+| `MANAGE_VERSION_FILE` / `MANAGE_VERSION_CONSTANT` | `includes/version.php` mit `APP_VERSION` |
+| `MANAGE_UPDATE_PROTECTED_PATHS` | `config.php`, `data/`, `.git/`, `manage-client/config.php` |
+| `MANAGE_UPDATE_SANITY_PATHS` | `index.php`, `includes/functions.php` |
+| `MANAGE_UPDATE_POST_HOOK` | `includes/after-update.php` → `psaAfterUpdate()` |
+| `MANAGE_BACKUP_SOURCES` | `data/*.json`, `data/uploads/**`, `data/manage/migrations.json` |
+| `MANAGE_BACKUP_DATABASE` | `null` — reines Flat-File-Projekt |
+| `MANAGE_BACKUP_LOCAL_RETENTION` | 4 lokale Archive |
+| `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` | 604800 (wöchentlich) |
+| `MANAGE_HTTP_TIMEOUT` | 5 Sekunden — die Einstellungsseite ruft das Manifest beim Rendern ab |
+
+`config.php` der Anwendung enthält **keine** Update- oder Backup-Konstanten
+mehr.
+
+## Bedienung im Admin
+
+- **Einstellungen** zeigt Version, Update-Verfügbarkeit, letztes Backup und
+  offene Migrationen und verlinkt auf die Vollansicht.
+- **Update & Backup** (`admin/manage.php`) bietet: Backup erstellen, Update
+  ausrollen, Migrationen nachholen, Status melden, lokale Backups herunterladen.
+- Das Dashboard (`admin/index.php`) stößt die periodischen Aufgaben an, siehe
+  unten.
+
+## Ohne Cron: Auslösung durch Adminaktivität
+
+Auf dieser Installation steht in der Regel kein Cron zur Verfügung. Die beiden
+Aufgaben, die sonst geplant laufen würden, hängen deshalb an der Adminaktivität:
+`admin/index.php` ruft beim Aufruf `manageActivityRunDueTasks()` aus
+[`includes/manage-activity.php`](../includes/manage-activity.php) auf. Der Login
+leitet immer auf das Dashboard, jede Adminsitzung kommt also dort vorbei.
+
+| Aufgabe | Intervall | Konstante |
+|---|---|---|
+| Automatisches Backup | wöchentlich | `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` |
+| Statusmeldung (Heartbeat) | stündlich | `MANAGE_ACTIVITY_HEARTBEAT_INTERVAL` |
+
+Ist das Intervall noch nicht abgelaufen, passiert nichts. Nach einem frisch
+erstellten Backup wird die Statusmeldung unabhängig vom Intervall gesendet,
+damit der Manage-Server nicht bis zu eine Stunde lang ein veraltetes „letztes
+Backup“ anzeigt.
+
+Der Zeitstempel der letzten Statusmeldung steht in `data/manage/activity.json`
+und wird **vor** dem Request geschrieben: ein nicht erreichbarer Manage-Server
+kostet dadurch einen Versuch pro Intervall, nicht einen pro Seitenaufruf. Keine
+der beiden Aufgaben kann die Seite abbrechen — Fehler landen im Client-Log
+(`data/manage/manage-client.log`), ein fehlgeschlagenes Backup zusätzlich als
+Hinweis auf dem Dashboard.
+
+Die Update-Prüfung braucht keine eigene Auslösung: die Einstellungsseite und
+`admin/manage.php` holen das Manifest beim Rendern.
+
+## Cron
+
+Nur relevant, wenn auf einer Installation doch Cron zur Verfügung steht - dann
+sind die Kommandos der zuverlässigere Weg und die Auslösung durch
+Adminaktivität greift nur noch selten ein:
+
+```cron
+20 3 * * * /usr/bin/php /pfad/zum/projekt/manage-client/bin/manage-client.php backup --trigger=cron --quiet
+7  * * * * /usr/bin/php /pfad/zum/projekt/manage-client/bin/manage-client.php heartbeat --quiet
+0  8 * * 1-5 /usr/bin/php /pfad/zum/projekt/manage-client/bin/manage-client.php check --quiet
+```
+
+Exitcodes: `0` Erfolg, `1` Fehler, `2` Update verfügbar (nur `check`). Updates
+werden bewusst **nicht** automatisch installiert.
+
+## Release bauen und veröffentlichen
+
+```bash
+./scripts/create-release-zip.sh v1.3.15
+```
+
+Das Skript schreibt die Version nach `includes/version.php`, packt alle von Git
+verwalteten Dateien abzüglich der Ausschlussliste (`config.php`,
+`manage-client/config.php`, `data/`, `build/`, `scripts/`)
+nach `build/releases/psa-orderform-vX.Y.Z.zip` und gibt SHA-256 und Größe aus.
+Das ZIP wird im Manage-Server unter **Releases** hochgeladen; Prüfsumme und
+Größe berechnet der Server selbst.
+
+Die Wurzel des ZIP **ist** das Anwendungsverzeichnis — kein zusätzlicher
+Oberordner.
+
+## Migrationen
+
+Migrationen liegen in `migrations/` und werden mit dem Release ausgeliefert. Sie
+laufen in Dateinamenreihenfolge, jeweils genau einmal; der Stand steht in
+`data/manage/migrations.json`.
+
+```php
+<?php
+
+return function (array $context): void {
+    $file = $context["app_root"] . "/data/products.json";
+    // ... idempotent umbauen ...
+};
+```
+
+Der Kontext enthält `app_root`, `instance`, `from_version`, `to_version`,
+`backup_dir`, `run_id` und `migration_id`. Migrationen müssen idempotent sein:
+eine mittendrin abgebrochene Migration gilt nicht als angewendet und läuft beim
+nächsten Lauf von vorn.
+
+Nach einem Fehler:
+
+```bash
+php manage-client/bin/manage-client.php migrate --dry-run
+php manage-client/bin/manage-client.php migrate
+```
+
+## Grenzen
+
+- **Kein Restore.** Backups werden erstellt und übertragen, aber nie
+  zurückgespielt. Die vom Update überschriebenen Dateien liegen als Kopie unter
+  `data/manage/updates/`, ausschließlich für manuelle Wiederherstellung.
+- **Gelöschte Dateien verschwinden nicht.** Ein Update legt sich über den
+  Bestand; was ein Release nicht mehr enthält, bleibt liegen. Entfernen gehört
+  in eine Migration.
+- **Kein Wartungsmodus.** Die Anwendung bleibt während des Ausrollens
+  erreichbar.
+- Backups enthalten personenbezogene Daten aus `data/orders.json`. Für den
+  Manage-Server gelten dieselben Anforderungen wie für diese Anwendung.
+  Zugangsdaten gehören nicht ins Backup — `config.php` ist deshalb keine
+  Backup-Quelle.

+ 31 - 0
includes/after-update.php

@@ -0,0 +1,31 @@
+<?php
+
+// Post-update callback, wired up in manage-client/config.php as
+// MANAGE_UPDATE_POST_HOOK. It runs after a release has been deployed and after
+// the release's migrations have succeeded.
+//
+// The context is documented in the client package under
+// docs/07_POST_UPDATE_HOOKS.md: app_root, instance, from_version, to_version,
+// backup_dir, run_id and the migrations applied in this run.
+
+function psaAfterUpdate(array $context): void
+{
+    $appRoot = rtrim((string) $context["app_root"], "/\\");
+
+    // A release ships no data/ directory, so a new installation - or a release
+    // that starts writing somewhere new - needs the writable directories to
+    // exist before the first request hits them.
+    foreach (["data", "data/uploads", "data/logs"] as $relative) {
+        $directory = $appRoot . "/" . $relative;
+        if (!is_dir($directory) && !mkdir($directory, 02775, true) && !is_dir($directory)) {
+            throw new RuntimeException("Verzeichnis konnte nicht angelegt werden: " . $relative);
+        }
+    }
+
+    // The deployment replaced PHP files underneath a running opcache. Resetting
+    // it here only helps when the update was triggered from the admin panel;
+    // from the CLI it is a no-op, because the web server runs its own cache.
+    if (function_exists("opcache_reset") && ini_get("opcache.enable")) {
+        @opcache_reset();
+    }
+}

+ 0 - 1114
includes/backup.php

@@ -1,1114 +0,0 @@
-<?php
-
-require_once __DIR__ . "/functions.php";
-
-if (!defined("BACKUP_DIR")) {
-    define("BACKUP_DIR", DATA_DIR . "backups/");
-}
-if (!defined("BACKUP_LOCAL_RETENTION")) {
-    define("BACKUP_LOCAL_RETENTION", 4);
-}
-if (!defined("BACKUP_AUTO_INTERVAL_SECONDS")) {
-    define("BACKUP_AUTO_INTERVAL_SECONDS", 604800);
-}
-if (!defined("BACKUP_REMOTE_TARGETS")) {
-    define("BACKUP_REMOTE_TARGETS", []);
-}
-
-class BackupRemoteUploadException extends RuntimeException
-{
-    private array $debugContext;
-
-    public function __construct(string $message, array $debugContext = [])
-    {
-        parent::__construct($message);
-        $this->debugContext = $debugContext;
-    }
-
-    public function getDebugContext(): array
-    {
-        return $this->debugContext;
-    }
-}
-
-function backupGetDirectory(): string
-{
-    return rtrim((string) BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR;
-}
-
-function backupGetIndexFile(): string
-{
-    return backupGetDirectory() . "backup-index.json";
-}
-
-function backupGetLockFile(): string
-{
-    return backupGetDirectory() . ".backup.lock";
-}
-
-function backupEnsureDirectory(string $dir): void
-{
-    if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
-        throw new RuntimeException("Backup-Verzeichnis konnte nicht erstellt werden.");
-    }
-
-    @chmod($dir, 02775);
-}
-
-function backupNormalizePath(string $path): string
-{
-    return str_replace("\\", "/", $path);
-}
-
-function backupIsTemporaryFile(string $path): bool
-{
-    $name = basename($path);
-    return $name === "" ||
-        $name[0] === "." ||
-        str_ends_with($name, ".tmp") ||
-        str_ends_with($name, ".part");
-}
-
-function backupGetSourceFiles(): array
-{
-    $dataDir = rtrim(DATA_DIR, "/\\") . DIRECTORY_SEPARATOR;
-    $files = [];
-
-    foreach (glob($dataDir . "*.json") ?: [] as $file) {
-        if (is_file($file) && is_readable($file) && !backupIsTemporaryFile($file)) {
-            $files[] = [
-                "path" => $file,
-                "name" => "data/" . basename($file),
-            ];
-        }
-    }
-
-    $uploadsDir = rtrim(UPLOADS_DIR, "/\\") . DIRECTORY_SEPARATOR;
-    if (is_dir($uploadsDir)) {
-        $items = new RecursiveIteratorIterator(
-            new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS),
-            RecursiveIteratorIterator::LEAVES_ONLY,
-        );
-
-        foreach ($items as $item) {
-            if (!$item->isFile() || !$item->isReadable()) {
-                continue;
-            }
-
-            $path = $item->getPathname();
-            if (backupIsTemporaryFile($path)) {
-                continue;
-            }
-
-            $relative = ltrim(
-                backupNormalizePath(substr($path, strlen($uploadsDir))),
-                "/",
-            );
-            if ($relative === "" || str_contains($relative, "\0")) {
-                continue;
-            }
-
-            $files[] = [
-                "path" => $path,
-                "name" => "data/uploads/" . $relative,
-            ];
-        }
-    }
-
-    usort($files, function ($left, $right) {
-        return strcmp($left["name"], $right["name"]);
-    });
-
-    return $files;
-}
-
-function backupGetDosDateTime(int $timestamp): array
-{
-    $parts = getdate($timestamp);
-    $year = max(1980, (int) $parts["year"]);
-
-    return [
-        (($year - 1980) << 9) | ((int) $parts["mon"] << 5) | (int) $parts["mday"],
-        ((int) $parts["hours"] << 11) |
-            ((int) $parts["minutes"] << 5) |
-            ((int) floor(((int) $parts["seconds"]) / 2)),
-    ];
-}
-
-function backupValidateZipEntryName(string $name): void
-{
-    $name = backupNormalizePath($name);
-
-    if (
-        $name === "" ||
-        str_contains($name, "\0") ||
-        str_starts_with($name, "/") ||
-        preg_match('/^[A-Za-z]:\//', $name) === 1
-    ) {
-        throw new RuntimeException("Ungültiger Backup-Pfad: " . $name);
-    }
-
-    foreach (explode("/", $name) as $segment) {
-        if ($segment === "" || $segment === "." || $segment === "..") {
-            throw new RuntimeException("Ungültiger Backup-Pfad: " . $name);
-        }
-    }
-
-    if (strlen($name) > 65535) {
-        throw new RuntimeException("Backup-Pfad ist zu lang: " . $name);
-    }
-}
-
-function backupWriteBytes($handle, string $data): void
-{
-    $offset = 0;
-    $length = strlen($data);
-
-    while ($offset < $length) {
-        $written = fwrite($handle, substr($data, $offset));
-        if ($written === false || $written === 0) {
-            throw new RuntimeException("Backup-ZIP konnte nicht geschrieben werden.");
-        }
-        $offset += $written;
-    }
-}
-
-function backupCopyFileToHandle(string $file, $handle): void
-{
-    $source = fopen($file, "rb");
-    if ($source === false) {
-        throw new RuntimeException("Backup-Datei konnte nicht gelesen werden: " . basename($file));
-    }
-
-    while (!feof($source)) {
-        $chunk = fread($source, 1048576);
-        if ($chunk === false) {
-            fclose($source);
-            throw new RuntimeException("Backup-Datei konnte nicht gelesen werden: " . basename($file));
-        }
-        if ($chunk !== "") {
-            try {
-                backupWriteBytes($handle, $chunk);
-            } catch (Throwable $exception) {
-                fclose($source);
-                throw $exception;
-            }
-        }
-    }
-
-    fclose($source);
-}
-
-function backupWriteZip(string $targetFile, array $files): array
-{
-    if (empty($files)) {
-        throw new RuntimeException("Keine Daten-Dateien für das Backup gefunden.");
-    }
-
-    $handle = fopen($targetFile, "wb");
-    if ($handle === false) {
-        throw new RuntimeException("Backup-ZIP konnte nicht erstellt werden.");
-    }
-
-    $centralDirectory = "";
-    $fileCount = 0;
-    $sourceBytes = 0;
-
-    try {
-        foreach ($files as $file) {
-            $path = (string) ($file["path"] ?? "");
-            $name = backupNormalizePath((string) ($file["name"] ?? ""));
-            backupValidateZipEntryName($name);
-
-            if (!is_file($path) || !is_readable($path)) {
-                continue;
-            }
-
-            $size = filesize($path);
-            if ($size === false) {
-                throw new RuntimeException("Backup-Dateigröße konnte nicht ermittelt werden: " . $name);
-            }
-            if ($size > 0xffffffff) {
-                throw new RuntimeException("Datei ist zu groß für dieses Backup-Format: " . $name);
-            }
-
-            $offset = ftell($handle);
-            if ($offset === false || $offset > 0xffffffff) {
-                throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
-            }
-
-            $crcHex = hash_file("crc32b", $path);
-            if (!is_string($crcHex) || !preg_match('/^[a-f0-9]{8}$/i', $crcHex)) {
-                throw new RuntimeException("Prüfsumme konnte nicht berechnet werden: " . $name);
-            }
-            $crc = (int) hexdec($crcHex);
-            [$dosDate, $dosTime] = backupGetDosDateTime((int) (filemtime($path) ?: time()));
-            $nameLength = strlen($name);
-
-            backupWriteBytes(
-                $handle,
-                pack(
-                    "VvvvvvVVVvv",
-                    0x04034b50,
-                    10,
-                    0,
-                    0,
-                    $dosTime,
-                    $dosDate,
-                    $crc,
-                    $size,
-                    $size,
-                    $nameLength,
-                    0,
-                ) . $name,
-            );
-
-            backupCopyFileToHandle($path, $handle);
-
-            $centralDirectory .=
-                pack(
-                    "VvvvvvvVVVvvvvvVV",
-                    0x02014b50,
-                    0x031e,
-                    10,
-                    0,
-                    0,
-                    $dosTime,
-                    $dosDate,
-                    $crc,
-                    $size,
-                    $size,
-                    $nameLength,
-                    0,
-                    0,
-                    0,
-                    0,
-                    0,
-                    $offset,
-                ) .
-                $name;
-
-            $fileCount++;
-            $sourceBytes += $size;
-        }
-
-        if ($fileCount < 1) {
-            throw new RuntimeException("Keine lesbaren Daten-Dateien für das Backup gefunden.");
-        }
-        if ($fileCount > 65535) {
-            throw new RuntimeException("Zu viele Dateien für dieses Backup-Format.");
-        }
-
-        $centralOffset = ftell($handle);
-        $centralSize = strlen($centralDirectory);
-        if (
-            $centralOffset === false ||
-            $centralOffset > 0xffffffff ||
-            $centralSize > 0xffffffff
-        ) {
-            throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
-        }
-
-        backupWriteBytes($handle, $centralDirectory);
-        backupWriteBytes(
-            $handle,
-            pack(
-                "VvvvvVVv",
-                0x06054b50,
-                0,
-                0,
-                $fileCount,
-                $fileCount,
-                $centralSize,
-                $centralOffset,
-                0,
-            ),
-        );
-    } catch (Throwable $exception) {
-        fclose($handle);
-        @unlink($targetFile);
-        throw $exception;
-    }
-
-    fclose($handle);
-    @chmod($targetFile, 0660);
-
-    return [
-        "file_count" => $fileCount,
-        "source_bytes" => $sourceBytes,
-        "archive_bytes" => (int) (filesize($targetFile) ?: 0),
-        "sha256" => hash_file("sha256", $targetFile) ?: "",
-    ];
-}
-
-function backupReadIndex(): array
-{
-    $index = readJsonFile(backupGetIndexFile());
-    $records =
-        isset($index["backups"]) && is_array($index["backups"])
-            ? $index["backups"]
-            : [];
-
-    return ["backups" => array_values($records)];
-}
-
-function backupWriteIndex(array $records): bool
-{
-    return writeJsonFile(backupGetIndexFile(), [
-        "backups" => array_values($records),
-    ]);
-}
-
-function backupListBackups(): array
-{
-    $records = backupReadIndex()["backups"];
-    $dir = backupGetDirectory();
-    $existing = [];
-
-    foreach ($records as $record) {
-        if (!is_array($record)) {
-            continue;
-        }
-
-        $filename = basename((string) ($record["filename"] ?? ""));
-        if ($filename === "" || !is_file($dir . $filename)) {
-            continue;
-        }
-
-        $record["filename"] = $filename;
-        $record["size"] = (int) (filesize($dir . $filename) ?: ($record["size"] ?? 0));
-        $existing[] = $record;
-    }
-
-    usort($existing, function ($left, $right) {
-        return strcmp((string) ($right["created_at"] ?? ""), (string) ($left["created_at"] ?? ""));
-    });
-
-    return $existing;
-}
-
-function backupFormatBytes(int $bytes): string
-{
-    if ($bytes >= 1073741824) {
-        return number_format($bytes / 1073741824, 2, ",", ".") . " GB";
-    }
-    if ($bytes >= 1048576) {
-        return number_format($bytes / 1048576, 2, ",", ".") . " MB";
-    }
-    if ($bytes >= 1024) {
-        return number_format($bytes / 1024, 1, ",", ".") . " KB";
-    }
-    return $bytes . " B";
-}
-
-function backupGetRetentionLimit(): int
-{
-    return max(1, (int) BACKUP_LOCAL_RETENTION);
-}
-
-function backupApplyRetention(): void
-{
-    $records = backupListBackups();
-    $keep = backupGetRetentionLimit();
-    $dir = backupGetDirectory();
-
-    foreach (array_slice($records, $keep) as $record) {
-        $filename = basename((string) ($record["filename"] ?? ""));
-        if ($filename !== "" && is_file($dir . $filename)) {
-            @unlink($dir . $filename);
-        }
-    }
-
-    backupWriteIndex(array_slice(backupListBackups(), 0, $keep));
-}
-
-function backupGetRemoteTargets(): array
-{
-    return is_array(BACKUP_REMOTE_TARGETS) ? BACKUP_REMOTE_TARGETS : [];
-}
-
-function backupGetTargetLabel(array $target, int $index): string
-{
-    $name = trim((string) ($target["name"] ?? ""));
-    if ($name !== "") {
-        return $name;
-    }
-
-    $type = trim((string) ($target["type"] ?? "target"));
-    return $type . "-" . ($index + 1);
-}
-
-function backupGetSafeTargetContext(array $target): array
-{
-    $safe = [];
-    $allowedKeys = [
-        "name",
-        "type",
-        "url",
-        "instance",
-        "bucket",
-        "region",
-        "prefix",
-        "endpoint",
-        "host",
-        "port",
-        "username",
-        "path",
-        "file",
-        "callback",
-        "timeout",
-    ];
-
-    foreach ($allowedKeys as $key) {
-        if (array_key_exists($key, $target)) {
-            $safe[$key] = is_scalar($target[$key]) ? (string) $target[$key] : gettype($target[$key]);
-        }
-    }
-
-    return $safe;
-}
-
-function backupGetHttpUserAgent(): string
-{
-    $version = defined("APP_VERSION") ? trim((string) APP_VERSION) : "";
-    if ($version === "") {
-        $version = "unknown";
-    }
-
-    return "PSA-Orderform-Backup/" . $version;
-}
-
-function backupFormatResponseExcerpt($response): string
-{
-    if (!is_string($response) || $response === "") {
-        return "";
-    }
-
-    $response = preg_replace('/\s+/', " ", trim($response));
-    if (!is_string($response)) {
-        return "";
-    }
-
-    return substr($response, 0, 500);
-}
-
-function backupFormatHeaderExcerpt(array $headers): array
-{
-    $result = [];
-    foreach ($headers as $header) {
-        $header = trim((string) $header);
-        if ($header === "") {
-            continue;
-        }
-        $result[] = substr($header, 0, 500);
-        if (count($result) >= 20) {
-            break;
-        }
-    }
-
-    return $result;
-}
-
-function backupGetLastPhpErrorMessage(): string
-{
-    $error = error_get_last();
-    if (!is_array($error)) {
-        return "";
-    }
-
-    return substr(trim((string) ($error["message"] ?? "")), 0, 500);
-}
-
-function backupRemoteCapabilities(): array
-{
-    $targets = backupGetRemoteTargets();
-    $types = [];
-    foreach ($targets as $target) {
-        if (is_array($target)) {
-            $type = trim((string) ($target["type"] ?? ""));
-            if ($type !== "") {
-                $types[$type] = true;
-            }
-        }
-    }
-
-    return [
-        "s3" => [
-            "configured" => !empty($types["s3"]),
-            "available" => function_exists("hash_hmac"),
-        ],
-        "sftp" => [
-            "configured" => !empty($types["sftp"]),
-            "available" =>
-                function_exists("ssh2_connect") &&
-                function_exists("ssh2_sftp"),
-        ],
-        "custom" => [
-            "configured" => !empty($types["custom"]),
-            "available" => true,
-        ],
-        "managed" => [
-            "configured" => !empty($types["managed"]),
-            "available" => true,
-        ],
-    ];
-}
-
-function backupGetHttpStatusFromHeaders(array $headers): int
-{
-    foreach ($headers as $header) {
-        if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) {
-            return (int) $matches[1];
-        }
-    }
-
-    return 0;
-}
-
-function backupUploadToS3(string $archivePath, array $metadata, array $target): array
-{
-    $bucket = trim((string) ($target["bucket"] ?? ""));
-    $region = trim((string) ($target["region"] ?? ""));
-    $accessKey = trim((string) ($target["access_key"] ?? ""));
-    $secretKey = (string) ($target["secret_key"] ?? "");
-    $prefix = trim((string) ($target["prefix"] ?? ""), "/");
-    $endpoint = rtrim(trim((string) ($target["endpoint"] ?? "")), "/");
-
-    if ($bucket === "" || $region === "" || $accessKey === "" || $secretKey === "") {
-        throw new RuntimeException("S3-Ziel ist unvollständig konfiguriert.");
-    }
-
-    $filename = basename($archivePath);
-    $key = ($prefix !== "" ? $prefix . "/" : "") . $filename;
-    $host = $endpoint !== ""
-        ? parse_url($endpoint, PHP_URL_HOST)
-        : $bucket . ".s3." . $region . ".amazonaws.com";
-    if (!is_string($host) || $host === "") {
-        throw new RuntimeException("S3-Endpunkt ist ungültig.");
-    }
-
-    $url = $endpoint !== ""
-        ? $endpoint . "/" . rawurlencode($bucket) . "/" . str_replace("%2F", "/", rawurlencode($key))
-        : "https://" . $host . "/" . str_replace("%2F", "/", rawurlencode($key));
-
-    $payload = file_get_contents($archivePath);
-    if ($payload === false) {
-        throw new RuntimeException("Backup-ZIP konnte für S3 nicht gelesen werden.");
-    }
-
-    $now = gmdate("Ymd\THis\Z");
-    $date = substr($now, 0, 8);
-    $payloadHash = hash("sha256", $payload);
-    $canonicalUri = parse_url($url, PHP_URL_PATH);
-    $canonicalUri = is_string($canonicalUri) && $canonicalUri !== "" ? $canonicalUri : "/";
-    $signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
-    $canonicalHeaders =
-        "content-type:application/zip\n" .
-        "host:" . $host . "\n" .
-        "x-amz-content-sha256:" . $payloadHash . "\n" .
-        "x-amz-date:" . $now . "\n";
-    $canonicalRequest =
-        "PUT\n" .
-        $canonicalUri .
-        "\n\n" .
-        $canonicalHeaders .
-        "\n" .
-        $signedHeaders .
-        "\n" .
-        $payloadHash;
-    $scope = $date . "/" . $region . "/s3/aws4_request";
-    $stringToSign =
-        "AWS4-HMAC-SHA256\n" .
-        $now .
-        "\n" .
-        $scope .
-        "\n" .
-        hash("sha256", $canonicalRequest);
-    $kDate = hash_hmac("sha256", $date, "AWS4" . $secretKey, true);
-    $kRegion = hash_hmac("sha256", $region, $kDate, true);
-    $kService = hash_hmac("sha256", "s3", $kRegion, true);
-    $kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
-    $signature = hash_hmac("sha256", $stringToSign, $kSigning);
-    $authorization =
-        "AWS4-HMAC-SHA256 Credential=" .
-        $accessKey .
-        "/" .
-        $scope .
-        ", SignedHeaders=" .
-        $signedHeaders .
-        ", Signature=" .
-        $signature;
-
-    $context = stream_context_create([
-        "http" => [
-            "method" => "PUT",
-            "timeout" => (int) ($target["timeout"] ?? 120),
-            "ignore_errors" => true,
-            "header" =>
-                "Content-Type: application/zip\r\n" .
-                "Content-Length: " . strlen($payload) . "\r\n" .
-                "Host: " . $host . "\r\n" .
-                "X-Amz-Date: " . $now . "\r\n" .
-                "X-Amz-Content-Sha256: " . $payloadHash . "\r\n" .
-                "Authorization: " . $authorization . "\r\n" .
-                "User-Agent: " . backupGetHttpUserAgent() . "\r\n",
-            "content" => $payload,
-        ],
-    ]);
-
-    $response = @file_get_contents($url, false, $context);
-    $phpError = $response === false ? backupGetLastPhpErrorMessage() : "";
-    if (function_exists("http_get_last_response_headers")) {
-        $lastHeaders = http_get_last_response_headers();
-        $headers = is_array($lastHeaders) ? $lastHeaders : [];
-    } else {
-        $legacyHeaders = ${"http_response_header"} ?? [];
-        $headers = is_array($legacyHeaders)
-            ? $legacyHeaders
-            : [];
-    }
-    $status = backupGetHttpStatusFromHeaders($headers);
-
-    if ($response === false || $status < 200 || $status >= 300) {
-        throw new BackupRemoteUploadException(
-            "S3-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
-            [
-                "http_status" => $status,
-                "response_excerpt" => backupFormatResponseExcerpt($response),
-                "response_headers" => backupFormatHeaderExcerpt($headers),
-                "php_error" => $phpError,
-                "bucket" => $bucket,
-                "region" => $region,
-                "key" => $key,
-                "endpoint" => $endpoint,
-            ],
-        );
-    }
-
-    return ["remote_path" => "s3://" . $bucket . "/" . $key];
-}
-
-function backupUploadToSftp(string $archivePath, array $metadata, array $target): array
-{
-    if (!function_exists("ssh2_connect") || !function_exists("ssh2_sftp")) {
-        throw new RuntimeException("PHP-SSH2-Erweiterung ist nicht verfügbar.");
-    }
-
-    $host = trim((string) ($target["host"] ?? ""));
-    $username = trim((string) ($target["username"] ?? ""));
-    $password = (string) ($target["password"] ?? "");
-    $remoteDir = rtrim((string) ($target["path"] ?? ""), "/");
-    $port = (int) ($target["port"] ?? 22);
-
-    if ($host === "" || $username === "" || $remoteDir === "") {
-        throw new RuntimeException("SFTP-Ziel ist unvollständig konfiguriert.");
-    }
-
-    $connection = @ssh2_connect($host, $port > 0 ? $port : 22);
-    if ($connection === false) {
-        throw new RuntimeException("SFTP-Verbindung konnte nicht hergestellt werden.");
-    }
-
-    $authenticated = false;
-    $privateKey = trim((string) ($target["private_key"] ?? ""));
-    $publicKey = trim((string) ($target["public_key"] ?? ""));
-    if (
-        $privateKey !== "" &&
-        $publicKey !== "" &&
-        function_exists("ssh2_auth_pubkey_file")
-    ) {
-        $authenticated = @ssh2_auth_pubkey_file(
-            $connection,
-            $username,
-            $publicKey,
-            $privateKey,
-            $password !== "" ? $password : null,
-        );
-    } elseif (function_exists("ssh2_auth_password")) {
-        $authenticated = @ssh2_auth_password($connection, $username, $password);
-    }
-
-    if (!$authenticated) {
-        throw new RuntimeException("SFTP-Anmeldung fehlgeschlagen.");
-    }
-
-    $sftp = @ssh2_sftp($connection);
-    if ($sftp === false) {
-        throw new RuntimeException("SFTP-Subsystem konnte nicht gestartet werden.");
-    }
-
-    $remotePath = $remoteDir . "/" . basename($archivePath);
-    $targetStream = @fopen("ssh2.sftp://" . intval($sftp) . $remotePath, "wb");
-    if ($targetStream === false) {
-        throw new RuntimeException("SFTP-Zieldatei konnte nicht geöffnet werden.");
-    }
-
-    $source = fopen($archivePath, "rb");
-    if ($source === false) {
-        fclose($targetStream);
-        throw new RuntimeException("Backup-ZIP konnte für SFTP nicht gelesen werden.");
-    }
-
-    $copied = stream_copy_to_stream($source, $targetStream);
-    fclose($source);
-    fclose($targetStream);
-
-    if ($copied === false) {
-        throw new RuntimeException("SFTP-Upload fehlgeschlagen.");
-    }
-
-    return ["remote_path" => "sftp://" . $host . $remotePath];
-}
-
-function backupValidateManagedInstance(string $instance): string
-{
-    $instance = trim($instance);
-    if (
-        $instance === "" ||
-        strlen($instance) > 120 ||
-        preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $instance) !== 1
-    ) {
-        throw new RuntimeException("Managed-Backup-Instanz ist ungültig.");
-    }
-
-    return $instance;
-}
-
-function backupBuildMultipartBody(array $fields, string $fileField, string $filePath, string $fileName, string $boundary): string
-{
-    $body = "";
-    foreach ($fields as $name => $value) {
-        $body .= "--" . $boundary . "\r\n";
-        $body .= 'Content-Disposition: form-data; name="' . addcslashes((string) $name, "\"\\") . "\"\r\n\r\n";
-        $body .= (string) $value . "\r\n";
-    }
-
-    $payload = file_get_contents($filePath);
-    if ($payload === false) {
-        throw new RuntimeException("Backup-ZIP konnte für Managed Upload nicht gelesen werden.");
-    }
-
-    $body .= "--" . $boundary . "\r\n";
-    $body .=
-        'Content-Disposition: form-data; name="' .
-        addcslashes($fileField, "\"\\") .
-        '"; filename="' .
-        addcslashes($fileName, "\"\\") .
-        "\"\r\n";
-    $body .= "Content-Type: application/zip\r\n\r\n";
-    $body .= $payload . "\r\n";
-    $body .= "--" . $boundary . "--\r\n";
-
-    return $body;
-}
-
-function backupUploadToManaged(string $archivePath, array $metadata, array $target): array
-{
-    $url = trim((string) ($target["url"] ?? ""));
-    $instance = backupValidateManagedInstance((string) ($target["instance"] ?? ""));
-
-    if (!filter_var($url, FILTER_VALIDATE_URL)) {
-        throw new RuntimeException("Managed-Backup-URL ist ungültig.");
-    }
-
-    $filename = basename($archivePath);
-    $sha256 = trim((string) ($metadata["sha256"] ?? ""));
-    if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
-        $sha256 = strtolower(hash_file("sha256", $archivePath) ?: "");
-    }
-    if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
-        throw new RuntimeException("Managed-Backup-Prüfsumme konnte nicht berechnet werden.");
-    }
-
-    $boundary = "----psa-backup-" . bin2hex(random_bytes(12));
-    $body = backupBuildMultipartBody(
-        [
-            "instance" => $instance,
-            "filename" => $filename,
-            "sha256" => $sha256,
-        ],
-        "backup",
-        $archivePath,
-        $filename,
-        $boundary,
-    );
-
-    $context = stream_context_create([
-        "http" => [
-            "method" => "POST",
-            "timeout" => (int) ($target["timeout"] ?? 120),
-            "ignore_errors" => true,
-            "header" =>
-                "Content-Type: multipart/form-data; boundary=" .
-                $boundary .
-                "\r\nAccept: application/json\r\nUser-Agent: " .
-                backupGetHttpUserAgent() .
-                "\r\nContent-Length: " .
-                strlen($body) .
-                "\r\n",
-            "content" => $body,
-        ],
-    ]);
-
-    $response = @file_get_contents($url, false, $context);
-    $phpError = $response === false ? backupGetLastPhpErrorMessage() : "";
-    if (function_exists("http_get_last_response_headers")) {
-        $lastHeaders = http_get_last_response_headers();
-        $headers = is_array($lastHeaders) ? $lastHeaders : [];
-    } else {
-        $legacyHeaders = ${"http_response_header"} ?? [];
-        $headers = is_array($legacyHeaders)
-            ? $legacyHeaders
-            : [];
-    }
-    $status = backupGetHttpStatusFromHeaders($headers);
-    if ($response === false || $status < 200 || $status >= 300) {
-        throw new BackupRemoteUploadException(
-            "Managed-Backup-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
-            [
-                "http_status" => $status,
-                "response_excerpt" => backupFormatResponseExcerpt($response),
-                "response_headers" => backupFormatHeaderExcerpt($headers),
-                "php_error" => $phpError,
-                "url" => $url,
-                "instance" => $instance,
-            ],
-        );
-    }
-
-    $decoded = json_decode($response, true);
-    if (!is_array($decoded) || empty($decoded["success"])) {
-        $error = is_array($decoded) ? trim((string) ($decoded["error"] ?? "")) : "";
-        throw new BackupRemoteUploadException(
-            "Managed-Backup-Upload wurde abgelehnt" . ($error !== "" ? ": " . $error : "."),
-            [
-                "http_status" => $status,
-                "response_excerpt" => backupFormatResponseExcerpt($response),
-                "response_headers" => backupFormatHeaderExcerpt($headers),
-                "url" => $url,
-                "instance" => $instance,
-                "server_error" => $error,
-            ],
-        );
-    }
-
-    return [
-        "remote_path" => $url,
-        "instance" => $instance,
-        "server_filename" => (string) ($decoded["filename"] ?? ""),
-    ];
-}
-
-function backupUploadToCustom(string $archivePath, array $metadata, array $target): array
-{
-    $file = trim((string) ($target["file"] ?? ""));
-    $callback = $target["callback"] ?? null;
-
-    if ($file !== "") {
-        if (!is_file($file)) {
-            throw new RuntimeException("Custom-Uploader-Datei wurde nicht gefunden.");
-        }
-        require_once $file;
-    }
-
-    if (!is_callable($callback)) {
-        throw new RuntimeException("Custom-Uploader ist nicht aufrufbar.");
-    }
-
-    $result = call_user_func($callback, $archivePath, $metadata, $target);
-    if ($result === true) {
-        return [];
-    }
-    if (is_array($result)) {
-        return $result;
-    }
-
-    throw new RuntimeException("Custom-Uploader meldet einen Fehler.");
-}
-
-function backupUploadRemotes(string $archivePath, array $metadata): array
-{
-    $results = [];
-
-    foreach (backupGetRemoteTargets() as $index => $target) {
-        if (!is_array($target)) {
-            continue;
-        }
-
-        $type = trim((string) ($target["type"] ?? ""));
-        $label = backupGetTargetLabel($target, (int) $index);
-        $startedAt = date("c");
-        $safeTargetContext = backupGetSafeTargetContext($target);
-
-        try {
-            if ($type === "s3") {
-                $extra = backupUploadToS3($archivePath, $metadata, $target);
-            } elseif ($type === "sftp") {
-                $extra = backupUploadToSftp($archivePath, $metadata, $target);
-            } elseif ($type === "custom") {
-                $extra = backupUploadToCustom($archivePath, $metadata, $target);
-            } elseif ($type === "managed") {
-                $extra = backupUploadToManaged($archivePath, $metadata, $target);
-            } else {
-                throw new RuntimeException("Unbekannter Backup-Zieltyp: " . $type);
-            }
-
-            $result = array_merge(
-                [
-                    "target" => $label,
-                    "type" => $type,
-                    "success" => true,
-                    "uploaded_at" => date("c"),
-                    "started_at" => $startedAt,
-                ],
-                is_array($extra) ? $extra : [],
-            );
-            $results[] = $result;
-
-            logAccess("Backup remote upload succeeded", [
-                "target" => $label,
-                "type" => $type,
-                "filename" => $metadata["filename"] ?? basename($archivePath),
-                "remote_path" => (string) ($result["remote_path"] ?? ""),
-            ]);
-        } catch (Throwable $exception) {
-            $debugContext = $exception instanceof BackupRemoteUploadException
-                ? $exception->getDebugContext()
-                : [];
-            $result = [
-                "target" => $label,
-                "type" => $type !== "" ? $type : "unknown",
-                "success" => false,
-                "started_at" => $startedAt,
-                "error" => $exception->getMessage(),
-            ];
-            if (!empty($debugContext)) {
-                $result["debug"] = $debugContext;
-            }
-            $results[] = $result;
-
-            logError("Backup remote upload failed", [
-                "target" => $label,
-                "type" => $type !== "" ? $type : "unknown",
-                "target_config" => $safeTargetContext,
-                "filename" => $metadata["filename"] ?? basename($archivePath),
-                "sha256" => $metadata["sha256"] ?? "",
-                "error" => $exception->getMessage(),
-                "debug" => $debugContext,
-            ]);
-        }
-    }
-
-    return $results;
-}
-
-function backupGetLastAutomaticAt(): int
-{
-    foreach (backupListBackups() as $record) {
-        if (($record["trigger"] ?? "") !== "automatic") {
-            continue;
-        }
-
-        $timestamp = strtotime((string) ($record["created_at"] ?? ""));
-        if ($timestamp !== false) {
-            return $timestamp;
-        }
-    }
-
-    return 0;
-}
-
-function backupIsAutomaticDue(): bool
-{
-    $interval = (int) BACKUP_AUTO_INTERVAL_SECONDS;
-    if ($interval < 1) {
-        return false;
-    }
-
-    return time() - backupGetLastAutomaticAt() >= $interval;
-}
-
-function backupCreate(string $trigger = "manual"): array
-{
-    $trigger = $trigger === "automatic" ? "automatic" : "manual";
-    $dir = backupGetDirectory();
-    backupEnsureDirectory($dir);
-
-    $lockHandle = fopen(backupGetLockFile(), "c+");
-    if ($lockHandle === false) {
-        throw new RuntimeException("Backup-Sperrdatei konnte nicht geöffnet werden.");
-    }
-
-    if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
-        fclose($lockHandle);
-        throw new RuntimeException("Es läuft bereits ein Backup.");
-    }
-
-    try {
-        $baseName = "backup-" . date("Ymd-His");
-        $filename = $baseName . ".zip";
-        $counter = 2;
-        while (file_exists($dir . $filename)) {
-            $filename = $baseName . "-" . $counter . ".zip";
-            $counter++;
-        }
-
-        $tmpFile = $dir . "." . $filename . ".tmp";
-        $archivePath = $dir . $filename;
-        $createdAt = date("c");
-        $zipStats = backupWriteZip($tmpFile, backupGetSourceFiles());
-
-        if (!rename($tmpFile, $archivePath)) {
-            @unlink($tmpFile);
-            throw new RuntimeException("Backup-ZIP konnte nicht finalisiert werden.");
-        }
-        @chmod($archivePath, 0660);
-
-        $record = [
-            "filename" => $filename,
-            "created_at" => $createdAt,
-            "trigger" => $trigger,
-            "size" => (int) (filesize($archivePath) ?: $zipStats["archive_bytes"]),
-            "file_count" => $zipStats["file_count"],
-            "source_bytes" => $zipStats["source_bytes"],
-            "sha256" => $zipStats["sha256"],
-            "remote_uploads" => backupUploadRemotes($archivePath, [
-                "filename" => $filename,
-                "created_at" => $createdAt,
-                "trigger" => $trigger,
-                "sha256" => $zipStats["sha256"],
-            ]),
-        ];
-
-        $records = backupListBackups();
-        array_unshift($records, $record);
-        backupWriteIndex($records);
-        backupApplyRetention();
-
-        logAccess("Backup created", [
-            "filename" => $filename,
-            "trigger" => $trigger,
-            "file_count" => $record["file_count"],
-        ]);
-
-        return $record;
-    } catch (Throwable $exception) {
-        logError("Backup failed", [
-            "trigger" => $trigger,
-            "error" => $exception->getMessage(),
-        ]);
-        throw $exception;
-    } finally {
-        flock($lockHandle, LOCK_UN);
-        fclose($lockHandle);
-    }
-}
-
-function backupCreateAutomaticIfDue(): ?array
-{
-    if (!backupIsAutomaticDue()) {
-        return null;
-    }
-
-    return backupCreate("automatic");
-}

+ 119 - 0
includes/manage-activity.php

@@ -0,0 +1,119 @@
+<?php
+
+// Cron replacement: the periodic manage-client tasks, triggered by admin
+// activity instead of a scheduler.
+//
+// This deployment usually has no cron, so the two jobs that would otherwise run
+// from manage-client/bin/manage-client.php happen when an admin loads the
+// dashboard: the automatic backup once its interval has passed, and the status
+// report to the manage server once its own, much shorter interval has passed.
+//
+// Both are throttled and neither may break the page it runs on, so nothing in
+// here throws.
+
+require_once __DIR__ . "/../manage-client/lib/client.php";
+
+// How long an admin session may pass without the manage server hearing from
+// this instance. Short enough that the server dashboard stays meaningful,
+// long enough that it is not one request per admin page view.
+if (!defined("MANAGE_ACTIVITY_HEARTBEAT_INTERVAL")) {
+    define("MANAGE_ACTIVITY_HEARTBEAT_INTERVAL", 3600);
+}
+
+function manageActivityStateFile(): string
+{
+    // Next to the client's own log, in data/manage/ - gitignored and blocked
+    // from the web by the .htaccess in the project root.
+    return dirname((string) MANAGE_LOG_FILE) . "/activity.json";
+}
+
+function manageActivityReadState(): array
+{
+    $file = manageActivityStateFile();
+    if (!is_file($file) || !is_readable($file)) {
+        return [];
+    }
+
+    $decoded = json_decode((string) file_get_contents($file), true);
+
+    return is_array($decoded) ? $decoded : [];
+}
+
+function manageActivityWriteState(array $state): void
+{
+    $file = manageActivityStateFile();
+    $dir = dirname($file);
+
+    if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
+        return;
+    }
+
+    @file_put_contents($file, json_encode($state, JSON_PRETTY_PRINT), LOCK_EX);
+}
+
+/**
+ * Sends the status report if the interval has passed.
+ *
+ * The timestamp is written before the request, not after: a manage server that
+ * hangs must not turn every following page load into another attempt.
+ *
+ * @return bool whether a heartbeat was attempted
+ */
+function manageActivityHeartbeatIfDue(bool $force = false): bool
+{
+    $interval = (int) MANAGE_ACTIVITY_HEARTBEAT_INTERVAL;
+    if (!$force && $interval <= 0) {
+        return false;
+    }
+
+    if (!manageClientConfigured()) {
+        return false;
+    }
+
+    $state = manageActivityReadState();
+    $last = (int) ($state["last_heartbeat_at"] ?? 0);
+
+    if (!$force && $last > 0 && time() - $last < $interval) {
+        return false;
+    }
+
+    $state["last_heartbeat_at"] = time();
+    manageActivityWriteState($state);
+
+    manageHeartbeatSendQuietly();
+
+    return true;
+}
+
+/**
+ * Runs the due jobs for one admin page view.
+ *
+ * @return array{message: string, type: string} empty message when nothing happened
+ */
+function manageActivityRunDueTasks(): array
+{
+    $result = ["message" => "", "type" => ""];
+    $backup = null;
+
+    try {
+        $backup = manageBackupCreateAutomaticIfDue();
+        if ($backup !== null) {
+            $result = [
+                "message" => "Automatisches Backup wurde erstellt: " . $backup["filename"] . ".",
+                "type" => "success",
+            ];
+        }
+    } catch (Throwable $exception) {
+        $result = [
+            "message" => "Automatisches Backup konnte nicht erstellt werden: " .
+                $exception->getMessage(),
+            "type" => "warning",
+        ];
+    }
+
+    // A fresh backup is worth reporting right away, so the server dashboard
+    // does not show a stale "last backup" for up to another interval.
+    manageActivityHeartbeatIfDue($backup !== null);
+
+    return $result;
+}

+ 1 - 1
includes/version.php

@@ -1,3 +1,3 @@
 <?php
 
-define("APP_VERSION", "v1.3.14");
+define("APP_VERSION", "v1.4.0");

+ 21 - 0
manage-client/.htaccess

@@ -0,0 +1,21 @@
+# The client library is included by PHP, never requested over HTTP.
+Options -Indexes
+
+<IfModule mod_authz_core.c>
+    <FilesMatch "^(config\.php|.*\.(json|log|md))$">
+        Require all denied
+    </FilesMatch>
+</IfModule>
+
+<IfModule !mod_authz_core.c>
+    <FilesMatch "^(config\.php|.*\.(json|log|md))$">
+        Order allow,deny
+        Deny from all
+    </FilesMatch>
+</IfModule>
+
+<IfModule mod_rewrite.c>
+    RewriteEngine On
+    # lib/ and bin/ must never be reachable directly.
+    RewriteRule ^(lib|bin)(?:/|$) - [F,L]
+</IfModule>

+ 296 - 0
manage-client/bin/manage-client.php

@@ -0,0 +1,296 @@
+#!/usr/bin/env php
+<?php
+
+declare(strict_types=1);
+
+// Command line interface for the manage client.
+//
+// Contains no logic of its own: every command calls the same public functions
+// the GUI panel and the host application use, so a feature behaves identically
+// however it is triggered.
+//
+// Usage:
+//   php manage-client/bin/manage-client.php status
+//                                           check
+//                                           update [--force] [--yes] [--skip-hook]
+//                                           migrate [--dry-run]
+//                                           backup [--trigger=cron]
+//                                           heartbeat
+//
+// Exit codes:
+//   0  success
+//   1  error (including a failed post-update step after a successful deploy)
+//   2  update available (check only)
+
+if (PHP_SAPI !== "cli") {
+    http_response_code(403);
+    exit("This script must be run from the command line.\n");
+}
+
+require_once dirname(__DIR__) . "/lib/client.php";
+
+$argv = $_SERVER["argv"] ?? [];
+array_shift($argv);
+
+$command = "";
+$flags = [];
+foreach ($argv as $argument) {
+    if (str_starts_with($argument, "--")) {
+        $parts = explode("=", substr($argument, 2), 2);
+        $flags[$parts[0]] = $parts[1] ?? true;
+    } elseif ($command === "") {
+        $command = $argument;
+    }
+}
+
+$quiet = isset($flags["quiet"]);
+
+function manageCliOut(string $line): void
+{
+    global $quiet;
+    if (!$quiet) {
+        fwrite(STDOUT, $line . PHP_EOL);
+    }
+}
+
+// Errors always print, even with --quiet, so a cron job still mails a failure.
+function manageCliError(string $line): void
+{
+    fwrite(STDERR, $line . PHP_EOL);
+}
+
+function manageCliUsage(): void
+{
+    fwrite(STDOUT, <<<TEXT
+Manage client
+
+  status                          Übersicht: Version, Update, Backups, Migrationen
+  check                           Prüft auf ein neues Release (Exit 2 = Update verfügbar)
+  update [--force] [--yes]        Spielt das aktuelle Release ein
+         [--skip-hook]            Nur Dateien ausrollen, ohne Migrationen/Hook
+  migrate [--dry-run]             Führt offene Migrationen aus
+  backup [--trigger=cron]         Erstellt ein Backup und lädt es hoch
+  heartbeat                       Meldet den Status an den Manage-Server
+
+Optionen: --quiet unterdrückt die normale Ausgabe (Fehler weiterhin auf STDERR).
+
+TEXT);
+}
+
+function manageCliConfirm(string $question): bool
+{
+    global $flags;
+    if (isset($flags["yes"])) {
+        return true;
+    }
+
+    fwrite(STDOUT, $question . " [j/N]: ");
+    $answer = trim((string) fgets(STDIN));
+
+    return in_array(strtolower($answer), ["j", "ja", "y", "yes"], true);
+}
+
+// Renders the migration/hook part of an update result. Shared by update and
+// migrate so both report a failure the same way.
+function manageCliReportHook(?array $hook): bool
+{
+    if ($hook === null) {
+        return true;
+    }
+
+    if (!empty($hook["skipped"])) {
+        $pending = (int) ($hook["migrations"]["pending"] ?? 0);
+        manageCliOut("  Post-Update übersprungen (--skip-hook)." .
+            ($pending > 0 ? " Offene Migrationen: " . $pending : ""));
+        return true;
+    }
+
+    $migrations = $hook["migrations"] ?? [];
+    $applied = $migrations["applied"] ?? [];
+    if ($applied !== []) {
+        manageCliOut("  Migrationen ausgeführt: " . implode(", ", $applied));
+    }
+
+    if (!empty($hook["success"])) {
+        if (!empty($hook["hook"]["configured"])) {
+            manageCliOut("  Post-Update-Hook ausgeführt.");
+        }
+        return true;
+    }
+
+    if (!empty($hook["failed_migration"])) {
+        manageCliError("  FEHLER in Migration " . $hook["failed_migration"] . ": " . (string) $hook["error"]);
+        manageCliError("  Verbleibende Migrationen wurden NICHT ausgeführt.");
+        manageCliError("  Ursache beheben und danach erneut ausführen: manage-client.php migrate");
+    } else {
+        manageCliError("  FEHLER im Post-Update-Hook: " . (string) ($hook["error"] ?? "unbekannt"));
+    }
+
+    return false;
+}
+
+try {
+    switch ($command) {
+        case "status":
+            $status = manageClientStatus();
+            manageCliOut("Instanz:           " . ($status["instance"] !== "" ? $status["instance"] : "(nicht gesetzt)"));
+            manageCliOut("Server:            " . ($status["server_url"] !== "" ? $status["server_url"] : "(nicht gesetzt)"));
+            manageCliOut("Konfiguriert:      " . ($status["configured"] ? "ja" : "NEIN"));
+            manageCliOut("Installierte Ver.: " . ($status["version"] !== "" ? $status["version"] : "unbekannt"));
+            manageCliOut("PHP:               " . $status["php_version"]);
+
+            if ($status["update"] !== null) {
+                manageCliOut("Aktuelles Release: " . $status["update"]["latest"]);
+                manageCliOut("Update verfügbar:  " . ($status["update"]["available"] ? "JA" : "nein"));
+            } elseif ($status["update_error"] !== null) {
+                manageCliOut("Update-Prüfung:    fehlgeschlagen (" . $status["update_error"] . ")");
+            }
+
+            manageCliOut("Lokale Backups:    " . count($status["backups"]));
+            manageCliOut("Letztes Backup:    " . ($status["last_backup_at"] ?? "nie"));
+            manageCliOut("Offene Migrationen: " . count($status["pending_migrations"]));
+
+            foreach ($status["pending_migrations"] as $migration) {
+                manageCliOut("  - " . $migration["id"]);
+            }
+            foreach ($status["errors"] as $error) {
+                manageCliError("Warnung: " . $error);
+            }
+            exit(0);
+
+        case "check":
+            $check = manageUpdateCheck();
+            manageCliOut("Installiert: " . ($check["current"] !== "" ? $check["current"] : "unbekannt"));
+            manageCliOut("Verfügbar:   " . $check["latest"]);
+            if ($check["available"]) {
+                manageCliOut("Ein Update ist verfügbar.");
+                exit(2);
+            }
+            manageCliOut("Die Installation ist aktuell.");
+            exit(0);
+
+        case "update":
+            $check = manageUpdateCheck();
+            $force = isset($flags["force"]);
+
+            if (!$check["available"] && !$force) {
+                manageCliOut("Kein Update verfügbar. Mit --force kann dasselbe Paket erneut ausgerollt werden.");
+                exit(0);
+            }
+
+            if (!manageCliConfirm(
+                "Version " . $check["latest"] . " jetzt ausrollen" .
+                ($check["current"] !== "" ? " (installiert: " . $check["current"] . ")" : "") . "?"
+            )) {
+                manageCliOut("Abgebrochen.");
+                exit(0);
+            }
+
+            $result = manageUpdateApply([
+                "force" => $force,
+                "skip_hook" => isset($flags["skip-hook"]),
+            ]);
+
+            manageCliOut("Update ausgerollt: " . $result["from_version"] . " -> " . $result["to_version"]);
+            manageCliOut("  Dateien kopiert:   " . $result["copied"]);
+            manageCliOut("  Dateien gesichert: " . $result["backed_up"]);
+            manageCliOut("  Geschützt übersprungen: " . $result["skipped"]);
+            manageCliOut("  Sicherungsverzeichnis: " . $result["backup_dir"]);
+
+            $hookOk = manageCliReportHook($result["hook"]);
+
+            manageHeartbeatSendQuietly();
+
+            // The deployment succeeded either way; the exit code reports the
+            // post-update step so a cron job notices a failed migration.
+            exit($hookOk ? 0 : 1);
+
+        case "migrate":
+            $pending = manageUpdatePendingMigrations();
+            if ($pending === []) {
+                manageCliOut("Keine offenen Migrationen.");
+                exit(0);
+            }
+
+            manageCliOut("Offene Migrationen: " . count($pending));
+            foreach ($pending as $migration) {
+                manageCliOut("  - " . $migration["id"]);
+            }
+
+            if (isset($flags["dry-run"])) {
+                manageCliOut("--dry-run: nichts ausgeführt.");
+                exit(0);
+            }
+
+            if (!manageCliConfirm("Diese Migrationen jetzt ausführen?")) {
+                manageCliOut("Abgebrochen.");
+                exit(0);
+            }
+
+            $report = manageUpdateRunMigrations();
+            if ($report["applied"] !== []) {
+                manageCliOut("Ausgeführt: " . implode(", ", $report["applied"]));
+            }
+            if (!$report["success"]) {
+                manageCliError("FEHLER in Migration " . (string) $report["failed"] . ": " . (string) $report["error"]);
+                manageCliError("Verbleibende Migrationen: " . ($report["pending"] - 1));
+                exit(1);
+            }
+
+            manageCliOut("Alle Migrationen abgeschlossen.");
+            manageHeartbeatSendQuietly();
+            exit(0);
+
+        case "backup":
+            $trigger = is_string($flags["trigger"] ?? null) ? (string) $flags["trigger"] : "manual";
+            $record = manageBackupCreate($trigger);
+
+            manageCliOut("Backup erstellt: " . $record["filename"]);
+            manageCliOut("  Dateien:  " . $record["file_count"]);
+            manageCliOut("  Größe:    " . manageFormatBytes((int) $record["size"]));
+            manageCliOut("  SHA-256:  " . $record["sha256"]);
+            if (is_array($record["database"] ?? null)) {
+                manageCliOut("  Datenbank: " . $record["database"]["tables"] . " Tabellen, " .
+                    $record["database"]["rows"] . " Zeilen");
+            }
+
+            $failed = false;
+            foreach ($record["remote_uploads"] as $upload) {
+                if (!empty($upload["success"])) {
+                    manageCliOut("  Upload " . $upload["target"] . ": OK");
+                } else {
+                    $failed = true;
+                    manageCliError("  Upload " . $upload["target"] . " FEHLGESCHLAGEN: " .
+                        (string) ($upload["error"] ?? "unbekannt"));
+                }
+            }
+
+            manageHeartbeatSendQuietly();
+
+            // The local archive exists regardless, but a failed upload must be
+            // visible to cron.
+            exit($failed ? 1 : 0);
+
+        case "heartbeat":
+            $result = manageHeartbeatSend();
+            manageCliOut("Heartbeat gesendet.");
+            manageCliOut("  Aktuelles Release: " . ($result["latest"] !== "" ? $result["latest"] : "keines"));
+            manageCliOut("  Update verfügbar:  " . ($result["update_available"] ? "JA" : "nein"));
+            exit(0);
+
+        case "":
+        case "help":
+        case "-h":
+        case "--help":
+            manageCliUsage();
+            exit(0);
+
+        default:
+            manageCliError("Unbekannter Befehl: " . $command);
+            manageCliUsage();
+            exit(1);
+    }
+} catch (Throwable $exception) {
+    manageCliError("Fehler: " . $exception->getMessage());
+    exit(1);
+}

+ 114 - 0
manage-client/config.sample.php

@@ -0,0 +1,114 @@
+<?php
+
+// Manage client configuration.
+//
+// Copy this file to config.php inside this folder and adjust the values.
+// config.php must NOT be committed to the host project's repository, and it
+// must NOT be part of the release package (see 06_UPDATE_PACKAGING.md).
+//
+// Every constant has a default in lib/client.php, so a minimal config.php only
+// needs the three connection values plus the backup sources.
+
+// ---------------------------------------------------------------------------
+// Connection
+// ---------------------------------------------------------------------------
+// Base URL of the manage server, without a trailing slash and without /api.
+define("MANAGE_SERVER_URL", "https://manage.example.org");
+
+// Instance id and token, both shown when the instance is created on the server
+// (Instanzen -> Instanz anlegen). The token is displayed exactly once.
+define("MANAGE_INSTANCE", "example-prod");
+define("MANAGE_TOKEN", "");
+
+// Seconds per HTTP request. Package downloads and backup uploads use the long
+// timeout, manifest and heartbeat the short one.
+define("MANAGE_HTTP_TIMEOUT", 15);
+define("MANAGE_HTTP_TIMEOUT_LONG", 300);
+
+// ---------------------------------------------------------------------------
+// Application layout
+// ---------------------------------------------------------------------------
+// Root of the host application. Defaults to the parent of this folder, which is
+// correct when manage-client/ sits directly in the application root.
+define("MANAGE_APP_ROOT", dirname(__DIR__));
+
+// Where the installed version is stored.
+//   - PHP file with a constant: set both values
+//   - plain text file containing only "v1.2.3": set MANAGE_VERSION_CONSTANT null
+define("MANAGE_VERSION_FILE", MANAGE_APP_ROOT . "/includes/version.php");
+define("MANAGE_VERSION_CONSTANT", "APP_VERSION");
+
+// Working directories. Must be writable by PHP and should not be web-readable.
+define("MANAGE_WORK_DIR", MANAGE_APP_ROOT . "/data/manage/work/");
+define("MANAGE_UPDATE_BACKUP_DIR", MANAGE_APP_ROOT . "/data/manage/updates/");
+define("MANAGE_BACKUP_DIR", MANAGE_APP_ROOT . "/data/manage/backups/");
+define("MANAGE_LOG_FILE", MANAGE_APP_ROOT . "/data/manage/manage-client.log");
+
+// ---------------------------------------------------------------------------
+// Update
+// ---------------------------------------------------------------------------
+// Paths the updater must never overwrite, relative to MANAGE_APP_ROOT.
+// A trailing slash marks a directory; everything below it is protected.
+define("MANAGE_UPDATE_PROTECTED_PATHS", [
+    "config.php",
+    "data/",
+    ".git/",
+    "manage-client/config.php",
+]);
+
+// A package must contain at least one of these paths, otherwise it is rejected
+// before anything is copied. Guards against deploying an unrelated ZIP.
+define("MANAGE_UPDATE_SANITY_PATHS", ["index.php"]);
+
+// Optional callback executed after a successful deployment.
+// See docs/07_POST_UPDATE_HOOKS.md.
+define("MANAGE_UPDATE_POST_HOOK", null);
+// define("MANAGE_UPDATE_POST_HOOK", [
+//     "file" => MANAGE_APP_ROOT . "/includes/after-update.php",
+//     "callback" => "myProjectAfterUpdate",
+// ]);
+
+// Migrations shipped inside the release package. Set to null to disable.
+define("MANAGE_MIGRATIONS_DIR", MANAGE_APP_ROOT . "/migrations");
+define("MANAGE_MIGRATIONS_STATE", MANAGE_APP_ROOT . "/data/manage/migrations.json");
+
+// ---------------------------------------------------------------------------
+// Backup
+// ---------------------------------------------------------------------------
+// What goes into the archive. Paths are relative to MANAGE_APP_ROOT.
+//   "glob" => shell glob, non-recursive
+//   "dir"  => directory, recursive
+//   "file" => single file
+//   "as"   => path prefix inside the ZIP
+define("MANAGE_BACKUP_SOURCES", [
+    ["as" => "data", "glob" => "data/*.json"],
+    ["as" => "data/uploads", "dir" => "data/uploads"],
+]);
+
+// Optional MySQL/MariaDB dump. null disables it entirely.
+define("MANAGE_BACKUP_DATABASE", null);
+// define("MANAGE_BACKUP_DATABASE", [
+//     "dsn" => "mysql:host=localhost;dbname=myproject;charset=utf8mb4",
+//     "user" => "myproject",
+//     "password" => "...",
+//     // Optional: only dump the structure of these tables, not their rows.
+//     "skip_data_tables" => ["sessions", "cache"],
+// ]);
+
+// Local archives kept on the instance. Minimum 1.
+define("MANAGE_BACKUP_LOCAL_RETENTION", 4);
+
+// Interval for manageBackupCreateAutomaticIfDue(), in seconds. 0 disables it.
+// Default is weekly. Irrelevant when a cron job runs the CLI instead.
+define("MANAGE_BACKUP_AUTO_INTERVAL_SECONDS", 604800);
+
+// Compress entries in the ZIP. Deflate needs zlib (bundled with PHP by default)
+// and matters mostly for SQL dumps; false stores everything uncompressed.
+define("MANAGE_BACKUP_COMPRESS", true);
+
+// Upload every new backup to the manage server. Set false for local-only backups.
+define("MANAGE_BACKUP_UPLOAD", true);
+
+// Additional targets besides the manage server: s3, sftp, custom.
+// See docs/05_BACKUP_SOURCES.md.
+define("MANAGE_BACKUP_REMOTE_TARGETS", []);

+ 518 - 0
manage-client/lib/backup.php

@@ -0,0 +1,518 @@
+<?php
+
+declare(strict_types=1);
+
+// Client-side backup: collecting sources, writing the archive, local retention
+// and uploading to the manage server.
+//
+// The source list is not hardcoded: it comes from MANAGE_BACKUP_SOURCES, plus
+// an optional SQL dump when MANAGE_BACKUP_DATABASE is configured.
+
+function manageBackupDir(): string
+{
+    return rtrim((string) MANAGE_BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR;
+}
+
+function manageBackupIndexFile(): string
+{
+    return manageBackupDir() . "backup-index.json";
+}
+
+function manageBackupLockFile(): string
+{
+    return manageBackupDir() . ".backup.lock";
+}
+
+// ---------------------------------------------------------------------------
+// Source collection
+// ---------------------------------------------------------------------------
+
+// Recursively lists readable files below $dir, mapped to $entryPrefix.
+function manageBackupCollectDirectory(string $dir, string $entryPrefix, array &$files): void
+{
+    if (!is_dir($dir)) {
+        return;
+    }
+
+    $base = rtrim($dir, "/\\") . DIRECTORY_SEPARATOR;
+    $items = new RecursiveIteratorIterator(
+        new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
+        RecursiveIteratorIterator::LEAVES_ONLY,
+    );
+
+    foreach ($items as $item) {
+        if (!$item->isFile() || !$item->isReadable()) {
+            continue;
+        }
+
+        $path = $item->getPathname();
+        if (manageIsTemporaryFile($path)) {
+            continue;
+        }
+
+        $relative = ltrim(manageClientNormalizePath(substr($path, strlen($base))), "/");
+        if ($relative === "" || str_contains($relative, "\0")) {
+            continue;
+        }
+
+        $files[] = [
+            "path" => $path,
+            "name" => trim($entryPrefix . "/" . $relative, "/"),
+        ];
+    }
+}
+
+/**
+ * Resolves MANAGE_BACKUP_SOURCES into a flat list of archive entries.
+ *
+ * Each source entry supports one of:
+ *   "glob" => "data/*.json"      non-recursive shell glob
+ *   "dir"  => "data/uploads"     recursive directory
+ *   "file" => "settings.ini"     single file
+ * plus an optional "as" prefix for the path inside the archive.
+ */
+function manageBackupCollectSources(): array
+{
+    $root = manageClientAppRoot();
+    $sources = is_array(MANAGE_BACKUP_SOURCES) ? MANAGE_BACKUP_SOURCES : [];
+    $files = [];
+
+    foreach ($sources as $source) {
+        if (!is_array($source)) {
+            continue;
+        }
+
+        $prefix = trim((string) ($source["as"] ?? ""), "/");
+
+        if (isset($source["glob"])) {
+            $pattern = $root . DIRECTORY_SEPARATOR . ltrim((string) $source["glob"], "/\\");
+            foreach (glob($pattern) ?: [] as $path) {
+                if (!is_file($path) || !is_readable($path) || manageIsTemporaryFile($path)) {
+                    continue;
+                }
+                $files[] = [
+                    "path" => $path,
+                    "name" => trim($prefix . "/" . basename($path), "/"),
+                ];
+            }
+            continue;
+        }
+
+        if (isset($source["dir"])) {
+            $dir = $root . DIRECTORY_SEPARATOR . ltrim((string) $source["dir"], "/\\");
+            manageBackupCollectDirectory($dir, $prefix !== "" ? $prefix : basename($dir), $files);
+            continue;
+        }
+
+        if (isset($source["file"])) {
+            $path = $root . DIRECTORY_SEPARATOR . ltrim((string) $source["file"], "/\\");
+            if (is_file($path) && is_readable($path)) {
+                $files[] = [
+                    "path" => $path,
+                    "name" => trim($prefix . "/" . basename($path), "/"),
+                ];
+            }
+        }
+    }
+
+    // Two sources may resolve to the same archive entry; the first one wins so
+    // the ZIP can never contain a duplicate name.
+    $unique = [];
+    foreach ($files as $file) {
+        $unique[$file["name"]] = $file;
+    }
+    $files = array_values($unique);
+
+    usort($files, static function (array $left, array $right): int {
+        return strcmp($left["name"], $right["name"]);
+    });
+
+    return $files;
+}
+
+// ---------------------------------------------------------------------------
+// Index
+// ---------------------------------------------------------------------------
+
+function manageBackupReadIndex(): array
+{
+    $index = manageReadJson(manageBackupIndexFile());
+    $records = isset($index["backups"]) && is_array($index["backups"])
+        ? $index["backups"]
+        : [];
+
+    return ["backups" => array_values($records)];
+}
+
+function manageBackupWriteIndex(array $records): void
+{
+    manageWriteJson(manageBackupIndexFile(), ["backups" => array_values($records)]);
+}
+
+/**
+ * Local backups, newest first. Self-healing: index records whose file is gone
+ * are dropped and sizes are refreshed from disk.
+ */
+function manageBackupList(): array
+{
+    $dir = manageBackupDir();
+    $existing = [];
+
+    foreach (manageBackupReadIndex()["backups"] as $record) {
+        if (!is_array($record)) {
+            continue;
+        }
+
+        $filename = basename((string) ($record["filename"] ?? ""));
+        if ($filename === "" || !is_file($dir . $filename)) {
+            continue;
+        }
+
+        $record["filename"] = $filename;
+        $record["size"] = (int) (filesize($dir . $filename) ?: ($record["size"] ?? 0));
+        $existing[] = $record;
+    }
+
+    usort($existing, static function (array $left, array $right): int {
+        return strcmp((string) ($right["created_at"] ?? ""), (string) ($left["created_at"] ?? ""));
+    });
+
+    return $existing;
+}
+
+function manageBackupRetentionLimit(): int
+{
+    return max(1, (int) MANAGE_BACKUP_LOCAL_RETENTION);
+}
+
+function manageBackupApplyRetention(): void
+{
+    $records = manageBackupList();
+    $keep = manageBackupRetentionLimit();
+    $dir = manageBackupDir();
+
+    foreach (array_slice($records, $keep) as $record) {
+        $filename = basename((string) ($record["filename"] ?? ""));
+        if ($filename !== "" && is_file($dir . $filename)) {
+            @unlink($dir . $filename);
+        }
+    }
+
+    manageBackupWriteIndex(array_slice(manageBackupList(), 0, $keep));
+}
+
+function manageBackupPath(string $filename): string
+{
+    $filename = basename($filename);
+    if (preg_match('/^backup-\d{8}-\d{6}(?:-\d+)?\.zip$/', $filename) !== 1) {
+        throw new RuntimeException("Ungültiger Backup-Dateiname: " . $filename);
+    }
+
+    $path = manageBackupDir() . $filename;
+    if (!is_file($path)) {
+        throw new RuntimeException("Backup wurde nicht gefunden: " . $filename);
+    }
+
+    return $path;
+}
+
+// ---------------------------------------------------------------------------
+// Upload to the manage server
+// ---------------------------------------------------------------------------
+
+function manageBackupBuildMultipartBody(
+    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 den 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;
+}
+
+/**
+ * Uploads one archive to the manage server.
+ *
+ * @param array $meta trigger, file_count, source_bytes, sha256, app_version
+ */
+function manageBackupUpload(string $archivePath, array $meta = []): array
+{
+    manageClientRequireConfigured();
+
+    if (!is_file($archivePath)) {
+        throw new RuntimeException("Backup-Datei existiert nicht: " . $archivePath);
+    }
+
+    $filename = basename($archivePath);
+    $sha256 = strtolower(trim((string) ($meta["sha256"] ?? "")));
+    if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) {
+        $sha256 = strtolower(hash_file("sha256", $archivePath) ?: "");
+    }
+    if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) {
+        throw new RuntimeException("Prüfsumme des Backups konnte nicht berechnet werden.");
+    }
+
+    $metaPayload = json_encode([
+        "trigger" => (string) ($meta["trigger"] ?? "manual"),
+        "file_count" => (int) ($meta["file_count"] ?? 0),
+        "source_bytes" => (int) ($meta["source_bytes"] ?? 0),
+        "app_version" => manageClientVersion(),
+    ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+
+    $boundary = "----manage-client-" . bin2hex(random_bytes(12));
+    $body = manageBackupBuildMultipartBody(
+        [
+            "filename" => $filename,
+            "sha256" => $sha256,
+            "meta" => $metaPayload === false ? "{}" : $metaPayload,
+        ],
+        "backup",
+        $archivePath,
+        $filename,
+        $boundary,
+    );
+
+    $response = manageClientRequest(
+        "POST",
+        "backup.php",
+        $body,
+        "multipart/form-data; boundary=" . $boundary,
+        (int) MANAGE_HTTP_TIMEOUT_LONG,
+    );
+
+    if ($response["status"] < 200 || $response["status"] >= 300) {
+        throw new ManageRemoteUploadException(
+            manageClientErrorMessage($response["status"], $response["body"]),
+            [
+                "http_status" => $response["status"],
+                "response_excerpt" => manageRemoteResponseExcerpt($response["body"]),
+                "filename" => $filename,
+            ],
+        );
+    }
+
+    $decoded = json_decode($response["body"], true);
+    if (!is_array($decoded) || empty($decoded["success"])) {
+        $error = is_array($decoded) ? trim((string) ($decoded["error"] ?? "")) : "";
+        throw new ManageRemoteUploadException(
+            "Der Manage-Server hat das Backup abgelehnt" . ($error !== "" ? ": " . $error : "."),
+            [
+                "http_status" => $response["status"],
+                "response_excerpt" => manageRemoteResponseExcerpt($response["body"]),
+                "filename" => $filename,
+            ],
+        );
+    }
+
+    return [
+        "target" => "Manage-Server",
+        "type" => "manage",
+        "success" => true,
+        "uploaded_at" => date(DATE_ATOM),
+        "server_filename" => (string) ($decoded["filename"] ?? ""),
+        "remote_path" => manageClientEndpoint("backup.php"),
+    ];
+}
+
+// ---------------------------------------------------------------------------
+// Creating a backup
+// ---------------------------------------------------------------------------
+
+/**
+ * Creates a local archive and, unless disabled, uploads it.
+ *
+ * A failed upload never invalidates the local archive: the error is stored in
+ * the index record and logged, exactly like the extra remote targets.
+ *
+ * @param string $trigger manual | automatic | cron | update
+ */
+function manageBackupCreate(string $trigger = "manual"): array
+{
+    $dir = manageBackupDir();
+    manageEnsureDir($dir);
+
+    $lockHandle = fopen(manageBackupLockFile(), "c+");
+    if ($lockHandle === false) {
+        throw new RuntimeException("Backup-Sperrdatei konnte nicht geöffnet werden.");
+    }
+
+    if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
+        fclose($lockHandle);
+        throw new RuntimeException("Es läuft bereits ein Backup.");
+    }
+
+    $dumpFile = null;
+
+    try {
+        $baseName = "backup-" . date("Ymd-His");
+        $filename = $baseName . ".zip";
+        $counter = 2;
+        while (file_exists($dir . $filename)) {
+            $filename = $baseName . "-" . $counter . ".zip";
+            $counter++;
+        }
+
+        $tmpFile = $dir . "." . $filename . ".tmp";
+        $archivePath = $dir . $filename;
+        $createdAt = date(DATE_ATOM);
+
+        $files = manageBackupCollectSources();
+        $database = null;
+
+        if (manageDatabaseConfigured()) {
+            $dumpFile = rtrim((string) MANAGE_WORK_DIR, "/\\") . DIRECTORY_SEPARATOR .
+                "dump-" . date("Ymd-His") . "-" . bin2hex(random_bytes(4)) . ".sql";
+            $database = manageDatabaseDump($dumpFile);
+            $files[] = [
+                "path" => $dumpFile,
+                "name" => "database/" . $database["database"] . ".sql",
+            ];
+        }
+
+        $zipStats = manageZipWrite($tmpFile, $files);
+
+        if (!rename($tmpFile, $archivePath)) {
+            @unlink($tmpFile);
+            throw new RuntimeException("Backup-ZIP konnte nicht finalisiert werden.");
+        }
+        @chmod($archivePath, 0660);
+
+        $metadata = [
+            "filename" => $filename,
+            "created_at" => $createdAt,
+            "trigger" => $trigger,
+            "sha256" => $zipStats["sha256"],
+            "file_count" => $zipStats["file_count"],
+            "source_bytes" => $zipStats["source_bytes"],
+        ];
+
+        $uploads = [];
+        if (MANAGE_BACKUP_UPLOAD === true && manageClientConfigured()) {
+            try {
+                $uploads[] = manageBackupUpload($archivePath, $metadata);
+            } catch (Throwable $exception) {
+                $debugContext = $exception instanceof ManageRemoteUploadException
+                    ? $exception->getDebugContext()
+                    : [];
+                $uploads[] = [
+                    "target" => "Manage-Server",
+                    "type" => "manage",
+                    "success" => false,
+                    "error" => $exception->getMessage(),
+                    "debug" => $debugContext,
+                ];
+                manageClientLog("ERROR", "Backup upload to manage server failed", [
+                    "filename" => $filename,
+                    "error" => $exception->getMessage(),
+                    "debug" => $debugContext,
+                ]);
+            }
+        }
+
+        $uploads = array_merge($uploads, manageRemoteUploadAll($archivePath, $metadata));
+
+        $record = [
+            "filename" => $filename,
+            "created_at" => $createdAt,
+            "trigger" => $trigger,
+            "size" => (int) (filesize($archivePath) ?: $zipStats["archive_bytes"]),
+            "file_count" => $zipStats["file_count"],
+            "source_bytes" => $zipStats["source_bytes"],
+            "sha256" => $zipStats["sha256"],
+            "app_version" => manageClientVersion(),
+            "database" => $database,
+            "remote_uploads" => $uploads,
+        ];
+
+        $records = manageBackupList();
+        array_unshift($records, $record);
+        manageBackupWriteIndex($records);
+        manageBackupApplyRetention();
+
+        manageClientLog("INFO", "Backup created", [
+            "filename" => $filename,
+            "trigger" => $trigger,
+            "file_count" => $record["file_count"],
+            "size" => $record["size"],
+        ]);
+
+        return $record;
+    } catch (Throwable $exception) {
+        manageClientLog("ERROR", "Backup failed", [
+            "trigger" => $trigger,
+            "error" => $exception->getMessage(),
+        ]);
+        throw $exception;
+    } finally {
+        if ($dumpFile !== null && is_file($dumpFile)) {
+            @unlink($dumpFile);
+        }
+        flock($lockHandle, LOCK_UN);
+        fclose($lockHandle);
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Automatic scheduling for hosts without cron
+// ---------------------------------------------------------------------------
+
+function manageBackupLastAutomaticAt(): int
+{
+    foreach (manageBackupList() as $record) {
+        $trigger = (string) ($record["trigger"] ?? "");
+        if ($trigger !== "automatic" && $trigger !== "cron") {
+            continue;
+        }
+
+        $timestamp = strtotime((string) ($record["created_at"] ?? ""));
+        if ($timestamp !== false) {
+            return $timestamp;
+        }
+    }
+
+    return 0;
+}
+
+function manageBackupIsAutomaticDue(): bool
+{
+    $interval = (int) MANAGE_BACKUP_AUTO_INTERVAL_SECONDS;
+    if ($interval < 1) {
+        return false;
+    }
+
+    return time() - manageBackupLastAutomaticAt() >= $interval;
+}
+
+/**
+ * Creates an automatic backup when the interval has elapsed. Meant to be called
+ * from an admin page the host application loads regularly. Returns null when
+ * nothing was due.
+ */
+function manageBackupCreateAutomaticIfDue(): ?array
+{
+    if (!manageBackupIsAutomaticDue()) {
+        return null;
+    }
+
+    return manageBackupCreate("automatic");
+}

+ 545 - 0
manage-client/lib/client.php

@@ -0,0 +1,545 @@
+<?php
+
+declare(strict_types=1);
+
+// Manage client core: configuration defaults, filesystem helpers, the
+// authenticated HTTP transport and version file handling.
+//
+// This is the only file a host project needs to require. It pulls in the rest
+// of the library, so both the CLI and the GUI panel start here:
+//
+//     require_once __DIR__ . "/manage-client/lib/client.php";
+//     $status = manageClientStatus();
+
+$manageClientConfigFile = dirname(__DIR__) . "/config.php";
+if (is_file($manageClientConfigFile)) {
+    require_once $manageClientConfigFile;
+}
+
+if (!defined("MANAGE_SERVER_URL")) {
+    define("MANAGE_SERVER_URL", "");
+}
+if (!defined("MANAGE_INSTANCE")) {
+    define("MANAGE_INSTANCE", "");
+}
+if (!defined("MANAGE_TOKEN")) {
+    define("MANAGE_TOKEN", "");
+}
+if (!defined("MANAGE_HTTP_TIMEOUT")) {
+    define("MANAGE_HTTP_TIMEOUT", 15);
+}
+if (!defined("MANAGE_HTTP_TIMEOUT_LONG")) {
+    define("MANAGE_HTTP_TIMEOUT_LONG", 300);
+}
+if (!defined("MANAGE_APP_ROOT")) {
+    define("MANAGE_APP_ROOT", dirname(__DIR__, 2));
+}
+if (!defined("MANAGE_VERSION_FILE")) {
+    define("MANAGE_VERSION_FILE", MANAGE_APP_ROOT . "/VERSION");
+}
+if (!defined("MANAGE_VERSION_CONSTANT")) {
+    define("MANAGE_VERSION_CONSTANT", null);
+}
+if (!defined("MANAGE_WORK_DIR")) {
+    define("MANAGE_WORK_DIR", MANAGE_APP_ROOT . "/data/manage/work/");
+}
+if (!defined("MANAGE_UPDATE_BACKUP_DIR")) {
+    define("MANAGE_UPDATE_BACKUP_DIR", MANAGE_APP_ROOT . "/data/manage/updates/");
+}
+if (!defined("MANAGE_BACKUP_DIR")) {
+    define("MANAGE_BACKUP_DIR", MANAGE_APP_ROOT . "/data/manage/backups/");
+}
+if (!defined("MANAGE_LOG_FILE")) {
+    define("MANAGE_LOG_FILE", MANAGE_APP_ROOT . "/data/manage/manage-client.log");
+}
+if (!defined("MANAGE_UPDATE_PROTECTED_PATHS")) {
+    define("MANAGE_UPDATE_PROTECTED_PATHS", ["config.php", "data/", ".git/", "manage-client/config.php"]);
+}
+if (!defined("MANAGE_UPDATE_SANITY_PATHS")) {
+    define("MANAGE_UPDATE_SANITY_PATHS", ["index.php"]);
+}
+if (!defined("MANAGE_UPDATE_POST_HOOK")) {
+    define("MANAGE_UPDATE_POST_HOOK", null);
+}
+if (!defined("MANAGE_MIGRATIONS_DIR")) {
+    define("MANAGE_MIGRATIONS_DIR", MANAGE_APP_ROOT . "/migrations");
+}
+if (!defined("MANAGE_MIGRATIONS_STATE")) {
+    define("MANAGE_MIGRATIONS_STATE", MANAGE_APP_ROOT . "/data/manage/migrations.json");
+}
+if (!defined("MANAGE_BACKUP_SOURCES")) {
+    define("MANAGE_BACKUP_SOURCES", [["as" => "data", "glob" => "data/*.json"]]);
+}
+if (!defined("MANAGE_BACKUP_DATABASE")) {
+    define("MANAGE_BACKUP_DATABASE", null);
+}
+if (!defined("MANAGE_BACKUP_LOCAL_RETENTION")) {
+    define("MANAGE_BACKUP_LOCAL_RETENTION", 4);
+}
+if (!defined("MANAGE_BACKUP_AUTO_INTERVAL_SECONDS")) {
+    define("MANAGE_BACKUP_AUTO_INTERVAL_SECONDS", 604800);
+}
+if (!defined("MANAGE_BACKUP_COMPRESS")) {
+    define("MANAGE_BACKUP_COMPRESS", true);
+}
+if (!defined("MANAGE_BACKUP_UPLOAD")) {
+    define("MANAGE_BACKUP_UPLOAD", true);
+}
+if (!defined("MANAGE_BACKUP_REMOTE_TARGETS")) {
+    define("MANAGE_BACKUP_REMOTE_TARGETS", []);
+}
+
+// Raised when a remote upload fails. Carries a scrubbed debug context that is
+// safe to log: credentials are never part of it.
+class ManageRemoteUploadException extends RuntimeException
+{
+    private array $debugContext;
+
+    public function __construct(string $message, array $debugContext = [])
+    {
+        parent::__construct($message);
+        $this->debugContext = $debugContext;
+    }
+
+    public function getDebugContext(): array
+    {
+        return $this->debugContext;
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Paths
+// ---------------------------------------------------------------------------
+
+function manageClientAppRoot(): string
+{
+    $root = realpath((string) MANAGE_APP_ROOT);
+    if ($root === false) {
+        throw new RuntimeException("MANAGE_APP_ROOT existiert nicht: " . MANAGE_APP_ROOT);
+    }
+
+    return rtrim($root, "/\\");
+}
+
+function manageClientPath(string $relative): string
+{
+    return manageClientAppRoot() . DIRECTORY_SEPARATOR . ltrim($relative, "/\\");
+}
+
+function manageClientNormalizePath(string $path): string
+{
+    return str_replace("\\", "/", $path);
+}
+
+function manageEnsureDir(string $dir): void
+{
+    if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
+        throw new RuntimeException("Verzeichnis konnte nicht erstellt werden: " . $dir);
+    }
+
+    @chmod($dir, 02775);
+}
+
+function manageRemoveDir(string $dir): void
+{
+    if (!is_dir($dir)) {
+        return;
+    }
+
+    $items = new RecursiveIteratorIterator(
+        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
+        RecursiveIteratorIterator::CHILD_FIRST,
+    );
+
+    foreach ($items as $item) {
+        if ($item->isDir()) {
+            @rmdir($item->getPathname());
+        } else {
+            @unlink($item->getPathname());
+        }
+    }
+
+    @rmdir($dir);
+}
+
+function manageIsTemporaryFile(string $path): bool
+{
+    $name = basename($path);
+
+    return $name === "" ||
+        $name[0] === "." ||
+        str_ends_with($name, ".tmp") ||
+        str_ends_with($name, ".part");
+}
+
+function manageFormatBytes(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";
+}
+
+// ---------------------------------------------------------------------------
+// JSON state files
+// ---------------------------------------------------------------------------
+
+function manageReadJson(string $file): array
+{
+    if (!is_file($file)) {
+        return [];
+    }
+
+    $content = file_get_contents($file);
+    if ($content === false || trim($content) === "") {
+        return [];
+    }
+
+    $decoded = json_decode($content, true);
+
+    return is_array($decoded) ? $decoded : [];
+}
+
+function manageWriteJson(string $file, array $data): void
+{
+    manageEnsureDir(dirname($file));
+
+    $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+    if ($json === false) {
+        throw new RuntimeException("JSON konnte nicht kodiert werden: " . basename($file));
+    }
+
+    $tmpFile = $file . ".tmp";
+    if (file_put_contents($tmpFile, $json . PHP_EOL, LOCK_EX) === false) {
+        throw new RuntimeException("Datei konnte nicht geschrieben werden: " . basename($file));
+    }
+
+    @chmod($tmpFile, 0664);
+    if (!rename($tmpFile, $file)) {
+        @unlink($tmpFile);
+        throw new RuntimeException("Datei konnte nicht gespeichert werden: " . basename($file));
+    }
+
+    @chmod($file, 0664);
+}
+
+// ---------------------------------------------------------------------------
+// Logging
+// ---------------------------------------------------------------------------
+
+// Appends one JSON line. Never throws: a failed log write must not abort an
+// update or a backup.
+function manageClientLog(string $level, string $message, array $context = []): void
+{
+    $file = (string) MANAGE_LOG_FILE;
+    if ($file === "") {
+        return;
+    }
+
+    try {
+        manageEnsureDir(dirname($file));
+    } catch (Throwable $exception) {
+        return;
+    }
+
+    // Simple size cap; the host application owns its own log rotation.
+    if (is_file($file) && (int) (filesize($file) ?: 0) > 2097152) {
+        @rename($file, $file . ".1");
+    }
+
+    $line = json_encode([
+        "timestamp" => date("Y-m-d H:i:s"),
+        "level" => $level,
+        "message" => $message,
+        "context" => $context,
+    ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+    if (is_string($line)) {
+        @file_put_contents($file, $line . PHP_EOL, FILE_APPEND | LOCK_EX);
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Version file
+// ---------------------------------------------------------------------------
+
+function manageIsVersionString(string $version): bool
+{
+    return preg_match('/^v\d+\.\d+\.\d+$/', trim($version)) === 1;
+}
+
+function manageVersionCompareValue(string $version): string
+{
+    return ltrim(trim($version), "vV");
+}
+
+/**
+ * Reads the installed version. Supports both supported layouts:
+ *   - a PHP file defining a constant (MANAGE_VERSION_CONSTANT)
+ *   - a plain text file containing only the version
+ *
+ * Returns "" when the version cannot be determined, which the callers treat as
+ * "unknown" rather than as an error.
+ */
+function manageClientVersion(): string
+{
+    $file = (string) MANAGE_VERSION_FILE;
+    if ($file === "" || !is_file($file)) {
+        return "";
+    }
+
+    $constant = MANAGE_VERSION_CONSTANT;
+    if (is_string($constant) && $constant !== "") {
+        // The constant may already be defined by the host application.
+        if (defined($constant)) {
+            $value = (string) constant($constant);
+            if (manageIsVersionString($value)) {
+                return trim($value);
+            }
+        }
+
+        // Otherwise parse it out of the file without executing it: the file may
+        // have side effects, and including it twice would fatal on redefinition.
+        $content = (string) file_get_contents($file);
+        $pattern = '/define\s*\(\s*["\']' . preg_quote($constant, "/") . '["\']\s*,\s*["\'](v?\d+\.\d+\.\d+)["\']/';
+        if (preg_match($pattern, $content, $matches) === 1) {
+            return trim($matches[1]);
+        }
+
+        return "";
+    }
+
+    $value = trim((string) file_get_contents($file));
+
+    return manageIsVersionString($value) ? $value : "";
+}
+
+// ---------------------------------------------------------------------------
+// HTTP transport
+// ---------------------------------------------------------------------------
+
+function manageClientConfigured(): bool
+{
+    return trim((string) MANAGE_SERVER_URL) !== "" &&
+        trim((string) MANAGE_INSTANCE) !== "" &&
+        trim((string) MANAGE_TOKEN) !== "";
+}
+
+function manageClientRequireConfigured(): void
+{
+    if (manageClientConfigured()) {
+        return;
+    }
+
+    throw new RuntimeException(
+        "Manage-Client ist nicht konfiguriert. MANAGE_SERVER_URL, MANAGE_INSTANCE und MANAGE_TOKEN " .
+        "müssen in manage-client/config.php gesetzt sein.",
+    );
+}
+
+function manageClientEndpoint(string $path): string
+{
+    $base = rtrim(trim((string) MANAGE_SERVER_URL), "/");
+
+    return $base . "/api/v1/" . ltrim($path, "/");
+}
+
+function manageClientUserAgent(): string
+{
+    $version = manageClientVersion();
+
+    return "Manage-Client/1.0 (" . (string) MANAGE_INSTANCE . "; app " . ($version !== "" ? $version : "unknown") . ")";
+}
+
+function manageClientAuthHeaders(): string
+{
+    return "X-Manage-Instance: " . (string) MANAGE_INSTANCE . "\r\n" .
+        "X-Manage-Token: " . (string) MANAGE_TOKEN . "\r\n" .
+        "User-Agent: " . manageClientUserAgent() . "\r\n";
+}
+
+function manageClientStatusFromHeaders(array $headers): int
+{
+    $status = 0;
+    foreach ($headers as $header) {
+        if (preg_match('/^HTTP\/\S+\s+(\d+)/', (string) $header, $matches) === 1) {
+            $status = (int) $matches[1];
+        }
+    }
+
+    return $status;
+}
+
+function manageClientResponseHeaders($legacyHeaders): array
+{
+    if (function_exists("http_get_last_response_headers")) {
+        $lastHeaders = http_get_last_response_headers();
+        return is_array($lastHeaders) ? $lastHeaders : [];
+    }
+
+    return is_array($legacyHeaders) ? $legacyHeaders : [];
+}
+
+// Turns a server error response into a message worth reading. The API always
+// answers with {"success":false,"error":"..."}; anything else is truncated.
+function manageClientErrorMessage(int $status, $body): string
+{
+    $suffix = $status > 0 ? " (HTTP " . $status . ")" : "";
+
+    if (is_string($body) && $body !== "") {
+        $decoded = json_decode($body, true);
+        if (is_array($decoded) && isset($decoded["error"])) {
+            return trim((string) $decoded["error"]) . $suffix;
+        }
+
+        $excerpt = substr(trim(preg_replace('/\s+/', " ", $body) ?? ""), 0, 300);
+        if ($excerpt !== "") {
+            return $excerpt . $suffix;
+        }
+    }
+
+    return "Anfrage fehlgeschlagen" . ($suffix !== "" ? $suffix : " (keine Antwort vom Server)") . ".";
+}
+
+/**
+ * Performs an authenticated request against the manage server.
+ *
+ * @param string      $method  GET or POST
+ * @param string      $path    endpoint below api/v1/
+ * @param string|null $body    raw request body for POST
+ * @param string      $contentType
+ * @param int|null    $timeout seconds; defaults to MANAGE_HTTP_TIMEOUT
+ *
+ * @return array{status: int, body: string}
+ */
+function manageClientRequest(
+    string $method,
+    string $path,
+    ?string $body = null,
+    string $contentType = "application/json",
+    ?int $timeout = null,
+): array {
+    manageClientRequireConfigured();
+
+    $url = manageClientEndpoint($path);
+    if (!filter_var($url, FILTER_VALIDATE_URL)) {
+        throw new RuntimeException("Ungültige Server-URL: " . $url);
+    }
+
+    $headers = manageClientAuthHeaders() . "Accept: application/json\r\n";
+    $options = [
+        "method" => $method,
+        "timeout" => $timeout ?? (int) MANAGE_HTTP_TIMEOUT,
+        "ignore_errors" => true,
+        "follow_location" => 0,
+        "protocol_version" => 1.1,
+    ];
+
+    if ($body !== null) {
+        $headers .= "Content-Type: " . $contentType . "\r\n";
+        $headers .= "Content-Length: " . strlen($body) . "\r\n";
+        $options["content"] = $body;
+    }
+
+    $options["header"] = $headers;
+    $context = stream_context_create(["http" => $options]);
+
+    $response = @file_get_contents($url, false, $context);
+    $status = manageClientStatusFromHeaders(manageClientResponseHeaders($http_response_header ?? null));
+
+    if ($response === false && $status === 0) {
+        throw new RuntimeException("Manage-Server ist nicht erreichbar: " . $url);
+    }
+
+    return ["status" => $status, "body" => is_string($response) ? $response : ""];
+}
+
+/**
+ * Authenticated request that expects a JSON object and a 2xx status.
+ */
+function manageClientRequestJson(
+    string $method,
+    string $path,
+    ?array $payload = null,
+    ?int $timeout = null,
+): array {
+    $body = $payload === null ? null : json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+    if ($payload !== null && $body === false) {
+        throw new RuntimeException("Anfrage konnte nicht kodiert werden.");
+    }
+
+    $response = manageClientRequest($method, $path, $body, "application/json", $timeout);
+
+    if ($response["status"] < 200 || $response["status"] >= 300) {
+        throw new RuntimeException(manageClientErrorMessage($response["status"], $response["body"]));
+    }
+
+    $decoded = json_decode($response["body"], true);
+    if (!is_array($decoded)) {
+        throw new RuntimeException("Antwort des Servers ist kein gültiges JSON.");
+    }
+
+    return $decoded;
+}
+
+require_once __DIR__ . "/zip.php";
+require_once __DIR__ . "/mysql.php";
+require_once __DIR__ . "/remote.php";
+require_once __DIR__ . "/backup.php";
+require_once __DIR__ . "/hooks.php";
+require_once __DIR__ . "/updater.php";
+require_once __DIR__ . "/heartbeat.php";
+
+/**
+ * Aggregate status used by the CLI, the GUI panel and any host integration.
+ * Never throws: every remote failure is reported inside the returned array, so
+ * a settings page can render even when the server is unreachable.
+ */
+function manageClientStatus(): array
+{
+    $status = [
+        "instance" => (string) MANAGE_INSTANCE,
+        "server_url" => (string) MANAGE_SERVER_URL,
+        "configured" => manageClientConfigured(),
+        "version" => manageClientVersion(),
+        "php_version" => PHP_VERSION,
+        "update" => null,
+        "update_error" => null,
+        "backups" => [],
+        "last_backup_at" => null,
+        "pending_migrations" => [],
+        "errors" => [],
+    ];
+
+    try {
+        $status["backups"] = manageBackupList();
+        $status["last_backup_at"] = $status["backups"] === []
+            ? null
+            : (string) ($status["backups"][0]["created_at"] ?? "");
+    } catch (Throwable $exception) {
+        $status["errors"][] = $exception->getMessage();
+    }
+
+    try {
+        $status["pending_migrations"] = manageUpdatePendingMigrations();
+    } catch (Throwable $exception) {
+        $status["errors"][] = $exception->getMessage();
+    }
+
+    if ($status["configured"]) {
+        try {
+            $status["update"] = manageUpdateCheck();
+        } catch (Throwable $exception) {
+            $status["update_error"] = $exception->getMessage();
+        }
+    }
+
+    return $status;
+}

+ 94 - 0
manage-client/lib/heartbeat.php

@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+// Status report to the manage server.
+//
+// Sent from cron (manage-client.php heartbeat) and after every update or
+// backup, so the server dashboard shows the installed version, the last backup
+// and any pending migrations without polling the instance.
+//
+// The response doubles as a cheap update check: one request is enough for a
+// monitoring job to learn that an instance is behind.
+
+function manageHeartbeatPayload(): array
+{
+    $lastBackupAt = "";
+    $pendingMigrations = 0;
+
+    try {
+        $backups = manageBackupList();
+        if ($backups !== []) {
+            $lastBackupAt = (string) ($backups[0]["created_at"] ?? "");
+        }
+    } catch (Throwable $exception) {
+        // A broken backup index must not stop the heartbeat; the server simply
+        // keeps the previous value.
+        manageClientLog("WARNING", "Heartbeat could not read backup index", [
+            "error" => $exception->getMessage(),
+        ]);
+    }
+
+    try {
+        $pendingMigrations = count(manageUpdatePendingMigrations());
+    } catch (Throwable $exception) {
+        manageClientLog("WARNING", "Heartbeat could not read migrations", [
+            "error" => $exception->getMessage(),
+        ]);
+    }
+
+    $diskFree = 0;
+    try {
+        $free = @disk_free_space(manageClientAppRoot());
+        if (is_float($free) || is_int($free)) {
+            $diskFree = (int) $free;
+        }
+    } catch (Throwable $exception) {
+        $diskFree = 0;
+    }
+
+    return [
+        "version" => manageClientVersion(),
+        "php_version" => PHP_VERSION,
+        "disk_free" => $diskFree,
+        "pending_migrations" => $pendingMigrations,
+        "last_backup_at" => $lastBackupAt,
+    ];
+}
+
+/**
+ * Sends the heartbeat.
+ *
+ * @return array{success: bool, latest: string, update_available: bool, server_time: string}
+ */
+function manageHeartbeatSend(): array
+{
+    $decoded = manageClientRequestJson(
+        "POST",
+        "heartbeat.php",
+        manageHeartbeatPayload(),
+        (int) MANAGE_HTTP_TIMEOUT,
+    );
+
+    return [
+        "success" => !empty($decoded["success"]),
+        "latest" => (string) ($decoded["latest"] ?? ""),
+        "update_available" => !empty($decoded["update_available"]),
+        "server_time" => (string) ($decoded["server_time"] ?? ""),
+    ];
+}
+
+/**
+ * Heartbeat that never throws. For call sites inside a host application where
+ * an unreachable server must not surface as an error.
+ */
+function manageHeartbeatSendQuietly(): ?array
+{
+    try {
+        return manageHeartbeatSend();
+    } catch (Throwable $exception) {
+        manageClientLog("WARNING", "Heartbeat failed", ["error" => $exception->getMessage()]);
+
+        return null;
+    }
+}

+ 309 - 0
manage-client/lib/hooks.php

@@ -0,0 +1,309 @@
+<?php
+
+declare(strict_types=1);
+
+// Post-update hook and migration runner.
+//
+// Runs as the last step of manageUpdateApply(), after the files are in place.
+// Two independent mechanisms, either or both:
+//
+//   1. MANAGE_UPDATE_POST_HOOK  – a project callback (clear a cache, rebuild an
+//      index, chmod a new directory)
+//   2. MANAGE_MIGRATIONS_DIR    – ordered, once-only migration scripts shipped
+//      inside the release package
+//
+// There is no rollback in this client, so a failure here must be loud rather
+// than silent: the run stops at the first failing migration, the remaining ones
+// stay pending, and the result is reported through the CLI exit code and the
+// GUI banner. `manage-client.php migrate` retries once the cause is fixed.
+
+function manageMigrationsEnabled(): bool
+{
+    $dir = MANAGE_MIGRATIONS_DIR;
+
+    return is_string($dir) && trim($dir) !== "";
+}
+
+function manageMigrationsDir(): string
+{
+    return rtrim((string) MANAGE_MIGRATIONS_DIR, "/\\") . DIRECTORY_SEPARATOR;
+}
+
+function manageMigrationsStateFile(): string
+{
+    return (string) MANAGE_MIGRATIONS_STATE;
+}
+
+function manageMigrationsReadState(): array
+{
+    $state = manageReadJson(manageMigrationsStateFile());
+    $applied = isset($state["applied"]) && is_array($state["applied"])
+        ? $state["applied"]
+        : [];
+
+    return ["applied" => array_values($applied)];
+}
+
+function manageMigrationsAppliedIds(): array
+{
+    $ids = [];
+    foreach (manageMigrationsReadState()["applied"] as $entry) {
+        if (is_array($entry) && ($entry["id"] ?? "") !== "") {
+            $ids[] = (string) $entry["id"];
+        }
+    }
+
+    return $ids;
+}
+
+function manageMigrationsRecordApplied(string $id, int $durationMs): void
+{
+    $state = manageMigrationsReadState();
+    $state["applied"][] = [
+        "id" => $id,
+        "applied_at" => date(DATE_ATOM),
+        "version" => manageClientVersion(),
+        "duration_ms" => $durationMs,
+    ];
+
+    manageWriteJson(manageMigrationsStateFile(), $state);
+}
+
+/**
+ * All migration files in the package, sorted by filename.
+ *
+ * The filename without .php is the migration id, so renaming an already applied
+ * migration makes it run again. That is documented, not accidental.
+ */
+function manageMigrationsAvailable(): array
+{
+    if (!manageMigrationsEnabled() || !is_dir(manageMigrationsDir())) {
+        return [];
+    }
+
+    $migrations = [];
+    foreach (glob(manageMigrationsDir() . "*.php") ?: [] as $path) {
+        if (!is_file($path) || !is_readable($path)) {
+            continue;
+        }
+        $id = basename($path, ".php");
+        if ($id === "" || $id[0] === ".") {
+            continue;
+        }
+        $migrations[] = ["id" => $id, "path" => $path];
+    }
+
+    usort($migrations, static function (array $left, array $right): int {
+        return strcmp($left["id"], $right["id"]);
+    });
+
+    return $migrations;
+}
+
+/**
+ * Migrations that have not been applied yet, in execution order.
+ */
+function manageUpdatePendingMigrations(): array
+{
+    $applied = manageMigrationsAppliedIds();
+    $pending = [];
+
+    foreach (manageMigrationsAvailable() as $migration) {
+        if (!in_array($migration["id"], $applied, true)) {
+            $pending[] = $migration;
+        }
+    }
+
+    return $pending;
+}
+
+// Builds the context handed to every migration and to the post-update hook.
+function manageHookContext(array $extra = []): array
+{
+    $context = array_merge([
+        "app_root" => manageClientAppRoot(),
+        "instance" => (string) MANAGE_INSTANCE,
+        "from_version" => "",
+        "to_version" => manageClientVersion(),
+        "backup_dir" => "",
+        "run_id" => "",
+    ], $extra);
+
+    // A database-backed project gets a ready connection, so a migration never
+    // has to duplicate the credentials that are already configured for backups.
+    if (manageDatabaseConfigured()) {
+        $context["pdo"] = manageDatabaseConnect();
+    }
+
+    return $context;
+}
+
+/**
+ * Loads one migration file and returns its callable.
+ *
+ * Two supported shapes:
+ *   return function (array $context): void { ... };
+ *   function up(array $context): void { ... }   // defined in the file
+ */
+function manageMigrationResolveCallable(array $migration): callable
+{
+    $returned = require $migration["path"];
+
+    if (is_callable($returned)) {
+        return $returned;
+    }
+
+    if (function_exists("up")) {
+        return "up";
+    }
+
+    throw new RuntimeException(
+        "Migration " . $migration["id"] . " liefert keine Funktion zurück und definiert kein up().",
+    );
+}
+
+/**
+ * Runs all pending migrations in order.
+ *
+ * Stops at the first failure; later migrations stay pending. Returns a report
+ * rather than throwing, so a caller can distinguish "deployment succeeded but
+ * a migration failed" from "deployment failed".
+ *
+ * @return array{success: bool, applied: array, failed: string|null, error: string|null, pending: int}
+ */
+function manageUpdateRunMigrations(array $context = []): array
+{
+    $report = [
+        "success" => true,
+        "applied" => [],
+        "failed" => null,
+        "error" => null,
+        "pending" => 0,
+    ];
+
+    $pending = manageUpdatePendingMigrations();
+    if ($pending === []) {
+        return $report;
+    }
+
+    $baseContext = manageHookContext($context);
+
+    foreach ($pending as $position => $migration) {
+        $startedAt = microtime(true);
+
+        try {
+            // Each migration is loaded in its own function scope. A file that
+            // defines up() twice across two migrations would collide, which is
+            // why the "return a closure" form is the documented default.
+            $callable = manageMigrationResolveCallable($migration);
+            $callable(array_merge($baseContext, ["migration_id" => $migration["id"]]));
+        } catch (Throwable $exception) {
+            $report["success"] = false;
+            $report["failed"] = $migration["id"];
+            $report["error"] = $exception->getMessage();
+            $report["pending"] = count($pending) - $position;
+
+            manageClientLog("ERROR", "Migration failed", [
+                "migration" => $migration["id"],
+                "error" => $exception->getMessage(),
+            ]);
+
+            return $report;
+        }
+
+        $durationMs = (int) round((microtime(true) - $startedAt) * 1000);
+        manageMigrationsRecordApplied($migration["id"], $durationMs);
+        $report["applied"][] = $migration["id"];
+
+        manageClientLog("INFO", "Migration applied", [
+            "migration" => $migration["id"],
+            "duration_ms" => $durationMs,
+        ]);
+    }
+
+    return $report;
+}
+
+/**
+ * Runs the configured project callback.
+ *
+ * @return array{configured: bool, success: bool, error: string|null}
+ */
+function manageUpdateRunPostHookCallback(array $context = []): array
+{
+    $hook = MANAGE_UPDATE_POST_HOOK;
+    if (!is_array($hook) || ($hook["callback"] ?? null) === null) {
+        return ["configured" => false, "success" => true, "error" => null];
+    }
+
+    try {
+        $file = trim((string) ($hook["file"] ?? ""));
+        if ($file !== "") {
+            if (!is_file($file)) {
+                throw new RuntimeException("Hook-Datei wurde nicht gefunden: " . $file);
+            }
+            require_once $file;
+        }
+
+        $callback = $hook["callback"];
+        if (!is_callable($callback)) {
+            throw new RuntimeException(
+                "Hook-Callback ist nicht aufrufbar: " . (is_string($callback) ? $callback : gettype($callback)),
+            );
+        }
+
+        $result = call_user_func($callback, manageHookContext($context));
+        if ($result === false || (is_array($result) && ($result["success"] ?? true) === false)) {
+            $error = is_array($result) ? trim((string) ($result["error"] ?? "")) : "";
+            throw new RuntimeException(
+                "Post-Update-Hook meldet einen Fehler" . ($error !== "" ? ": " . $error : "."),
+            );
+        }
+    } catch (Throwable $exception) {
+        manageClientLog("ERROR", "Post-update hook failed", [
+            "error" => $exception->getMessage(),
+        ]);
+
+        return ["configured" => true, "success" => false, "error" => $exception->getMessage()];
+    }
+
+    manageClientLog("INFO", "Post-update hook finished", []);
+
+    return ["configured" => true, "success" => true, "error" => null];
+}
+
+/**
+ * Full post-update step: migrations first, then the project callback.
+ *
+ * Migrations run first so the callback can rely on the new schema. When a
+ * migration fails the callback is skipped, because running it against a
+ * half-migrated state is worse than not running it at all.
+ *
+ * @return array{success: bool, migrations: array, hook: array, error: string|null, failed_migration: string|null}
+ */
+function manageUpdateRunPostHook(array $context = []): array
+{
+    $migrations = manageUpdateRunMigrations($context);
+
+    if (!$migrations["success"]) {
+        return [
+            "success" => false,
+            "migrations" => $migrations,
+            "hook" => ["configured" => false, "success" => true, "error" => null, "skipped" => true],
+            "error" => $migrations["error"],
+            "failed_migration" => $migrations["failed"],
+        ];
+    }
+
+    $hook = manageUpdateRunPostHookCallback(array_merge($context, [
+        "migrations" => $migrations["applied"],
+    ]));
+
+    return [
+        "success" => $hook["success"],
+        "migrations" => $migrations,
+        "hook" => $hook,
+        "error" => $hook["error"],
+        "failed_migration" => null,
+    ];
+}

+ 235 - 0
manage-client/lib/mysql.php

@@ -0,0 +1,235 @@
+<?php
+
+declare(strict_types=1);
+
+// Optional MySQL/MariaDB dump for the backup archive.
+//
+// PDO only: no exec(), no mysqldump binary, because shared hosting frequently
+// blocks shell execution. The dump is streamed to a temp file, so table size is
+// bounded by disk rather than memory_limit.
+//
+// Active only when MANAGE_BACKUP_DATABASE is configured. Projects without a
+// database leave it at null and never touch this.
+
+function manageDatabaseConfigured(): bool
+{
+    $config = MANAGE_BACKUP_DATABASE;
+
+    return is_array($config) && trim((string) ($config["dsn"] ?? "")) !== "";
+}
+
+function manageDatabaseConnect(): PDO
+{
+    $config = MANAGE_BACKUP_DATABASE;
+    if (!is_array($config)) {
+        throw new RuntimeException("MANAGE_BACKUP_DATABASE ist nicht konfiguriert.");
+    }
+
+    $dsn = trim((string) ($config["dsn"] ?? ""));
+    if ($dsn === "") {
+        throw new RuntimeException("MANAGE_BACKUP_DATABASE benötigt einen DSN.");
+    }
+
+    if (!class_exists("PDO")) {
+        throw new RuntimeException("Die PHP-PDO-Erweiterung ist nicht verfügbar.");
+    }
+
+    try {
+        $pdo = new PDO(
+            $dsn,
+            (string) ($config["user"] ?? ""),
+            (string) ($config["password"] ?? ""),
+            [
+                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+            ],
+        );
+    } catch (PDOException $exception) {
+        // The DSN may contain a host name but never a password, so it is safe
+        // to keep out of the message entirely.
+        throw new RuntimeException("Datenbankverbindung fehlgeschlagen: " . $exception->getMessage());
+    }
+
+    return $pdo;
+}
+
+function manageDatabaseName(PDO $pdo): string
+{
+    $config = MANAGE_BACKUP_DATABASE;
+    $name = is_array($config) ? trim((string) ($config["name"] ?? "")) : "";
+    if ($name !== "") {
+        return $name;
+    }
+
+    try {
+        $value = $pdo->query("SELECT DATABASE()")->fetchColumn();
+        if (is_string($value) && $value !== "") {
+            return $value;
+        }
+    } catch (Throwable $exception) {
+        // Fall through to the generic name below.
+    }
+
+    return "database";
+}
+
+function manageDatabaseQuoteIdentifier(string $identifier): string
+{
+    return "`" . str_replace("`", "``", $identifier) . "`";
+}
+
+function manageDatabaseTables(PDO $pdo): array
+{
+    $tables = [];
+    foreach ($pdo->query("SHOW FULL TABLES") as $row) {
+        $values = array_values($row);
+        $name = (string) ($values[0] ?? "");
+        $type = strtoupper((string) ($values[1] ?? "BASE TABLE"));
+        if ($name === "") {
+            continue;
+        }
+        $tables[] = ["name" => $name, "type" => $type];
+    }
+
+    usort($tables, static function (array $left, array $right): int {
+        return strcmp($left["name"], $right["name"]);
+    });
+
+    return $tables;
+}
+
+// Formats one value for the INSERT statement. Binary content is written as a
+// hex literal so the dump stays valid ASCII and survives any transport.
+function manageDatabaseQuoteValue(PDO $pdo, $value): string
+{
+    if ($value === null) {
+        return "NULL";
+    }
+    if (is_int($value) || is_float($value)) {
+        return (string) $value;
+    }
+    if (is_bool($value)) {
+        return $value ? "1" : "0";
+    }
+
+    $value = (string) $value;
+    if ($value !== "" && preg_match('//u', $value) !== 1) {
+        return "0x" . bin2hex($value);
+    }
+
+    return $pdo->quote($value);
+}
+
+/**
+ * Writes a SQL dump of the configured database to $targetFile.
+ *
+ * @return array{tables: int, rows: int, bytes: int, database: string}
+ */
+function manageDatabaseDump(string $targetFile): array
+{
+    $pdo = manageDatabaseConnect();
+    $config = MANAGE_BACKUP_DATABASE;
+    $skipDataTables = is_array($config) && is_array($config["skip_data_tables"] ?? null)
+        ? array_map("strval", $config["skip_data_tables"])
+        : [];
+
+    manageEnsureDir(dirname($targetFile));
+    $handle = fopen($targetFile, "wb");
+    if ($handle === false) {
+        throw new RuntimeException("SQL-Dump konnte nicht erstellt werden.");
+    }
+
+    $database = manageDatabaseName($pdo);
+    $tableCount = 0;
+    $rowCount = 0;
+
+    try {
+        fwrite($handle, "-- Manage client database dump\n");
+        fwrite($handle, "-- Database: " . $database . "\n");
+        fwrite($handle, "-- Created: " . date(DATE_ATOM) . "\n\n");
+        fwrite($handle, "SET NAMES utf8mb4;\n");
+        fwrite($handle, "SET FOREIGN_KEY_CHECKS=0;\n\n");
+
+        foreach (manageDatabaseTables($pdo) as $table) {
+            $name = $table["name"];
+            $quoted = manageDatabaseQuoteIdentifier($name);
+
+            // Views must be recreated after the tables they read from, but a
+            // single-pass dump with FOREIGN_KEY_CHECKS=0 is enough in practice
+            // and keeps this readable.
+            $createRow = $pdo->query("SHOW CREATE TABLE " . $quoted)->fetch();
+            $create = "";
+            foreach ((array) $createRow as $key => $value) {
+                if (stripos((string) $key, "create") === 0) {
+                    $create = (string) $value;
+                    break;
+                }
+            }
+            if ($create === "") {
+                continue;
+            }
+
+            $tableCount++;
+            fwrite($handle, "--\n-- Table: " . $name . "\n--\n");
+            fwrite($handle, "DROP TABLE IF EXISTS " . $quoted . ";\n");
+            fwrite($handle, "DROP VIEW IF EXISTS " . $quoted . ";\n");
+            fwrite($handle, $create . ";\n\n");
+
+            if ($table["type"] === "VIEW" || in_array($name, $skipDataTables, true)) {
+                continue;
+            }
+
+            // Chunked reads keep a large table from being buffered as a whole.
+            $offset = 0;
+            $chunkSize = 500;
+            while (true) {
+                $statement = $pdo->prepare(
+                    "SELECT * FROM " . $quoted . " LIMIT " . $chunkSize . " OFFSET " . $offset,
+                );
+                $statement->execute();
+                $rows = $statement->fetchAll();
+                if ($rows === []) {
+                    break;
+                }
+
+                foreach ($rows as $row) {
+                    $columns = [];
+                    $values = [];
+                    foreach ($row as $column => $value) {
+                        $columns[] = manageDatabaseQuoteIdentifier((string) $column);
+                        $values[] = manageDatabaseQuoteValue($pdo, $value);
+                    }
+                    fwrite(
+                        $handle,
+                        "INSERT INTO " . $quoted . " (" . implode(", ", $columns) . ") VALUES (" .
+                        implode(", ", $values) . ");\n",
+                    );
+                    $rowCount++;
+                }
+
+                if (count($rows) < $chunkSize) {
+                    break;
+                }
+                $offset += $chunkSize;
+            }
+
+            fwrite($handle, "\n");
+        }
+
+        fwrite($handle, "SET FOREIGN_KEY_CHECKS=1;\n");
+    } catch (Throwable $exception) {
+        fclose($handle);
+        @unlink($targetFile);
+        throw new RuntimeException("Datenbank-Dump fehlgeschlagen: " . $exception->getMessage());
+    }
+
+    fclose($handle);
+    @chmod($targetFile, 0660);
+
+    return [
+        "tables" => $tableCount,
+        "rows" => $rowCount,
+        "bytes" => (int) (filesize($targetFile) ?: 0),
+        "database" => $database,
+    ];
+}

+ 366 - 0
manage-client/lib/remote.php

@@ -0,0 +1,366 @@
+<?php
+
+declare(strict_types=1);
+
+// Extra backup destinations besides the manage server: s3, sftp and custom.
+//
+// There is no "managed" target type: uploading to the manage server is built
+// in (manageBackupUpload) and configured through MANAGE_SERVER_URL /
+// MANAGE_INSTANCE / MANAGE_TOKEN instead of a target entry.
+
+function manageRemoteTargets(): array
+{
+    return is_array(MANAGE_BACKUP_REMOTE_TARGETS) ? MANAGE_BACKUP_REMOTE_TARGETS : [];
+}
+
+function manageRemoteTargetLabel(array $target, int $index): string
+{
+    $name = trim((string) ($target["name"] ?? ""));
+    if ($name !== "") {
+        return $name;
+    }
+
+    $type = trim((string) ($target["type"] ?? "target"));
+
+    return $type . "-" . ($index + 1);
+}
+
+// Whitelist of non-secret keys, so a failure can be logged with useful context
+// without ever writing an access key or password to disk.
+function manageRemoteSafeContext(array $target): array
+{
+    $safe = [];
+    $allowedKeys = [
+        "name", "type", "url", "bucket", "region", "prefix", "endpoint",
+        "host", "port", "username", "path", "file", "callback", "timeout",
+    ];
+
+    foreach ($allowedKeys as $key) {
+        if (array_key_exists($key, $target)) {
+            $safe[$key] = is_scalar($target[$key]) ? (string) $target[$key] : gettype($target[$key]);
+        }
+    }
+
+    return $safe;
+}
+
+function manageRemoteResponseExcerpt($response): string
+{
+    if (!is_string($response) || $response === "") {
+        return "";
+    }
+
+    $response = preg_replace('/\s+/', " ", trim($response));
+
+    return is_string($response) ? substr($response, 0, 500) : "";
+}
+
+function manageRemoteLastPhpError(): string
+{
+    $error = error_get_last();
+    if (!is_array($error)) {
+        return "";
+    }
+
+    return substr(trim((string) ($error["message"] ?? "")), 0, 500);
+}
+
+// Reports which target types this installation can actually use, so the GUI can
+// warn about a configured target that will always fail.
+function manageRemoteCapabilities(): array
+{
+    $types = [];
+    foreach (manageRemoteTargets() as $target) {
+        if (is_array($target)) {
+            $type = trim((string) ($target["type"] ?? ""));
+            if ($type !== "") {
+                $types[$type] = true;
+            }
+        }
+    }
+
+    return [
+        "s3" => [
+            "configured" => !empty($types["s3"]),
+            "available" => function_exists("hash_hmac"),
+        ],
+        "sftp" => [
+            "configured" => !empty($types["sftp"]),
+            "available" => function_exists("ssh2_connect") && function_exists("ssh2_sftp"),
+        ],
+        "custom" => [
+            "configured" => !empty($types["custom"]),
+            "available" => true,
+        ],
+    ];
+}
+
+function manageRemoteUploadToS3(string $archivePath, array $metadata, array $target): array
+{
+    $bucket = trim((string) ($target["bucket"] ?? ""));
+    $region = trim((string) ($target["region"] ?? ""));
+    $accessKey = trim((string) ($target["access_key"] ?? ""));
+    $secretKey = (string) ($target["secret_key"] ?? "");
+    $prefix = trim((string) ($target["prefix"] ?? ""), "/");
+    $endpoint = rtrim(trim((string) ($target["endpoint"] ?? "")), "/");
+
+    if ($bucket === "" || $region === "" || $accessKey === "" || $secretKey === "") {
+        throw new RuntimeException("S3-Ziel ist unvollständig konfiguriert.");
+    }
+
+    $filename = basename($archivePath);
+    $key = ($prefix !== "" ? $prefix . "/" : "") . $filename;
+    $host = $endpoint !== ""
+        ? parse_url($endpoint, PHP_URL_HOST)
+        : $bucket . ".s3." . $region . ".amazonaws.com";
+    if (!is_string($host) || $host === "") {
+        throw new RuntimeException("S3-Endpunkt ist ungültig.");
+    }
+
+    $url = $endpoint !== ""
+        ? $endpoint . "/" . rawurlencode($bucket) . "/" . str_replace("%2F", "/", rawurlencode($key))
+        : "https://" . $host . "/" . str_replace("%2F", "/", rawurlencode($key));
+
+    // The payload must be hashed as a whole for SigV4, so a backup larger than
+    // memory_limit cannot use this target.
+    $payload = file_get_contents($archivePath);
+    if ($payload === false) {
+        throw new RuntimeException("Backup-ZIP konnte für S3 nicht gelesen werden.");
+    }
+
+    $now = gmdate("Ymd\THis\Z");
+    $date = substr($now, 0, 8);
+    $payloadHash = hash("sha256", $payload);
+    $canonicalUri = parse_url($url, PHP_URL_PATH);
+    $canonicalUri = is_string($canonicalUri) && $canonicalUri !== "" ? $canonicalUri : "/";
+    $signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
+    $canonicalHeaders =
+        "content-type:application/zip\n" .
+        "host:" . $host . "\n" .
+        "x-amz-content-sha256:" . $payloadHash . "\n" .
+        "x-amz-date:" . $now . "\n";
+    $canonicalRequest =
+        "PUT\n" . $canonicalUri . "\n\n" . $canonicalHeaders . "\n" . $signedHeaders . "\n" . $payloadHash;
+    $scope = $date . "/" . $region . "/s3/aws4_request";
+    $stringToSign =
+        "AWS4-HMAC-SHA256\n" . $now . "\n" . $scope . "\n" . hash("sha256", $canonicalRequest);
+    $kDate = hash_hmac("sha256", $date, "AWS4" . $secretKey, true);
+    $kRegion = hash_hmac("sha256", $region, $kDate, true);
+    $kService = hash_hmac("sha256", "s3", $kRegion, true);
+    $kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
+    $signature = hash_hmac("sha256", $stringToSign, $kSigning);
+    $authorization =
+        "AWS4-HMAC-SHA256 Credential=" . $accessKey . "/" . $scope .
+        ", SignedHeaders=" . $signedHeaders . ", Signature=" . $signature;
+
+    $context = stream_context_create([
+        "http" => [
+            "method" => "PUT",
+            "timeout" => (int) ($target["timeout"] ?? 120),
+            "ignore_errors" => true,
+            "follow_location" => 0,
+            "header" =>
+                "Content-Type: application/zip\r\n" .
+                "Content-Length: " . strlen($payload) . "\r\n" .
+                "Host: " . $host . "\r\n" .
+                "X-Amz-Date: " . $now . "\r\n" .
+                "X-Amz-Content-Sha256: " . $payloadHash . "\r\n" .
+                "Authorization: " . $authorization . "\r\n" .
+                "User-Agent: " . manageClientUserAgent() . "\r\n",
+            "content" => $payload,
+        ],
+    ]);
+
+    $response = @file_get_contents($url, false, $context);
+    $phpError = $response === false ? manageRemoteLastPhpError() : "";
+    $headers = manageClientResponseHeaders($http_response_header ?? null);
+    $status = manageClientStatusFromHeaders($headers);
+
+    if ($response === false || $status < 200 || $status >= 300) {
+        throw new ManageRemoteUploadException(
+            "S3-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
+            [
+                "http_status" => $status,
+                "response_excerpt" => manageRemoteResponseExcerpt($response),
+                "php_error" => $phpError,
+                "bucket" => $bucket,
+                "region" => $region,
+                "key" => $key,
+                "endpoint" => $endpoint,
+            ],
+        );
+    }
+
+    return ["remote_path" => "s3://" . $bucket . "/" . $key];
+}
+
+function manageRemoteUploadToSftp(string $archivePath, array $metadata, array $target): array
+{
+    if (!function_exists("ssh2_connect") || !function_exists("ssh2_sftp")) {
+        throw new RuntimeException("Die PHP-SSH2-Erweiterung ist nicht verfügbar.");
+    }
+
+    $host = trim((string) ($target["host"] ?? ""));
+    $username = trim((string) ($target["username"] ?? ""));
+    $password = (string) ($target["password"] ?? "");
+    $remoteDir = rtrim((string) ($target["path"] ?? ""), "/");
+    $port = (int) ($target["port"] ?? 22);
+
+    if ($host === "" || $username === "" || $remoteDir === "") {
+        throw new RuntimeException("SFTP-Ziel ist unvollständig konfiguriert.");
+    }
+
+    $connection = @ssh2_connect($host, $port > 0 ? $port : 22);
+    if ($connection === false) {
+        throw new RuntimeException("SFTP-Verbindung konnte nicht hergestellt werden.");
+    }
+
+    $authenticated = false;
+    $privateKey = trim((string) ($target["private_key"] ?? ""));
+    $publicKey = trim((string) ($target["public_key"] ?? ""));
+    if ($privateKey !== "" && $publicKey !== "" && function_exists("ssh2_auth_pubkey_file")) {
+        $authenticated = @ssh2_auth_pubkey_file(
+            $connection,
+            $username,
+            $publicKey,
+            $privateKey,
+            $password !== "" ? $password : null,
+        );
+    } elseif (function_exists("ssh2_auth_password")) {
+        $authenticated = @ssh2_auth_password($connection, $username, $password);
+    }
+
+    if (!$authenticated) {
+        throw new RuntimeException("SFTP-Anmeldung fehlgeschlagen.");
+    }
+
+    $sftp = @ssh2_sftp($connection);
+    if ($sftp === false) {
+        throw new RuntimeException("SFTP-Subsystem konnte nicht gestartet werden.");
+    }
+
+    $remotePath = $remoteDir . "/" . basename($archivePath);
+    $targetStream = @fopen("ssh2.sftp://" . intval($sftp) . $remotePath, "wb");
+    if ($targetStream === false) {
+        throw new RuntimeException("SFTP-Zieldatei konnte nicht geöffnet werden. Existiert das Verzeichnis?");
+    }
+
+    $source = fopen($archivePath, "rb");
+    if ($source === false) {
+        fclose($targetStream);
+        throw new RuntimeException("Backup-ZIP konnte für SFTP nicht gelesen werden.");
+    }
+
+    $copied = stream_copy_to_stream($source, $targetStream);
+    fclose($source);
+    fclose($targetStream);
+
+    if ($copied === false) {
+        throw new RuntimeException("SFTP-Upload fehlgeschlagen.");
+    }
+
+    return ["remote_path" => "sftp://" . $host . $remotePath];
+}
+
+function manageRemoteUploadToCustom(string $archivePath, array $metadata, array $target): array
+{
+    $file = trim((string) ($target["file"] ?? ""));
+    $callback = $target["callback"] ?? null;
+
+    if ($file !== "") {
+        if (!is_file($file)) {
+            throw new RuntimeException("Custom-Uploader-Datei wurde nicht gefunden: " . $file);
+        }
+        require_once $file;
+    }
+
+    if (!is_callable($callback)) {
+        throw new RuntimeException("Custom-Uploader ist nicht aufrufbar.");
+    }
+
+    $result = call_user_func($callback, $archivePath, $metadata, $target);
+    if ($result === true) {
+        return [];
+    }
+    if (is_array($result) && ($result["success"] ?? true) !== false) {
+        return $result;
+    }
+    if (is_array($result)) {
+        throw new RuntimeException(trim((string) ($result["error"] ?? "Custom-Uploader meldet einen Fehler.")));
+    }
+
+    throw new RuntimeException("Custom-Uploader meldet einen Fehler.");
+}
+
+/**
+ * Runs every configured extra target. Each is attempted independently and a
+ * failure never invalidates the local backup: the error is recorded in the
+ * backup index and logged.
+ */
+function manageRemoteUploadAll(string $archivePath, array $metadata): array
+{
+    $results = [];
+
+    foreach (manageRemoteTargets() as $index => $target) {
+        if (!is_array($target)) {
+            continue;
+        }
+
+        $type = trim((string) ($target["type"] ?? ""));
+        $label = manageRemoteTargetLabel($target, (int) $index);
+        $startedAt = date(DATE_ATOM);
+
+        try {
+            if ($type === "s3") {
+                $extra = manageRemoteUploadToS3($archivePath, $metadata, $target);
+            } elseif ($type === "sftp") {
+                $extra = manageRemoteUploadToSftp($archivePath, $metadata, $target);
+            } elseif ($type === "custom") {
+                $extra = manageRemoteUploadToCustom($archivePath, $metadata, $target);
+            } else {
+                throw new RuntimeException("Unbekannter Backup-Zieltyp: " . ($type !== "" ? $type : "(leer)"));
+            }
+
+            $results[] = array_merge([
+                "target" => $label,
+                "type" => $type,
+                "success" => true,
+                "started_at" => $startedAt,
+                "uploaded_at" => date(DATE_ATOM),
+            ], $extra);
+
+            manageClientLog("INFO", "Remote upload succeeded", [
+                "target" => $label,
+                "type" => $type,
+                "filename" => $metadata["filename"] ?? basename($archivePath),
+            ]);
+        } catch (Throwable $exception) {
+            $debugContext = $exception instanceof ManageRemoteUploadException
+                ? $exception->getDebugContext()
+                : [];
+
+            $result = [
+                "target" => $label,
+                "type" => $type !== "" ? $type : "unknown",
+                "success" => false,
+                "started_at" => $startedAt,
+                "error" => $exception->getMessage(),
+            ];
+            if ($debugContext !== []) {
+                $result["debug"] = $debugContext;
+            }
+            $results[] = $result;
+
+            manageClientLog("ERROR", "Remote upload failed", [
+                "target" => $label,
+                "type" => $type !== "" ? $type : "unknown",
+                "target_config" => manageRemoteSafeContext($target),
+                "filename" => $metadata["filename"] ?? basename($archivePath),
+                "error" => $exception->getMessage(),
+                "debug" => $debugContext,
+            ]);
+        }
+    }
+
+    return $results;
+}

+ 423 - 0
manage-client/lib/updater.php

@@ -0,0 +1,423 @@
+<?php
+
+declare(strict_types=1);
+
+// Update pipeline: check, download, verify, extract, deploy, post-update hook.
+//
+// Everything host-specific is configurable:
+//   - the version file (for example includes/version.php with APP_VERSION)
+//   - the protected paths (for example config.php, data/, .git/)
+//   - the package sanity marker (for example index.php / admin/ / includes/)
+//
+// Deployment is an overlay copy: every file in the package is written over the
+// application root, with each overwritten file copied aside first. Files that
+// disappeared between releases are NOT removed, and there is no restore path —
+// the aside copies exist for manual recovery only.
+
+function manageUpdateWorkDir(): string
+{
+    return rtrim((string) MANAGE_WORK_DIR, "/\\") . DIRECTORY_SEPARATOR;
+}
+
+function manageUpdateBackupRoot(): string
+{
+    return rtrim((string) MANAGE_UPDATE_BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR;
+}
+
+// ---------------------------------------------------------------------------
+// Manifest
+// ---------------------------------------------------------------------------
+
+/**
+ * Fetches and strictly validates the manifest.
+ *
+ * Every field is re-checked here because the response decides which code the
+ * instance will execute next.
+ */
+function manageUpdateFetchManifest(): array
+{
+    $decoded = manageClientRequestJson("GET", "manifest.php", null, (int) MANAGE_HTTP_TIMEOUT);
+
+    $version = trim((string) ($decoded["version"] ?? $decoded["latest"] ?? ""));
+    $packageUrl = trim((string) ($decoded["package_url"] ?? ""));
+    $sha256 = strtolower(trim((string) ($decoded["sha256"] ?? "")));
+    $size = isset($decoded["size"]) ? (int) $decoded["size"] : 0;
+    $publishedAt = trim((string) ($decoded["published_at"] ?? ""));
+
+    if (!manageIsVersionString($version)) {
+        throw new RuntimeException("Version im Manifest ist ungültig.");
+    }
+    if (!filter_var($packageUrl, FILTER_VALIDATE_URL)) {
+        throw new RuntimeException("Paket-URL im Manifest ist ungültig.");
+    }
+    if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) {
+        throw new RuntimeException("Prüfsumme im Manifest ist ungültig.");
+    }
+
+    return [
+        "version" => $version,
+        "package_url" => $packageUrl,
+        "sha256" => $sha256,
+        "size" => $size,
+        "published_at" => $publishedAt,
+    ];
+}
+
+/**
+ * Checks whether a newer release is available.
+ *
+ * @return array{current: string, latest: string, available: bool, manifest: array}
+ */
+function manageUpdateCheck(): array
+{
+    $manifest = manageUpdateFetchManifest();
+    $current = manageClientVersion();
+
+    $available = $current === ""
+        ? true
+        : version_compare(
+            manageVersionCompareValue($manifest["version"]),
+            manageVersionCompareValue($current),
+            ">",
+        );
+
+    return [
+        "current" => $current,
+        "latest" => $manifest["version"],
+        "available" => $available,
+        "manifest" => $manifest,
+    ];
+}
+
+// ---------------------------------------------------------------------------
+// Download and extraction
+// ---------------------------------------------------------------------------
+
+function manageUpdateDownloadPackage(array $manifest, string $targetFile): void
+{
+    manageEnsureDir(dirname($targetFile));
+
+    $version = (string) $manifest["version"];
+    $response = manageClientRequest(
+        "GET",
+        "package.php?version=" . rawurlencode($version),
+        null,
+        "application/json",
+        (int) MANAGE_HTTP_TIMEOUT_LONG,
+    );
+
+    if ($response["status"] < 200 || $response["status"] >= 300) {
+        throw new RuntimeException(manageClientErrorMessage($response["status"], $response["body"]));
+    }
+    if ($response["body"] === "") {
+        throw new RuntimeException("Das heruntergeladene Paket ist leer.");
+    }
+
+    if (file_put_contents($targetFile, $response["body"], LOCK_EX) === false) {
+        throw new RuntimeException("Das heruntergeladene Paket konnte nicht gespeichert werden.");
+    }
+
+    if ($manifest["size"] > 0 && filesize($targetFile) !== $manifest["size"]) {
+        unlink($targetFile);
+        throw new RuntimeException("Größe des heruntergeladenen Pakets stimmt nicht überein.");
+    }
+
+    $actualHash = strtolower(hash_file("sha256", $targetFile) ?: "");
+    if ($actualHash !== $manifest["sha256"]) {
+        unlink($targetFile);
+        throw new RuntimeException("Prüfsumme des Pakets stimmt nicht überein.");
+    }
+}
+
+// Rejects zip-slip and anything else that would escape the stage directory.
+function manageUpdateValidateZipEntry(string $entry): bool
+{
+    $entry = str_replace("\\", "/", $entry);
+    $normalized = trim($entry, "/");
+
+    if (
+        $normalized === "" ||
+        str_contains($entry, "\0") ||
+        str_starts_with($entry, "/") ||
+        preg_match('/^[A-Za-z]:\//', $entry) === 1
+    ) {
+        return false;
+    }
+
+    foreach (explode("/", $normalized) as $segment) {
+        if ($segment === "" || $segment === "." || $segment === "..") {
+            return false;
+        }
+    }
+
+    return true;
+}
+
+function manageUpdateExtractPackage(string $zipFile, string $stageDir): void
+{
+    if (!class_exists("ZipArchive")) {
+        throw new RuntimeException("Die PHP-Erweiterung ZipArchive ist nicht verfügbar.");
+    }
+
+    manageRemoveDir($stageDir);
+    manageEnsureDir($stageDir);
+
+    $zip = new ZipArchive();
+    if ($zip->open($zipFile) !== true) {
+        throw new RuntimeException("Das heruntergeladene Paket ist keine lesbare ZIP-Datei.");
+    }
+
+    $sanityPaths = is_array(MANAGE_UPDATE_SANITY_PATHS) ? MANAGE_UPDATE_SANITY_PATHS : [];
+    $hasAppFile = $sanityPaths === [];
+
+    for ($i = 0; $i < $zip->numFiles; $i++) {
+        $name = (string) $zip->getNameIndex($i);
+        if (!manageUpdateValidateZipEntry($name)) {
+            $zip->close();
+            throw new RuntimeException("Das Paket enthält einen unsicheren Pfad: " . $name);
+        }
+
+        foreach ($sanityPaths as $sanityPath) {
+            $sanityPath = trim(str_replace("\\", "/", (string) $sanityPath), "/");
+            if ($sanityPath === "") {
+                continue;
+            }
+            if ($name === $sanityPath || str_starts_with($name, $sanityPath . "/")) {
+                $hasAppFile = true;
+            }
+        }
+    }
+
+    if (!$hasAppFile) {
+        $zip->close();
+        throw new RuntimeException(
+            "Das Paket sieht nicht wie ein Release dieser Anwendung aus (erwartet: " .
+            implode(", ", array_map("strval", $sanityPaths)) . ").",
+        );
+    }
+
+    if (!$zip->extractTo($stageDir)) {
+        $zip->close();
+        throw new RuntimeException("Das Paket konnte nicht entpackt werden.");
+    }
+
+    $zip->close();
+}
+
+// ---------------------------------------------------------------------------
+// Deployment
+// ---------------------------------------------------------------------------
+
+function manageUpdateRelativePath(string $path, string $baseDir): string
+{
+    return ltrim(str_replace("\\", "/", substr($path, strlen($baseDir))), "/");
+}
+
+/**
+ * Whether a path from the package must be left alone.
+ *
+ * A configured entry ending in "/" protects the directory and everything below
+ * it; anything else matches the exact path.
+ */
+function manageUpdateShouldSkipPath(string $relativePath): bool
+{
+    $relativePath = trim(str_replace("\\", "/", $relativePath), "/");
+    if ($relativePath === "") {
+        return true;
+    }
+
+    $protected = is_array(MANAGE_UPDATE_PROTECTED_PATHS) ? MANAGE_UPDATE_PROTECTED_PATHS : [];
+
+    foreach ($protected as $entry) {
+        $entry = str_replace("\\", "/", (string) $entry);
+        $isDirectory = str_ends_with($entry, "/");
+        $entry = trim($entry, "/");
+        if ($entry === "") {
+            continue;
+        }
+
+        if ($relativePath === $entry) {
+            return true;
+        }
+        if ($isDirectory && str_starts_with($relativePath, $entry . "/")) {
+            return true;
+        }
+        // A protected directory named without a trailing slash still protects
+        // its contents; the trailing slash only documents the intent.
+        if (!$isDirectory && str_starts_with($relativePath, $entry . "/")) {
+            return true;
+        }
+    }
+
+    return false;
+}
+
+function manageUpdateCopyWithBackup(string $stageDir, string $appRoot, string $backupDir): array
+{
+    manageEnsureDir($backupDir);
+
+    $copied = 0;
+    $backedUp = 0;
+    $skipped = 0;
+
+    $items = new RecursiveIteratorIterator(
+        new RecursiveDirectoryIterator($stageDir, FilesystemIterator::SKIP_DOTS),
+        RecursiveIteratorIterator::SELF_FIRST,
+    );
+
+    foreach ($items as $item) {
+        $relativePath = manageUpdateRelativePath($item->getPathname(), $stageDir);
+        if (manageUpdateShouldSkipPath($relativePath)) {
+            $skipped++;
+            continue;
+        }
+
+        $targetPath = $appRoot . DIRECTORY_SEPARATOR . $relativePath;
+
+        if ($item->isDir()) {
+            manageEnsureDir($targetPath);
+            continue;
+        }
+
+        manageEnsureDir(dirname($targetPath));
+
+        if (file_exists($targetPath)) {
+            $backupPath = $backupDir . DIRECTORY_SEPARATOR . $relativePath;
+            manageEnsureDir(dirname($backupPath));
+            if (!copy($targetPath, $backupPath)) {
+                throw new RuntimeException("Datei konnte nicht gesichert werden: " . $relativePath);
+            }
+            $backedUp++;
+        }
+
+        if (!copy($item->getPathname(), $targetPath)) {
+            throw new RuntimeException("Datei konnte nicht ausgerollt werden: " . $relativePath);
+        }
+
+        @chmod($targetPath, fileperms($item->getPathname()) & 0777);
+        $copied++;
+    }
+
+    return ["copied" => $copied, "backed_up" => $backedUp, "skipped" => $skipped];
+}
+
+// Keeps only the backup directory of the run that just finished.
+function manageUpdateCleanupOldBackups(string $keepBackupDir): int
+{
+    $backupRoot = rtrim(manageUpdateBackupRoot(), "/\\");
+    if (!is_dir($backupRoot)) {
+        return 0;
+    }
+
+    $keepRealPath = realpath($keepBackupDir);
+    $backupRootRealPath = realpath($backupRoot);
+    if ($keepRealPath === false || $backupRootRealPath === false) {
+        return 0;
+    }
+
+    $removed = 0;
+    foreach (new DirectoryIterator($backupRootRealPath) as $item) {
+        if ($item->isDot() || !$item->isDir()) {
+            continue;
+        }
+
+        $path = $item->getPathname();
+        if (realpath($path) === $keepRealPath) {
+            continue;
+        }
+
+        manageRemoveDir($path);
+        if (is_dir($path)) {
+            throw new RuntimeException("Altes Backup-Verzeichnis konnte nicht entfernt werden: " . $path);
+        }
+        $removed++;
+    }
+
+    return $removed;
+}
+
+/**
+ * Downloads, verifies and deploys one release, then runs the post-update step.
+ *
+ * $options:
+ *   force     bool  redeploy even when no newer version is available
+ *   skip_hook bool  deploy files only, run neither migrations nor the callback
+ *
+ * The returned array always reports deployment and post-update separately:
+ * a failed hook does not undo a successful deployment.
+ */
+function manageUpdateApply(array $options = []): array
+{
+    $force = !empty($options["force"]);
+    $skipHook = !empty($options["skip_hook"]);
+
+    $appRoot = manageClientAppRoot();
+    $check = manageUpdateCheck();
+    $manifest = $check["manifest"];
+
+    if (!$check["available"] && !$force) {
+        throw new RuntimeException(
+            "Es ist kein neueres Update verfügbar. Mit der Option \"force\" kann dasselbe Paket erneut ausgerollt werden.",
+        );
+    }
+
+    $runId = date("Ymd-His");
+    $workDir = manageUpdateWorkDir() . $runId;
+    $stageDir = $workDir . DIRECTORY_SEPARATOR . "stage";
+    $zipFile = $workDir . DIRECTORY_SEPARATOR . "package.zip";
+    $backupDir = manageUpdateBackupRoot() . $runId . "-" . $manifest["version"];
+
+    manageEnsureDir($workDir);
+
+    try {
+        manageUpdateDownloadPackage($manifest, $zipFile);
+        manageUpdateExtractPackage($zipFile, $stageDir);
+        $result = manageUpdateCopyWithBackup($stageDir, $appRoot, $backupDir);
+    } finally {
+        manageRemoveDir($workDir);
+    }
+
+    $removedBackups = manageUpdateCleanupOldBackups($backupDir);
+
+    manageClientLog("INFO", "Update deployed", [
+        "from_version" => $check["current"],
+        "to_version" => $manifest["version"],
+        "copied" => $result["copied"],
+        "backed_up" => $result["backed_up"],
+        "backup_dir" => $backupDir,
+    ]);
+
+    $report = [
+        "deployed" => true,
+        "from_version" => $check["current"],
+        "to_version" => $manifest["version"],
+        "version" => manageClientVersion(),
+        "copied" => $result["copied"],
+        "backed_up" => $result["backed_up"],
+        "skipped" => $result["skipped"],
+        "removed_backups" => $removedBackups,
+        "backup_dir" => $backupDir,
+        "hook" => null,
+    ];
+
+    if ($skipHook) {
+        $report["hook"] = [
+            "success" => true,
+            "skipped" => true,
+            "migrations" => ["applied" => [], "pending" => count(manageUpdatePendingMigrations())],
+        ];
+
+        return $report;
+    }
+
+    // The version constant may already be loaded in this process from the old
+    // code, so to_version is taken from the manifest rather than re-read.
+    $report["hook"] = manageUpdateRunPostHook([
+        "from_version" => $check["current"],
+        "to_version" => $manifest["version"],
+        "backup_dir" => $backupDir,
+        "run_id" => $runId,
+    ]);
+
+    return $report;
+}

+ 328 - 0
manage-client/lib/zip.php

@@ -0,0 +1,328 @@
+<?php
+
+declare(strict_types=1);
+
+// Pure-PHP ZIP writer. Builds the archive with pack() rather than requiring
+// ext-zip, exec or a temp copy of the whole archive in memory.
+//
+// Deflate compression (method 8) is optional: storing everything uncompressed
+// is fine for JPEGs but wasteful for the SQL dumps this client can produce.
+//
+// Limits (no Zip64): 4 GB per entry, 4 GB per archive, 65535 entries.
+
+function manageZipDosDateTime(int $timestamp): array
+{
+    $parts = getdate($timestamp);
+    $year = max(1980, (int) $parts["year"]);
+
+    return [
+        (($year - 1980) << 9) | ((int) $parts["mon"] << 5) | (int) $parts["mday"],
+        ((int) $parts["hours"] << 11) |
+            ((int) $parts["minutes"] << 5) |
+            ((int) floor(((int) $parts["seconds"]) / 2)),
+    ];
+}
+
+function manageZipValidateEntryName(string $name): void
+{
+    $name = manageClientNormalizePath($name);
+
+    if (
+        $name === "" ||
+        str_contains($name, "\0") ||
+        str_starts_with($name, "/") ||
+        preg_match('/^[A-Za-z]:\//', $name) === 1
+    ) {
+        throw new RuntimeException("Ungültiger Pfad im Backup: " . $name);
+    }
+
+    foreach (explode("/", $name) as $segment) {
+        if ($segment === "" || $segment === "." || $segment === "..") {
+            throw new RuntimeException("Ungültiger Pfad im Backup: " . $name);
+        }
+    }
+
+    if (strlen($name) > 65535) {
+        throw new RuntimeException("Pfad im Backup ist zu lang: " . $name);
+    }
+}
+
+function manageZipWriteBytes($handle, string $data): void
+{
+    $offset = 0;
+    $length = strlen($data);
+
+    while ($offset < $length) {
+        $written = fwrite($handle, substr($data, $offset));
+        if ($written === false || $written === 0) {
+            throw new RuntimeException("Backup-ZIP konnte nicht geschrieben werden.");
+        }
+        $offset += $written;
+    }
+}
+
+// Streams a stored (uncompressed) entry in 1 MiB chunks, so archive size is
+// never bounded by memory_limit.
+function manageZipCopyStored(string $file, $handle): void
+{
+    $source = fopen($file, "rb");
+    if ($source === false) {
+        throw new RuntimeException("Datei konnte nicht gelesen werden: " . basename($file));
+    }
+
+    try {
+        while (!feof($source)) {
+            $chunk = fread($source, 1048576);
+            if ($chunk === false) {
+                throw new RuntimeException("Datei konnte nicht gelesen werden: " . basename($file));
+            }
+            if ($chunk !== "") {
+                manageZipWriteBytes($handle, $chunk);
+            }
+        }
+    } finally {
+        fclose($source);
+    }
+}
+
+// Deflates an entry with an incremental zlib stream, again without ever holding
+// the whole file in memory. Returns the compressed size.
+function manageZipCopyDeflated(string $file, $handle): int
+{
+    $source = fopen($file, "rb");
+    if ($source === false) {
+        throw new RuntimeException("Datei konnte nicht gelesen werden: " . basename($file));
+    }
+
+    // Raw deflate (window -15) is what a ZIP entry with method 8 expects.
+    $deflate = deflate_init(ZLIB_ENCODING_RAW, ["level" => 6]);
+    if ($deflate === false) {
+        fclose($source);
+        throw new RuntimeException("Kompression konnte nicht initialisiert werden.");
+    }
+
+    $compressedSize = 0;
+    try {
+        while (!feof($source)) {
+            $chunk = fread($source, 1048576);
+            if ($chunk === false) {
+                throw new RuntimeException("Datei konnte nicht gelesen werden: " . basename($file));
+            }
+            if ($chunk === "") {
+                continue;
+            }
+            $encoded = deflate_add($deflate, $chunk, ZLIB_NO_FLUSH);
+            if ($encoded === false) {
+                throw new RuntimeException("Kompression fehlgeschlagen: " . basename($file));
+            }
+            if ($encoded !== "") {
+                manageZipWriteBytes($handle, $encoded);
+                $compressedSize += strlen($encoded);
+            }
+        }
+
+        $encoded = deflate_add($deflate, "", ZLIB_FINISH);
+        if ($encoded === false) {
+            throw new RuntimeException("Kompression fehlgeschlagen: " . basename($file));
+        }
+        if ($encoded !== "") {
+            manageZipWriteBytes($handle, $encoded);
+            $compressedSize += strlen($encoded);
+        }
+    } finally {
+        fclose($source);
+    }
+
+    return $compressedSize;
+}
+
+function manageZipCompressionAvailable(): bool
+{
+    return MANAGE_BACKUP_COMPRESS === true &&
+        function_exists("deflate_init") &&
+        function_exists("deflate_add");
+}
+
+/**
+ * Writes a ZIP archive.
+ *
+ * @param string $targetFile absolute path of the archive to create
+ * @param array  $files      list of ["path" => absolute, "name" => entry name]
+ *
+ * @return array{file_count: int, source_bytes: int, archive_bytes: int, sha256: string}
+ */
+function manageZipWrite(string $targetFile, array $files): array
+{
+    if ($files === []) {
+        throw new RuntimeException("Keine Dateien für das Backup gefunden.");
+    }
+
+    $handle = fopen($targetFile, "wb");
+    if ($handle === false) {
+        throw new RuntimeException("Backup-ZIP konnte nicht erstellt werden.");
+    }
+
+    $compress = manageZipCompressionAvailable();
+    $centralDirectory = "";
+    $fileCount = 0;
+    $sourceBytes = 0;
+
+    try {
+        foreach ($files as $file) {
+            $path = (string) ($file["path"] ?? "");
+            $name = manageClientNormalizePath((string) ($file["name"] ?? ""));
+            manageZipValidateEntryName($name);
+
+            if (!is_file($path) || !is_readable($path)) {
+                continue;
+            }
+
+            $size = filesize($path);
+            if ($size === false) {
+                throw new RuntimeException("Dateigröße konnte nicht ermittelt werden: " . $name);
+            }
+            if ($size > 0xffffffff) {
+                throw new RuntimeException("Datei ist zu groß für dieses Backup-Format: " . $name);
+            }
+
+            $offset = ftell($handle);
+            if ($offset === false || $offset > 0xffffffff) {
+                throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
+            }
+
+            $crcHex = hash_file("crc32b", $path);
+            if (!is_string($crcHex) || preg_match('/^[a-f0-9]{8}$/i', $crcHex) !== 1) {
+                throw new RuntimeException("Prüfsumme konnte nicht berechnet werden: " . $name);
+            }
+            $crc = (int) hexdec($crcHex);
+            [$dosDate, $dosTime] = manageZipDosDateTime((int) (filemtime($path) ?: time()));
+            $nameLength = strlen($name);
+
+            // An empty file must stay stored: deflate would emit a 2-byte body
+            // for zero input, which some readers reject.
+            $useDeflate = $compress && $size > 0;
+            $method = $useDeflate ? 8 : 0;
+
+            // The local header needs the compressed size up front, which is not
+            // known before compressing. The header is therefore written with a
+            // placeholder and patched after the body, exactly like a two-pass
+            // writer; seeking is safe because the target is a real file.
+            manageZipWriteBytes(
+                $handle,
+                pack(
+                    "VvvvvvVVVvv",
+                    0x04034b50,
+                    $useDeflate ? 20 : 10,
+                    0,
+                    $method,
+                    $dosTime,
+                    $dosDate,
+                    $crc,
+                    0,
+                    $size,
+                    $nameLength,
+                    0,
+                ) . $name,
+            );
+
+            if ($useDeflate) {
+                $compressedSize = manageZipCopyDeflated($path, $handle);
+            } else {
+                manageZipCopyStored($path, $handle);
+                $compressedSize = $size;
+            }
+
+            if ($compressedSize > 0xffffffff) {
+                throw new RuntimeException("Datei ist zu groß für dieses Backup-Format: " . $name);
+            }
+
+            if ($useDeflate) {
+                $afterEntry = ftell($handle);
+                if ($afterEntry === false) {
+                    throw new RuntimeException("Backup-ZIP konnte nicht geschrieben werden.");
+                }
+                // Compressed size sits 18 bytes into the local file header.
+                if (fseek($handle, $offset + 18) !== 0) {
+                    throw new RuntimeException("Backup-ZIP konnte nicht aktualisiert werden.");
+                }
+                manageZipWriteBytes($handle, pack("V", $compressedSize));
+                if (fseek($handle, $afterEntry) !== 0) {
+                    throw new RuntimeException("Backup-ZIP konnte nicht aktualisiert werden.");
+                }
+            }
+
+            $centralDirectory .=
+                pack(
+                    "VvvvvvvVVVvvvvvVV",
+                    0x02014b50,
+                    0x031e,
+                    $useDeflate ? 20 : 10,
+                    0,
+                    $method,
+                    $dosTime,
+                    $dosDate,
+                    $crc,
+                    $compressedSize,
+                    $size,
+                    $nameLength,
+                    0,
+                    0,
+                    0,
+                    0,
+                    0,
+                    $offset,
+                ) .
+                $name;
+
+            $fileCount++;
+            $sourceBytes += $size;
+        }
+
+        if ($fileCount < 1) {
+            throw new RuntimeException("Keine lesbaren Dateien für das Backup gefunden.");
+        }
+        if ($fileCount > 65535) {
+            throw new RuntimeException("Zu viele Dateien für dieses Backup-Format.");
+        }
+
+        $centralOffset = ftell($handle);
+        $centralSize = strlen($centralDirectory);
+        if (
+            $centralOffset === false ||
+            $centralOffset > 0xffffffff ||
+            $centralSize > 0xffffffff
+        ) {
+            throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
+        }
+
+        manageZipWriteBytes($handle, $centralDirectory);
+        manageZipWriteBytes(
+            $handle,
+            pack(
+                "VvvvvVVv",
+                0x06054b50,
+                0,
+                0,
+                $fileCount,
+                $fileCount,
+                $centralSize,
+                $centralOffset,
+                0,
+            ),
+        );
+    } catch (Throwable $exception) {
+        fclose($handle);
+        @unlink($targetFile);
+        throw $exception;
+    }
+
+    fclose($handle);
+    @chmod($targetFile, 0660);
+
+    return [
+        "file_count" => $fileCount,
+        "source_bytes" => $sourceBytes,
+        "archive_bytes" => (int) (filesize($targetFile) ?: 0),
+        "sha256" => hash_file("sha256", $targetFile) ?: "",
+    ];
+}

+ 350 - 0
manage-client/ui/panel.php

@@ -0,0 +1,350 @@
+<?php
+
+declare(strict_types=1);
+
+// Drop-in admin page for the host application.
+//
+// The host is expected to have established its own session and authentication
+// BEFORE including this file. Adjust the default guard below to whatever the
+// host project uses (see 02_INTEGRATION.md).
+//
+// Typical integration, as myproject/admin/manage.php:
+//
+//     require_once __DIR__ . "/../config.php";
+//     require_once __DIR__ . "/../includes/functions.php";
+//     if (empty($_SESSION["admin_logged_in"])) { header("Location: login.php"); exit; }
+//     require __DIR__ . "/../manage-client/ui/panel.php";
+//
+// This page contains no update or backup logic of its own: every action calls
+// the same public functions as the CLI.
+
+require_once dirname(__DIR__) . "/lib/client.php";
+
+if (session_status() === PHP_SESSION_NONE) {
+    session_start();
+}
+
+// --- Authentication guard --------------------------------------------------
+// Replace this block if the host application uses a different session flag.
+if (!defined("MANAGE_PANEL_SKIP_AUTH_GUARD") && empty($_SESSION["admin_logged_in"])) {
+    http_response_code(403);
+    exit("Zugriff verweigert. Dieses Panel setzt eine angemeldete Sitzung voraus.");
+}
+
+function managePanelEscape($value): string
+{
+    return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
+}
+
+function managePanelCsrfToken(): string
+{
+    if (empty($_SESSION["manage_panel_csrf"])) {
+        $_SESSION["manage_panel_csrf"] = bin2hex(random_bytes(32));
+    }
+
+    return (string) $_SESSION["manage_panel_csrf"];
+}
+
+function managePanelCsrfValid(string $token): bool
+{
+    return !empty($_SESSION["manage_panel_csrf"]) &&
+        hash_equals((string) $_SESSION["manage_panel_csrf"], $token);
+}
+
+$messages = [];
+$errors = [];
+$warnings = [];
+
+if (($_SERVER["REQUEST_METHOD"] ?? "") === "POST") {
+    try {
+        if (!managePanelCsrfValid((string) ($_POST["csrf_token"] ?? ""))) {
+            throw new RuntimeException("Ungültiges Sicherheitstoken. Bitte die Seite neu laden.");
+        }
+
+        $action = (string) ($_POST["action"] ?? "");
+
+        if ($action === "backup") {
+            $record = manageBackupCreate("manual");
+            $messages[] = "Backup erstellt: " . $record["filename"] .
+                " (" . $record["file_count"] . " Dateien, " . manageFormatBytes((int) $record["size"]) . ")";
+            foreach ($record["remote_uploads"] as $upload) {
+                if (empty($upload["success"])) {
+                    $warnings[] = "Upload an " . (string) $upload["target"] . " fehlgeschlagen: " .
+                        (string) ($upload["error"] ?? "unbekannt");
+                }
+            }
+            manageHeartbeatSendQuietly();
+        } elseif ($action === "update") {
+            $result = manageUpdateApply(["force" => !empty($_POST["force"])]);
+            $messages[] = "Update ausgerollt: " . $result["from_version"] . " → " . $result["to_version"];
+            $messages[] = $result["copied"] . " Dateien kopiert, " . $result["backed_up"] . " gesichert.";
+            $messages[] = "Sicherungsverzeichnis: " . $result["backup_dir"];
+
+            $hook = $result["hook"];
+            if (is_array($hook)) {
+                $applied = $hook["migrations"]["applied"] ?? [];
+                if ($applied !== []) {
+                    $messages[] = "Migrationen ausgeführt: " . implode(", ", $applied);
+                }
+                if (empty($hook["success"])) {
+                    // The files are deployed; only the post-update step failed.
+                    if (!empty($hook["failed_migration"])) {
+                        $errors[] = "Die Dateien wurden ausgerollt, aber die Migration \"" .
+                            (string) $hook["failed_migration"] . "\" ist fehlgeschlagen: " .
+                            (string) ($hook["error"] ?? "");
+                        $errors[] = "Verbleibende Migrationen wurden nicht ausgeführt. " .
+                            "Nach Behebung der Ursache unten \"Migrationen ausführen\" verwenden.";
+                    } else {
+                        $errors[] = "Die Dateien wurden ausgerollt, aber der Post-Update-Hook ist " .
+                            "fehlgeschlagen: " . (string) ($hook["error"] ?? "");
+                    }
+                }
+            }
+            manageHeartbeatSendQuietly();
+        } elseif ($action === "migrate") {
+            $report = manageUpdateRunMigrations();
+            if ($report["applied"] !== []) {
+                $messages[] = "Migrationen ausgeführt: " . implode(", ", $report["applied"]);
+            }
+            if (!$report["success"]) {
+                $errors[] = "Migration \"" . (string) $report["failed"] . "\" ist fehlgeschlagen: " .
+                    (string) $report["error"];
+            } elseif ($report["applied"] === []) {
+                $messages[] = "Keine offenen Migrationen.";
+            }
+        } elseif ($action === "heartbeat") {
+            $result = manageHeartbeatSend();
+            $messages[] = "Heartbeat gesendet. Aktuelles Release: " .
+                ($result["latest"] !== "" ? $result["latest"] : "keines") . ".";
+        } elseif ($action === "download") {
+            $path = manageBackupPath((string) ($_POST["filename"] ?? ""));
+            $size = filesize($path);
+            $handle = fopen($path, "rb");
+            if ($size === false || $handle === false) {
+                throw new RuntimeException("Backup konnte nicht geöffnet werden.");
+            }
+
+            header("Content-Type: application/zip");
+            header("Content-Disposition: attachment; filename=\"" . addcslashes(basename($path), "\"\\") . "\"");
+            header("Content-Length: " . (string) $size);
+            header("Cache-Control: private, no-store");
+            header("X-Content-Type-Options: nosniff");
+            fpassthru($handle);
+            fclose($handle);
+            exit;
+        }
+    } catch (Throwable $exception) {
+        $errors[] = $exception->getMessage();
+    }
+}
+
+$status = manageClientStatus();
+$capabilities = manageRemoteCapabilities();
+
+?>
+<!DOCTYPE html>
+<html lang="de">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Update &amp; Backup</title>
+    <style>
+        /* Self-contained so the panel looks reasonable in any host application.
+           Override by loading the host's stylesheet after this file. */
+        .mc-wrap { max-width: 60rem; margin: 0 auto; padding: 1.5rem 1rem 3rem;
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+            line-height: 1.55; color: #16191d; }
+        .mc-wrap h1 { font-size: 1.5rem; margin: 0 0 1rem; }
+        .mc-wrap h2 { font-size: 1.1rem; margin: 1.75rem 0 .75rem; }
+        .mc-alert { padding: .7rem .9rem; border-radius: 8px; margin-bottom: .5rem; border: 1px solid transparent; }
+        .mc-ok { background: #eaf6ee; border-color: #bfe0cb; color: #1a6b3c; }
+        .mc-warn { background: #fdf3e0; border-color: #e6cf9d; color: #8a5a00; }
+        .mc-err { background: #fceceb; border-color: #f0c3bf; color: #a52218; }
+        .mc-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; }
+        .mc-card { border: 1px solid #d9dde3; border-radius: 8px; padding: .8rem .9rem; background: #fff; }
+        .mc-label { font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; color: #5c6470; }
+        .mc-value { font-size: 1.2rem; font-weight: 600; }
+        .mc-row { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: .75rem 0; }
+        .mc-btn { padding: .45rem .9rem; border: 1px solid #1f3b63; border-radius: 8px; background: #1f3b63;
+            color: #fff; font: inherit; font-size: .9rem; cursor: pointer; }
+        .mc-btn.sec { background: #fff; color: #1f3b63; }
+        .mc-table { width: 100%; border-collapse: collapse; font-size: .9rem; }
+        .mc-table th, .mc-table td { text-align: left; padding: .5rem .6rem; border-bottom: 1px solid #d9dde3; }
+        .mc-table thead th { font-size: .75rem; text-transform: uppercase; color: #5c6470; }
+        .mc-scroll { overflow-x: auto; border: 1px solid #d9dde3; border-radius: 8px; background: #fff; }
+        .mc-muted { color: #5c6470; }
+        .mc-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .8rem; }
+    </style>
+</head>
+<body>
+<div class="mc-wrap">
+    <h1>Update &amp; Backup</h1>
+
+    <p><a href="settings.php">&larr; Zurück zu den Einstellungen</a></p>
+
+    <?php foreach ($messages as $message): ?>
+        <p class="mc-alert mc-ok"><?php echo managePanelEscape($message); ?></p>
+    <?php endforeach; ?>
+    <?php foreach ($warnings as $warning): ?>
+        <p class="mc-alert mc-warn"><?php echo managePanelEscape($warning); ?></p>
+    <?php endforeach; ?>
+    <?php foreach ($errors as $error): ?>
+        <p class="mc-alert mc-err"><?php echo managePanelEscape($error); ?></p>
+    <?php endforeach; ?>
+
+    <?php if (!$status["configured"]): ?>
+        <p class="mc-alert mc-err">
+            Der Manage-Client ist nicht konfiguriert. In <code>manage-client/config.php</code> müssen
+            <code>MANAGE_SERVER_URL</code>, <code>MANAGE_INSTANCE</code> und <code>MANAGE_TOKEN</code> gesetzt sein.
+        </p>
+    <?php endif; ?>
+
+    <div class="mc-grid">
+        <div class="mc-card">
+            <p class="mc-label">Installierte Version</p>
+            <p class="mc-value"><?php echo managePanelEscape($status["version"] !== "" ? $status["version"] : "unbekannt"); ?></p>
+        </div>
+        <div class="mc-card">
+            <p class="mc-label">Aktuelles Release</p>
+            <p class="mc-value">
+                <?php echo managePanelEscape($status["update"]["latest"] ?? "–"); ?>
+            </p>
+            <?php if ($status["update_error"] !== null): ?>
+                <p class="mc-muted"><?php echo managePanelEscape($status["update_error"]); ?></p>
+            <?php endif; ?>
+        </div>
+        <div class="mc-card">
+            <p class="mc-label">Lokale Backups</p>
+            <p class="mc-value"><?php echo count($status["backups"]); ?></p>
+            <p class="mc-muted">Letztes: <?php echo managePanelEscape($status["last_backup_at"] ?? "nie"); ?></p>
+        </div>
+        <div class="mc-card">
+            <p class="mc-label">Offene Migrationen</p>
+            <p class="mc-value"><?php echo count($status["pending_migrations"]); ?></p>
+        </div>
+    </div>
+
+    <?php if ($status["update"] !== null && $status["update"]["available"]): ?>
+        <p class="mc-alert mc-warn">
+            Version <?php echo managePanelEscape($status["update"]["latest"]); ?> steht bereit.
+            Vor dem Ausrollen sollte ein aktuelles Backup vorliegen.
+        </p>
+    <?php endif; ?>
+
+    <h2>Aktionen</h2>
+    <div class="mc-row">
+        <form method="POST">
+            <input type="hidden" name="csrf_token" value="<?php echo managePanelEscape(managePanelCsrfToken()); ?>">
+            <input type="hidden" name="action" value="backup">
+            <button type="submit" class="mc-btn">Backup jetzt erstellen</button>
+        </form>
+
+        <form method="POST" onsubmit="return confirm('Update jetzt ausrollen? Dateien werden überschrieben.');">
+            <input type="hidden" name="csrf_token" value="<?php echo managePanelEscape(managePanelCsrfToken()); ?>">
+            <input type="hidden" name="action" value="update">
+            <label class="mc-muted">
+                <input type="checkbox" name="force" value="1"> erneut ausrollen
+            </label>
+            <button type="submit" class="mc-btn">Update ausrollen</button>
+        </form>
+
+        <?php if ($status["pending_migrations"] !== []): ?>
+            <form method="POST">
+                <input type="hidden" name="csrf_token" value="<?php echo managePanelEscape(managePanelCsrfToken()); ?>">
+                <input type="hidden" name="action" value="migrate">
+                <button type="submit" class="mc-btn sec">Migrationen ausführen</button>
+            </form>
+        <?php endif; ?>
+
+        <form method="POST">
+            <input type="hidden" name="csrf_token" value="<?php echo managePanelEscape(managePanelCsrfToken()); ?>">
+            <input type="hidden" name="action" value="heartbeat">
+            <button type="submit" class="mc-btn sec">Status melden</button>
+        </form>
+    </div>
+
+    <?php if ($status["pending_migrations"] !== []): ?>
+        <h2>Offene Migrationen</h2>
+        <ul>
+            <?php foreach ($status["pending_migrations"] as $migration): ?>
+                <li class="mc-mono"><?php echo managePanelEscape($migration["id"]); ?></li>
+            <?php endforeach; ?>
+        </ul>
+    <?php endif; ?>
+
+    <h2>Lokale Backups</h2>
+    <?php if ($status["backups"] === []): ?>
+        <p class="mc-muted">Es wurde noch kein Backup erstellt.</p>
+    <?php else: ?>
+        <div class="mc-scroll">
+            <table class="mc-table">
+                <thead>
+                <tr>
+                    <th>Datei</th>
+                    <th>Erstellt</th>
+                    <th>Auslöser</th>
+                    <th>Dateien</th>
+                    <th>Größe</th>
+                    <th>Upload</th>
+                    <th></th>
+                </tr>
+                </thead>
+                <tbody>
+                <?php foreach ($status["backups"] as $backup): ?>
+                    <tr>
+                        <td class="mc-mono"><?php echo managePanelEscape($backup["filename"] ?? ""); ?></td>
+                        <td><?php echo managePanelEscape($backup["created_at"] ?? ""); ?></td>
+                        <td><?php echo managePanelEscape($backup["trigger"] ?? ""); ?></td>
+                        <td><?php echo (int) ($backup["file_count"] ?? 0); ?></td>
+                        <td><?php echo managePanelEscape(manageFormatBytes((int) ($backup["size"] ?? 0))); ?></td>
+                        <td>
+                            <?php
+                            $uploads = is_array($backup["remote_uploads"] ?? null) ? $backup["remote_uploads"] : [];
+                            if ($uploads === []) {
+                                echo "–";
+                            } else {
+                                foreach ($uploads as $upload) {
+                                    $ok = !empty($upload["success"]);
+                                    echo managePanelEscape((string) ($upload["target"] ?? "?")) .
+                                        ": " . ($ok ? "OK" : "Fehler") . "<br>";
+                                }
+                            }
+                            ?>
+                        </td>
+                        <td>
+                            <form method="POST">
+                                <input type="hidden" name="csrf_token" value="<?php echo managePanelEscape(managePanelCsrfToken()); ?>">
+                                <input type="hidden" name="action" value="download">
+                                <input type="hidden" name="filename" value="<?php echo managePanelEscape($backup["filename"] ?? ""); ?>">
+                                <button type="submit" class="mc-btn sec">Herunterladen</button>
+                            </form>
+                        </td>
+                    </tr>
+                <?php endforeach; ?>
+                </tbody>
+            </table>
+        </div>
+    <?php endif; ?>
+
+    <?php
+    $capabilityWarnings = [];
+    foreach ($capabilities as $type => $capability) {
+        if ($capability["configured"] && !$capability["available"]) {
+            $capabilityWarnings[] = $type;
+        }
+    }
+    ?>
+    <?php if ($capabilityWarnings !== []): ?>
+        <p class="mc-alert mc-warn">
+            Konfigurierte Backup-Ziele ohne Systemunterstützung:
+            <?php echo managePanelEscape(implode(", ", $capabilityWarnings)); ?>.
+            Diese Uploads werden fehlschlagen.
+        </p>
+    <?php endif; ?>
+
+    <?php foreach ($status["errors"] as $error): ?>
+        <p class="mc-alert mc-warn"><?php echo managePanelEscape($error); ?></p>
+    <?php endforeach; ?>
+</div>
+</body>
+</html>

+ 60 - 0
manage-client/ui/status-partial.php

@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+// Small status block for an existing settings page in the host application.
+//
+// Include it wherever a short "update available / last backup" summary belongs:
+//
+//     <?php include __DIR__ . "/../manage-client/ui/status-partial.php"; ?>
+//
+// Renders nothing but a fragment: no <html>, no styles of its own beyond the
+// inline minimum, and it never throws. Set $manageStatusPanelUrl before the
+// include to link to the full panel.
+
+require_once dirname(__DIR__) . "/lib/client.php";
+
+/** @var string $manageStatusPanelUrl */
+$manageStatusPanelUrl = $manageStatusPanelUrl ?? "manage.php";
+
+$manageStatus = null;
+$manageStatusError = null;
+
+try {
+    $manageStatus = manageClientStatus();
+} catch (Throwable $exception) {
+    $manageStatusError = $exception->getMessage();
+}
+
+$manageStatusEscape = static function ($value): string {
+    return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
+};
+?>
+<div class="manage-status" style="border:1px solid #d9dde3;border-radius:8px;padding:.9rem 1rem;background:#fff;">
+    <?php if ($manageStatusError !== null): ?>
+        <p>Status nicht verfügbar: <?php echo $manageStatusEscape($manageStatusError); ?></p>
+    <?php elseif ($manageStatus === null): ?>
+        <p>Status nicht verfügbar.</p>
+    <?php else: ?>
+        <p>
+            <strong>Version:</strong>
+            <?php echo $manageStatusEscape($manageStatus["version"] !== "" ? $manageStatus["version"] : "unbekannt"); ?>
+            <?php if ($manageStatus["update"] !== null && $manageStatus["update"]["available"]): ?>
+                &mdash; <strong>Update <?php echo $manageStatusEscape($manageStatus["update"]["latest"]); ?> verfügbar</strong>
+            <?php elseif ($manageStatus["update"] !== null): ?>
+                &mdash; aktuell
+            <?php elseif ($manageStatus["update_error"] !== null): ?>
+                &mdash; <span title="<?php echo $manageStatusEscape($manageStatus["update_error"]); ?>">Update-Prüfung fehlgeschlagen</span>
+            <?php endif; ?>
+        </p>
+        <p>
+            <strong>Letztes Backup:</strong>
+            <?php echo $manageStatusEscape($manageStatus["last_backup_at"] ?? "nie"); ?>
+            (<?php echo count($manageStatus["backups"]); ?> lokal)
+        </p>
+        <?php if ($manageStatus["pending_migrations"] !== []): ?>
+            <p><strong><?php echo count($manageStatus["pending_migrations"]); ?> offene Migration(en).</strong></p>
+        <?php endif; ?>
+        <p><a href="<?php echo $manageStatusEscape($manageStatusPanelUrl); ?>">Update &amp; Backup verwalten</a></p>
+    <?php endif; ?>
+</div>

+ 0 - 0
migrations/.gitkeep


+ 189 - 0
scripts/create-release-zip.sh

@@ -0,0 +1,189 @@
+#!/usr/bin/env bash
+#
+# Builds a release package for a managed project.
+#
+# The product name, the version file and the exclude list are variables at the
+# top instead of hardcoded paths.
+#
+# It ships with the manage client package. Copy it into `scripts/` of the project
+# it builds, adjust the CONFIGURATION block, and run it from the project root:
+#
+#     ./scripts/create-release-zip.sh v1.3.0
+#
+# It writes the version into the version file, packs every git-tracked file
+# minus the exclusions, and prints the SHA-256 and size. Upload the resulting
+# ZIP in the manage server under "Releases".
+
+set -euo pipefail
+
+# --- CONFIGURATION ----------------------------------------------------------
+
+# Package name prefix. Must match MANAGE_PACKAGE_PREFIX on the manage server.
+PRODUCT="psa-orderform"
+
+# File holding the installed version, relative to the project root.
+VERSION_FILE="includes/version.php"
+
+# Name of the constant inside that file. Empty means a plain text file that
+# contains nothing but the version.
+VERSION_CONSTANT="APP_VERSION"
+
+# Output directory for built packages, relative to the project root.
+BUILD_DIR="build/releases"
+
+# Paths excluded from the package. Anything holding credentials or runtime data
+# of the target installation MUST be listed here.
+EXCLUDES=(
+    ".gitignore"
+    "config.php"
+    "manage-client/config.php"
+    "data/"
+    "build/"
+    "scripts/"
+    ".codex/"
+)
+
+# --- END CONFIGURATION ------------------------------------------------------
+
+usage() {
+    cat <<USAGE
+Usage: $(basename "$0") vX.Y.Z
+
+Builds ${BUILD_DIR}/${PRODUCT}-vX.Y.Z.zip from the git-tracked files of the
+current repository and writes the version into ${VERSION_FILE}.
+USAGE
+}
+
+VERSION="${1:-}"
+
+if [[ -z "$VERSION" || "$VERSION" == "-h" || "$VERSION" == "--help" ]]; then
+    usage
+    exit 1
+fi
+
+if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+    echo "Error: version must look like v1.3.0" >&2
+    exit 1
+fi
+
+for tool in git zip sed awk; do
+    if ! command -v "$tool" >/dev/null 2>&1; then
+        echo "Error: required tool not found: $tool" >&2
+        exit 1
+    fi
+done
+
+if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
+    echo "Error: not inside a git repository. Run this from the project root." >&2
+    exit 1
+fi
+
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "$REPO_ROOT"
+
+if [[ ! -f "$VERSION_FILE" ]]; then
+    echo "Error: version file not found: $VERSION_FILE" >&2
+    exit 1
+fi
+
+# Uncommitted changes would silently stay out of the package, because the file
+# list comes from git. Warn rather than refuse: building from a dirty tree is
+# sometimes deliberate.
+if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then
+    echo "Warning: the working tree has uncommitted changes." >&2
+    echo "         Only committed content is packaged." >&2
+fi
+
+# --- write the version ------------------------------------------------------
+
+write_version() {
+    if [[ -n "$VERSION_CONSTANT" ]]; then
+        # PHP file with a define(). Replace only the version literal.
+        sed -i.bak -E \
+            "s/(define\\(\\s*[\"']${VERSION_CONSTANT}[\"']\\s*,\\s*[\"'])[^\"']*([\"'])/\\1${VERSION}\\2/" \
+            "$VERSION_FILE"
+        rm -f "${VERSION_FILE}.bak"
+    else
+        printf '%s\n' "$VERSION" > "$VERSION_FILE"
+    fi
+}
+
+read_version() {
+    if [[ -n "$VERSION_CONSTANT" ]]; then
+        grep -oE "define\\(\\s*[\"']${VERSION_CONSTANT}[\"']\\s*,\\s*[\"']v[0-9]+\\.[0-9]+\\.[0-9]+[\"']" \
+            "$VERSION_FILE" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1
+    else
+        tr -d '[:space:]' < "$VERSION_FILE"
+    fi
+}
+
+write_version
+
+WRITTEN="$(read_version)"
+if [[ "$WRITTEN" != "$VERSION" ]]; then
+    echo "Error: could not write the version into $VERSION_FILE (found: '${WRITTEN}')." >&2
+    echo "       Check VERSION_CONSTANT and the file's format." >&2
+    exit 1
+fi
+
+echo "Version written to ${VERSION_FILE}: ${VERSION}"
+
+# --- collect the files ------------------------------------------------------
+
+is_excluded() {
+    local path="$1"
+    local pattern
+    for pattern in "${EXCLUDES[@]}"; do
+        if [[ "$pattern" == */ ]]; then
+            [[ "$path" == "${pattern}"* ]] && return 0
+        else
+            [[ "$path" == "$pattern" ]] && return 0
+        fi
+    done
+    return 1
+}
+
+FILE_LIST="$(mktemp)"
+trap 'rm -f "$FILE_LIST"' EXIT
+
+COUNT=0
+while IFS= read -r path; do
+    if is_excluded "$path"; then
+        continue
+    fi
+    [[ -f "$path" ]] || continue
+    printf '%s\n' "$path" >> "$FILE_LIST"
+    COUNT=$((COUNT + 1))
+done < <(git ls-files)
+
+if [[ "$COUNT" -eq 0 ]]; then
+    echo "Error: no files to package." >&2
+    exit 1
+fi
+
+# --- build ------------------------------------------------------------------
+
+mkdir -p "$BUILD_DIR"
+ARCHIVE="${BUILD_DIR}/${PRODUCT}-${VERSION}.zip"
+rm -f "$ARCHIVE"
+
+zip -q -X "$ARCHIVE" -@ < "$FILE_LIST"
+
+if command -v sha256sum >/dev/null 2>&1; then
+    SHA="$(sha256sum "$ARCHIVE" | awk '{print $1}')"
+else
+    SHA="$(shasum -a 256 "$ARCHIVE" | awk '{print $1}')"
+fi
+
+SIZE="$(wc -c < "$ARCHIVE" | tr -d '[:space:]')"
+
+cat <<SUMMARY
+
+Package:  ${ARCHIVE}
+Files:    ${COUNT}
+Size:     ${SIZE} bytes
+SHA-256:  ${SHA}
+
+Next: upload it in the manage server under "Releases" with version ${VERSION}.
+      Checksum and size are recomputed there; the values above are for checking.
+SUMMARY

+ 0 - 129
scripts/create-update-zip.sh

@@ -1,129 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-die() {
-    printf 'Error: %s\n' "$*" >&2
-    exit 1
-}
-
-require_command() {
-    command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1"
-}
-
-is_excluded_path() {
-    case "$1" in
-        .gitignore|config.php|data|data/*|update-server|update-server/*|.codex|.codex/*|build|build/*|scripts|scripts/*|backup-server|backup-server/*)
-            return 0
-            ;;
-    esac
-
-    return 1
-}
-
-read_current_version() {
-    sed -nE 's/.*define\(["'\'']APP_VERSION["'\''],[[:space:]]*["'\'']([^"'\'']+)["'\'']\).*/\1/p' "$version_file" | head -n 1
-}
-
-write_current_version() {
-    local version="$1"
-    local tmp_version_file
-
-    # No "sed -i": GNU requires no argument, BSD/macOS requires one.
-    tmp_version_file="$(mktemp)"
-    sed -E "s/define\\([\"']APP_VERSION[\"'],[[:space:]]*[\"'][^\"']+[\"']\\);/define(\"APP_VERSION\", \"${version}\");/" \
-        "$version_file" > "$tmp_version_file" ||
-        { rm -f "$tmp_version_file"; die "Version string could not be updated in $version_file"; }
-
-    cat "$tmp_version_file" > "$version_file" ||
-        { rm -f "$tmp_version_file"; die "Version string could not be written to $version_file"; }
-    rm -f "$tmp_version_file"
-
-    [[ "$(read_current_version)" == "$version" ]] || die "Version string could not be updated in $version_file"
-}
-
-sha256_file() {
-    local file="$1"
-
-    if command -v sha256sum >/dev/null 2>&1; then
-        sha256sum "$file" | awk '{print $1}'
-    elif command -v shasum >/dev/null 2>&1; then
-        shasum -a 256 "$file" | awk '{print $1}'
-    else
-        die "Required command not found: sha256sum or shasum"
-    fi
-}
-
-file_size() {
-    local file="$1"
-
-    # GNU stat uses -c, BSD/macOS stat uses -f.
-    stat -c '%s' "$file" 2>/dev/null || stat -f '%z' "$file"
-}
-
-require_command git
-require_command zip
-require_command sed
-require_command awk
-
-repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "Not inside a git repository."
-current_dir="$(pwd -P)"
-repo_root_real="$(cd "$repo_root" && pwd -P)"
-
-[[ "$current_dir" == "$repo_root_real" ]] || die "Run this script from the repository root: $repo_root_real"
-
-[[ "$#" -le 1 ]] || die "Usage: $0 [vX.Y.Z]"
-
-version_file='includes/version.php'
-[[ -f "$version_file" ]] || die "Version file not found: $version_file"
-
-current_version="$(read_current_version)"
-[[ -n "$current_version" ]] || die "Current version could not be read from $version_file"
-
-if [[ "$#" -eq 1 ]]; then
-    version="$1"
-else
-    printf 'Current version: %s\n' "$current_version"
-    printf 'New version (vX.Y.Z): '
-    IFS= read -r version || die "No version provided."
-fi
-
-[[ -n "$version" ]] || die "No version provided."
-[[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "Version must use the format vX.Y.Z."
-
-write_current_version "$version"
-
-output_dir='build/updates'
-output_file="${output_dir}/psa-orderform-${version}.zip"
-tmp_file_list="$(mktemp)"
-
-cleanup() {
-    rm -f "$tmp_file_list"
-}
-trap cleanup EXIT
-
-while IFS= read -r path; do
-    [[ -n "$path" ]] || continue
-    is_excluded_path "$path" && continue
-    [[ -f "$path" ]] || continue
-    printf '%s\n' "$path" >> "$tmp_file_list"
-done < <(git ls-files)
-
-file_count="$(wc -l < "$tmp_file_list" | tr -d '[:space:]')"
-[[ "$file_count" -gt 0 ]] || die "No files selected for the update archive."
-
-mkdir -p "$output_dir"
-rm -f "$output_file"
-
-zip -q -@ "$output_file" < "$tmp_file_list"
-[[ -f "$output_file" ]] || die "ZIP archive was not created."
-
-checksum="$(sha256_file "$output_file")"
-size="$(file_size "$output_file")"
-
-printf 'Update ZIP created\n'
-printf 'Version: %s\n' "$version"
-printf 'Version file: %s\n' "$version_file"
-printf 'Path: %s\n' "$output_file"
-printf 'Files: %s\n' "$file_count"
-printf 'SHA-256: %s\n' "$checksum"
-printf 'Size: %s bytes\n' "$size"

+ 0 - 14
update-server/.htaccess

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

+ 0 - 41
update-server/README.md

@@ -1,41 +0,0 @@
-# PSA Orderform Update Server
-
-> **DEPRECATED** — This component is deprecated and no longer maintained. It is
-> kept in the repository for reference only and will be removed in a future
-> release. Do not deploy it for new installations. Existing deployments keep
-> working, but no fixes or features are planned.
-
-This folder can be deployed as the central update server.
-
-Release ZIPs can be managed through `manage.php`:
-
-1. Copy `config.sample.php` to `config.php`.
-2. Change `UPDATE_SERVER_PASSWORD`.
-3. Open `index.php` or `manage.php`, log in with the configured password, and upload a ZIP with a `vX.Y.Z` version.
-
-The management UI stores the ZIP under `packages/`, calculates SHA-256 and size, and updates `manifest.json`.
-
-Manual release management is also possible:
-
-1. Put the package under `packages/`, for example `packages/psa-orderform-v1.3.3.zip`.
-2. Calculate its SHA-256 checksum and byte size.
-3. Edit `manifest.json` and set `latest` to the version clients should install.
-
-Example release entry:
-
-```json
-{
-    "latest": "v1.3.3",
-    "releases": {
-        "v1.3.3": {
-            "version": "v1.3.3",
-            "package": "packages/psa-orderform-v1.3.3.zip",
-            "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
-            "size": 123456,
-            "published_at": "2026-06-23T00:00:00+00:00"
-        }
-    }
-}
-```
-
-Clients should use `manifest.php`, not `manifest.json`, as their `UPDATE_MANIFEST_URL`.

+ 0 - 11
update-server/config.sample.php

@@ -1,11 +0,0 @@
-<?php
-
-// DEPRECATED: The bundled update server is deprecated and unmaintained.
-// It is kept for reference only and will be removed in a future release.
-// Do not deploy it for new installations.
-
-// Copy this file to config.php and change the password before deploying.
-define("UPDATE_SERVER_PASSWORD", "change-me");
-
-// Optional stronger alternative:
-// define("UPDATE_SERVER_PASSWORD_HASH", "$2y$10$replace-this-with-a-precomputed-password-hash");

+ 0 - 8
update-server/index.php

@@ -1,8 +0,0 @@
-<?php
-
-// DEPRECATED: The bundled update server is deprecated and unmaintained.
-// It is kept for reference only and will be removed in a future release.
-// Do not deploy it for new installations.
-
-header("Location: manage.php");
-exit;

+ 0 - 405
update-server/manage.php

@@ -1,405 +0,0 @@
-<?php
-
-// DEPRECATED: The bundled update server is deprecated and unmaintained.
-// It is kept for reference only and will be removed in a future release.
-// Do not deploy it for new installations.
-
-declare(strict_types=1);
-
-$baseDir = __DIR__;
-$configFile = $baseDir . "/config.php";
-$manifestFile = $baseDir . "/manifest.json";
-$packagesDir = $baseDir . "/packages";
-
-if (is_file($configFile)) {
-    require_once $configFile;
-}
-
-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 updateManageEscape($value): string
-{
-    return htmlspecialchars((string) $value, ENT_QUOTES, "UTF-8");
-}
-
-function updateManageVersionIsValid(string $version): bool
-{
-    return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
-}
-
-function updateManagePasswordConfigured(): bool
-{
-    return defined("UPDATE_SERVER_PASSWORD_HASH") || defined("UPDATE_SERVER_PASSWORD");
-}
-
-function updateManagePasswordMatches(string $password): bool
-{
-    if (defined("UPDATE_SERVER_PASSWORD_HASH")) {
-        return password_verify($password, (string) UPDATE_SERVER_PASSWORD_HASH);
-    }
-
-    if (defined("UPDATE_SERVER_PASSWORD")) {
-        return hash_equals((string) UPDATE_SERVER_PASSWORD, $password);
-    }
-
-    return false;
-}
-
-function updateManageIsLoggedIn(): bool
-{
-    return !empty($_SESSION["update_server_logged_in"]);
-}
-
-function updateManageCsrfToken(): string
-{
-    if (empty($_SESSION["update_server_csrf_token"])) {
-        $_SESSION["update_server_csrf_token"] = bin2hex(random_bytes(32));
-    }
-
-    return $_SESSION["update_server_csrf_token"];
-}
-
-function updateManageCsrfIsValid(string $token): bool
-{
-    return !empty($_SESSION["update_server_csrf_token"]) &&
-        hash_equals($_SESSION["update_server_csrf_token"], $token);
-}
-
-function updateManageEnsureDirectory(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 updateManageReadManifest(string $manifestFile): array
-{
-    if (!is_file($manifestFile)) {
-        return ["latest" => "", "releases" => []];
-    }
-
-    $decoded = json_decode((string) file_get_contents($manifestFile), true);
-    if (!is_array($decoded)) {
-        throw new RuntimeException("Manifest is not valid JSON.");
-    }
-
-    return [
-        "latest" => trim((string) ($decoded["latest"] ?? "")),
-        "releases" => isset($decoded["releases"]) && is_array($decoded["releases"])
-            ? $decoded["releases"]
-            : [],
-    ];
-}
-
-function updateManageWriteManifest(string $manifestFile, array $manifest): void
-{
-    $json = json_encode(
-        $manifest,
-        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
-    );
-    if ($json === false) {
-        throw new RuntimeException("Manifest cannot be encoded.");
-    }
-
-    $tmpFile = $manifestFile . ".tmp";
-    if (file_put_contents($tmpFile, $json . PHP_EOL, LOCK_EX) === false) {
-        throw new RuntimeException("Manifest cannot be written.");
-    }
-
-    @chmod($tmpFile, 0664);
-    if (!rename($tmpFile, $manifestFile)) {
-        @unlink($tmpFile);
-        throw new RuntimeException("Manifest cannot be saved.");
-    }
-
-    @chmod($manifestFile, 0664);
-}
-
-function updateManagePackageFileName(string $version): string
-{
-    return "psa-orderform-" . $version . ".zip";
-}
-
-function updateManageUploadedFileIsZip(array $file): bool
-{
-    $name = strtolower((string) ($file["name"] ?? ""));
-    $tmpName = (string) ($file["tmp_name"] ?? "");
-
-    if (!str_ends_with($name, ".zip") || !is_uploaded_file($tmpName)) {
-        return false;
-    }
-
-    $handle = fopen($tmpName, "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 updateManagePublishUpload(
-    string $version,
-    array $file,
-    string $manifestFile,
-    string $packagesDir,
-): void {
-    if (!updateManageVersionIsValid($version)) {
-        throw new RuntimeException("Version must use the format vX.Y.Z.");
-    }
-
-    if (($file["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
-        throw new RuntimeException("Upload failed with error code " . (string) ($file["error"] ?? "unknown") . ".");
-    }
-
-    if (!updateManageUploadedFileIsZip($file)) {
-        throw new RuntimeException("Uploaded file must be a ZIP package.");
-    }
-
-    updateManageEnsureDirectory($packagesDir);
-
-    $fileName = updateManagePackageFileName($version);
-    $targetPath = $packagesDir . DIRECTORY_SEPARATOR . $fileName;
-    if (!move_uploaded_file((string) $file["tmp_name"], $targetPath)) {
-        throw new RuntimeException("Uploaded package cannot be stored.");
-    }
-
-    @chmod($targetPath, 0664);
-
-    $sha256 = strtolower(hash_file("sha256", $targetPath) ?: "");
-    $size = filesize($targetPath);
-    if (!preg_match('/^[a-f0-9]{64}$/', $sha256) || $size === false || $size <= 0) {
-        @unlink($targetPath);
-        throw new RuntimeException("Stored package could not be verified.");
-    }
-
-    $manifest = updateManageReadManifest($manifestFile);
-    $manifest["latest"] = $version;
-    $manifest["releases"][$version] = [
-        "version" => $version,
-        "package" => "packages/" . $fileName,
-        "sha256" => $sha256,
-        "size" => $size,
-        "published_at" => date(DATE_ATOM),
-    ];
-
-    ksort($manifest["releases"]);
-    updateManageWriteManifest($manifestFile, $manifest);
-}
-
-function updateManageSetLatest(string $version, string $manifestFile): void
-{
-    if (!updateManageVersionIsValid($version)) {
-        throw new RuntimeException("Invalid release version.");
-    }
-
-    $manifest = updateManageReadManifest($manifestFile);
-    if (!isset($manifest["releases"][$version])) {
-        throw new RuntimeException("Release is not present in the manifest.");
-    }
-
-    $manifest["latest"] = $version;
-    updateManageWriteManifest($manifestFile, $manifest);
-}
-
-function updateManageDeleteRelease(
-    string $version,
-    string $manifestFile,
-    string $baseDir,
-): void {
-    if (!updateManageVersionIsValid($version)) {
-        throw new RuntimeException("Invalid release version.");
-    }
-
-    $manifest = updateManageReadManifest($manifestFile);
-    if (!isset($manifest["releases"][$version])) {
-        throw new RuntimeException("Release is not present in the manifest.");
-    }
-
-    $package = trim((string) ($manifest["releases"][$version]["package"] ?? ""));
-    unset($manifest["releases"][$version]);
-    if ($manifest["latest"] === $version) {
-        $manifest["latest"] = "";
-    }
-
-    updateManageWriteManifest($manifestFile, $manifest);
-
-    if ($package !== "" && !str_contains($package, "\0") && !str_starts_with($package, "/")) {
-        $packagePath = realpath($baseDir . "/" . $package);
-        $packagesPath = realpath($baseDir . "/packages");
-        if (
-            $packagePath !== false &&
-            $packagesPath !== false &&
-            str_starts_with($packagePath, $packagesPath . DIRECTORY_SEPARATOR) &&
-            is_file($packagePath)
-        ) {
-            unlink($packagePath);
-        }
-    }
-}
-
-if ($_SERVER["REQUEST_METHOD"] === "POST") {
-    $action = (string) ($_POST["action"] ?? "");
-
-    if ($action === "login") {
-        if (!updateManagePasswordConfigured()) {
-            $errors[] = "No password is configured.";
-        } elseif (updateManagePasswordMatches((string) ($_POST["password"] ?? ""))) {
-            session_regenerate_id(true);
-            $_SESSION["update_server_logged_in"] = true;
-            $messages[] = "Logged in.";
-        } else {
-            $errors[] = "Wrong password.";
-        }
-    } elseif ($action === "logout") {
-        unset($_SESSION["update_server_logged_in"], $_SESSION["update_server_csrf_token"]);
-        $messages[] = "Logged out.";
-    } elseif (!updateManageIsLoggedIn()) {
-        $errors[] = "Login required.";
-    } elseif (!updateManageCsrfIsValid((string) ($_POST["csrf_token"] ?? ""))) {
-        $errors[] = "Invalid token. Please reload the page and try again.";
-    } else {
-        try {
-            if ($action === "upload") {
-                updateManagePublishUpload(
-                    trim((string) ($_POST["version"] ?? "")),
-                    $_FILES["package"] ?? [],
-                    $manifestFile,
-                    $packagesDir,
-                );
-                $messages[] = "Release uploaded and published.";
-            } elseif ($action === "set_latest") {
-                updateManageSetLatest(trim((string) ($_POST["version"] ?? "")), $manifestFile);
-                $messages[] = "Latest release updated.";
-            } elseif ($action === "delete") {
-                updateManageDeleteRelease(trim((string) ($_POST["version"] ?? "")), $manifestFile, $baseDir);
-                $messages[] = "Release deleted.";
-            }
-        } catch (Throwable $exception) {
-            $errors[] = $exception->getMessage();
-        }
-    }
-}
-
-try {
-    $manifest = updateManageReadManifest($manifestFile);
-} catch (Throwable $exception) {
-    $manifest = ["latest" => "", "releases" => []];
-    $errors[] = $exception->getMessage();
-}
-
-$releases = $manifest["releases"];
-krsort($releases);
-
-?>
-<!DOCTYPE html>
-<html lang="de">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>Update Management</title>
-</head>
-<body>
-    <h1>Update Management</h1>
-    <p><strong>Deprecated:</strong> This update server is no longer maintained and will be removed in a future release.</p>
-
-    <?php foreach ($messages as $message): ?>
-        <p><strong><?php echo updateManageEscape($message); ?></strong></p>
-    <?php endforeach; ?>
-
-    <?php foreach ($errors as $error): ?>
-        <p><strong>Error:</strong> <?php echo updateManageEscape($error); ?></p>
-    <?php endforeach; ?>
-
-    <?php if (!updateManageIsLoggedIn()): ?>
-        <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>Upload release</h2>
-        <form method="POST" enctype="multipart/form-data">
-            <input type="hidden" name="action" value="upload">
-            <input type="hidden" name="csrf_token" value="<?php echo updateManageEscape(updateManageCsrfToken()); ?>">
-            <p>
-                <label for="version">Version</label><br>
-                <input type="text" id="version" name="version" required placeholder="v1.3.3" pattern="v[0-9]+\.[0-9]+\.[0-9]+">
-            </p>
-            <p>
-                <label for="package">ZIP package</label><br>
-                <input type="file" id="package" name="package" accept=".zip,application/zip" required>
-            </p>
-            <button type="submit">Upload and publish</button>
-        </form>
-
-        <h2>Current manifest</h2>
-        <p>Latest: <?php echo updateManageEscape($manifest["latest"] !== "" ? $manifest["latest"] : "none"); ?></p>
-        <p>Manifest endpoint: <a href="manifest.php">manifest.php</a></p>
-
-        <?php if (empty($releases)): ?>
-            <p>No releases configured.</p>
-        <?php else: ?>
-            <table border="1" cellpadding="6" cellspacing="0">
-                <thead>
-                    <tr>
-                        <th>Version</th>
-                        <th>Package</th>
-                        <th>SHA-256</th>
-                        <th>Size</th>
-                        <th>Published</th>
-                        <th>Actions</th>
-                    </tr>
-                </thead>
-                <tbody>
-                    <?php foreach ($releases as $version => $release): ?>
-                        <tr>
-                            <td><?php echo updateManageEscape($version); ?></td>
-                            <td><?php echo updateManageEscape($release["package"] ?? ""); ?></td>
-                            <td><?php echo updateManageEscape($release["sha256"] ?? ""); ?></td>
-                            <td><?php echo updateManageEscape($release["size"] ?? ""); ?></td>
-                            <td><?php echo updateManageEscape($release["published_at"] ?? ""); ?></td>
-                            <td>
-                                <?php if ($manifest["latest"] !== $version): ?>
-                                    <form method="POST" style="display:inline">
-                                        <input type="hidden" name="action" value="set_latest">
-                                        <input type="hidden" name="csrf_token" value="<?php echo updateManageEscape(updateManageCsrfToken()); ?>">
-                                        <input type="hidden" name="version" value="<?php echo updateManageEscape($version); ?>">
-                                        <button type="submit">Set latest</button>
-                                    </form>
-                                <?php endif; ?>
-                                <form method="POST" style="display:inline" onsubmit="return confirm('Delete this release?');">
-                                    <input type="hidden" name="action" value="delete">
-                                    <input type="hidden" name="csrf_token" value="<?php echo updateManageEscape(updateManageCsrfToken()); ?>">
-                                    <input type="hidden" name="version" value="<?php echo updateManageEscape($version); ?>">
-                                    <button type="submit">Delete</button>
-                                </form>
-                            </td>
-                        </tr>
-                    <?php endforeach; ?>
-                </tbody>
-            </table>
-        <?php endif; ?>
-    <?php endif; ?>
-</body>
-</html>

+ 0 - 4
update-server/manifest.json

@@ -1,4 +0,0 @@
-{
-    "latest": "",
-    "releases": {}
-}

+ 0 - 115
update-server/manifest.php

@@ -1,115 +0,0 @@
-<?php
-
-// DEPRECATED: The bundled update server is deprecated and unmaintained.
-// It is kept for reference only and will be removed in a future release.
-// Do not deploy it for new installations.
-
-declare(strict_types=1);
-
-header("Content-Type: application/json; charset=utf-8");
-header("Cache-Control: no-store");
-
-$baseDir = __DIR__;
-$manifestFile = $baseDir . "/manifest.json";
-
-function updateServerRespond(int $status, array $payload): void
-{
-    http_response_code($status);
-    echo json_encode(
-        $payload,
-        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
-    );
-    exit;
-}
-
-function updateServerValidateVersion(string $version): bool
-{
-    return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
-}
-
-function updateServerNormalizePackageUrl(string $version): string
-{
-    $scheme = "http";
-    if (
-        (!empty($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] !== "off") ||
-        (isset($_SERVER["SERVER_PORT"]) && (int) $_SERVER["SERVER_PORT"] === 443) ||
-        strtolower((string) ($_SERVER["HTTP_X_FORWARDED_PROTO"] ?? "")) === "https"
-    ) {
-        $scheme = "https";
-    }
-
-    $host = $_SERVER["HTTP_HOST"] ?? "localhost";
-    $scriptDir = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/");
-
-    return $scheme .
-        "://" .
-        $host .
-        ($scriptDir === "" ? "" : $scriptDir) .
-        "/package.php?version=" .
-        rawurlencode($version);
-}
-
-if (!is_file($manifestFile)) {
-    updateServerRespond(500, ["error" => "Manifest file is missing."]);
-}
-
-$decoded = json_decode((string) file_get_contents($manifestFile), true);
-if (!is_array($decoded)) {
-    updateServerRespond(500, ["error" => "Manifest file is not valid JSON."]);
-}
-
-$latest = trim((string) ($decoded["latest"] ?? ""));
-$releases = isset($decoded["releases"]) && is_array($decoded["releases"])
-    ? $decoded["releases"]
-    : [];
-
-if ($latest === "" || !updateServerValidateVersion($latest)) {
-    updateServerRespond(404, ["error" => "No valid latest release is configured."]);
-}
-
-if (!isset($releases[$latest]) || !is_array($releases[$latest])) {
-    updateServerRespond(404, ["error" => "Latest release entry is missing."]);
-}
-
-$release = $releases[$latest];
-$version = trim((string) ($release["version"] ?? $latest));
-$package = trim((string) ($release["package"] ?? ""));
-$sha256 = strtolower(trim((string) ($release["sha256"] ?? "")));
-$publishedAt = trim((string) ($release["published_at"] ?? ""));
-$size = isset($release["size"]) ? (int) $release["size"] : 0;
-
-if ($version !== $latest || !updateServerValidateVersion($version)) {
-    updateServerRespond(500, ["error" => "Latest release version is invalid."]);
-}
-
-if ($package === "" || str_contains($package, "\0") || str_starts_with($package, "/")) {
-    updateServerRespond(500, ["error" => "Latest release package path is invalid."]);
-}
-
-if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
-    updateServerRespond(500, ["error" => "Latest release checksum is invalid."]);
-}
-
-$packagePath = realpath($baseDir . "/" . $package);
-$packagesDir = realpath($baseDir . "/packages");
-if (
-    $packagePath === false ||
-    $packagesDir === false ||
-    !str_starts_with($packagePath, $packagesDir . DIRECTORY_SEPARATOR) ||
-    !is_file($packagePath)
-) {
-    updateServerRespond(404, ["error" => "Latest release package is missing."]);
-}
-
-if ($size <= 0) {
-    $size = filesize($packagePath) ?: 0;
-}
-
-updateServerRespond(200, [
-    "latest" => $latest,
-    "version" => $version,
-    "package_url" => updateServerNormalizePackageUrl($version),
-    "sha256" => $sha256,
-    "size" => $size,
-    "published_at" => $publishedAt,
-]);

+ 0 - 89
update-server/package.php

@@ -1,89 +0,0 @@
-<?php
-
-// DEPRECATED: The bundled update server is deprecated and unmaintained.
-// It is kept for reference only and will be removed in a future release.
-// Do not deploy it for new installations.
-
-declare(strict_types=1);
-
-$baseDir = __DIR__;
-$manifestFile = $baseDir . "/manifest.json";
-
-function updateServerPackageError(int $status, string $message): void
-{
-    http_response_code($status);
-    header("Content-Type: text/plain; charset=utf-8");
-    echo $message;
-    exit;
-}
-
-function updateServerPackageValidVersion(string $version): bool
-{
-    return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
-}
-
-$version = trim((string) ($_GET["version"] ?? ""));
-if (!updateServerPackageValidVersion($version)) {
-    updateServerPackageError(400, "Invalid version.");
-}
-
-if (!is_file($manifestFile)) {
-    updateServerPackageError(500, "Manifest file is missing.");
-}
-
-$decoded = json_decode((string) file_get_contents($manifestFile), true);
-if (!is_array($decoded)) {
-    updateServerPackageError(500, "Manifest file is not valid JSON.");
-}
-
-$releases = isset($decoded["releases"]) && is_array($decoded["releases"])
-    ? $decoded["releases"]
-    : [];
-if (!isset($releases[$version]) || !is_array($releases[$version])) {
-    updateServerPackageError(404, "Release is not configured.");
-}
-
-$release = $releases[$version];
-$releaseVersion = trim((string) ($release["version"] ?? $version));
-$sha256 = strtolower(trim((string) ($release["sha256"] ?? "")));
-$package = trim((string) ($release["package"] ?? ""));
-if ($releaseVersion !== $version || !updateServerPackageValidVersion($releaseVersion)) {
-    updateServerPackageError(500, "Release version is invalid.");
-}
-if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
-    updateServerPackageError(500, "Release checksum is invalid.");
-}
-if ($package === "" || str_contains($package, "\0") || str_starts_with($package, "/")) {
-    updateServerPackageError(500, "Release package path is invalid.");
-}
-
-$packagePath = realpath($baseDir . "/" . $package);
-$packagesDir = realpath($baseDir . "/packages");
-if (
-    $packagePath === false ||
-    $packagesDir === false ||
-    !str_starts_with($packagePath, $packagesDir . DIRECTORY_SEPARATOR) ||
-    !is_file($packagePath)
-) {
-    updateServerPackageError(404, "Release package is missing.");
-}
-
-if (strtolower(pathinfo($packagePath, PATHINFO_EXTENSION)) !== "zip") {
-    updateServerPackageError(500, "Release package is not a ZIP file.");
-}
-
-$fileName = basename($packagePath);
-$fileSize = filesize($packagePath);
-$handle = fopen($packagePath, "rb");
-if ($fileSize === false || $handle === false) {
-    updateServerPackageError(500, "Release package cannot be opened.");
-}
-
-header("Content-Type: application/zip");
-header("Content-Disposition: attachment; filename=\"" . addcslashes($fileName, "\"\\") . "\"");
-header("Content-Length: " . (string) $fileSize);
-header("Cache-Control: public, max-age=300");
-header("X-Content-Type-Options: nosniff");
-
-fpassthru($handle);
-fclose($handle);

+ 0 - 2
update-server/packages/.gitignore

@@ -1,2 +0,0 @@
-*.zip
-!.gitkeep

+ 0 - 1
update-server/packages/.gitkeep

@@ -1 +0,0 @@
-