Bläddra i källkod

initial commit

Medowar 1 månad sedan
incheckning
90e0d37438
73 ändrade filer med 11814 tillägg och 0 borttagningar
  1. 6 0
      .gitignore
  2. 46 0
      .htaccess
  3. 99 0
      README.md
  4. 153 0
      admin/backups.php
  5. 163 0
      admin/index.php
  6. 179 0
      admin/instances.php
  7. 81 0
      admin/login.php
  8. 16 0
      admin/logout.php
  9. 171 0
      admin/releases.php
  10. 104 0
      admin/settings.php
  11. 76 0
      api/v1/backup.php
  12. 58 0
      api/v1/heartbeat.php
  13. 42 0
      api/v1/manifest.php
  14. 47 0
      api/v1/package.php
  15. 508 0
      assets/css/style.css
  16. 122 0
      client-package/README.md
  17. 2 0
      client-package/docs/.htaccess
  18. 93 0
      client-package/docs/01_QUICKSTART.md
  19. 141 0
      client-package/docs/02_INTEGRATION.md
  20. 119 0
      client-package/docs/03_CONFIG_REFERENCE.md
  21. 230 0
      client-package/docs/04_FUNCTION_API.md
  22. 219 0
      client-package/docs/05_BACKUP_SOURCES.md
  23. 131 0
      client-package/docs/06_UPDATE_PACKAGING.md
  24. 234 0
      client-package/docs/07_POST_UPDATE_HOOKS.md
  25. 171 0
      client-package/docs/08_PROTOCOL.md
  26. 225 0
      client-package/docs/09_TROUBLESHOOTING.md
  27. 139 0
      client-package/docs/10_SECURITY.md
  28. 200 0
      client-package/docs/assets/docs.css
  29. 11 0
      client-package/docs/assets/marked.min.js
  30. 151 0
      client-package/docs/index.php
  31. 54 0
      client-package/examples/after-update.php
  32. 28 0
      client-package/examples/cron/manage-client.cron
  33. 48 0
      client-package/examples/flat-file-project/migrations/2026-08-20-01-add-category-id.php
  34. 54 0
      client-package/examples/integration-snippet.php
  35. 34 0
      client-package/examples/mysql-project/migrations/2026-08-20-01-add-orders-index.php
  36. 21 0
      client-package/manage-client/.htaccess
  37. 296 0
      client-package/manage-client/bin/manage-client.php
  38. 114 0
      client-package/manage-client/config.sample.php
  39. 520 0
      client-package/manage-client/lib/backup.php
  40. 545 0
      client-package/manage-client/lib/client.php
  41. 94 0
      client-package/manage-client/lib/heartbeat.php
  42. 309 0
      client-package/manage-client/lib/hooks.php
  43. 235 0
      client-package/manage-client/lib/mysql.php
  44. 367 0
      client-package/manage-client/lib/remote.php
  45. 424 0
      client-package/manage-client/lib/updater.php
  46. 330 0
      client-package/manage-client/lib/zip.php
  47. 348 0
      client-package/manage-client/ui/panel.php
  48. 60 0
      client-package/manage-client/ui/status-partial.php
  49. 85 0
      config.sample.php
  50. 2 0
      docs/.htaccess
  51. 186 0
      docs/ARCHITECTURE.md
  52. 130 0
      docs/CONFIG_REFERENCE.md
  53. 107 0
      docs/INSTANCE_MANAGEMENT.md
  54. 108 0
      docs/MIGRATION_PSA.md
  55. 96 0
      docs/RELEASING.md
  56. 181 0
      docs/SERVER_SETUP.md
  57. 200 0
      docs/assets/docs.css
  58. 11 0
      docs/assets/marked.min.js
  59. 151 0
      docs/index.php
  60. 128 0
      includes/api.php
  61. 123 0
      includes/auth.php
  62. 616 0
      includes/backups.php
  63. 171 0
      includes/bootstrap.php
  64. 270 0
      includes/instances.php
  65. 78 0
      includes/layout.php
  66. 137 0
      includes/log.php
  67. 174 0
      includes/ratelimit.php
  68. 279 0
      includes/releases.php
  69. 346 0
      includes/s3.php
  70. 107 0
      includes/storage.php
  71. 6 0
      index.php
  72. 114 0
      scripts/build-client-package.sh
  73. 190 0
      scripts/create-release-zip.sh

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+config.php
+storage/
+build/
+.codex
+.idea
+*.tmp

+ 46 - 0
.htaccess

@@ -0,0 +1,46 @@
+Options -Indexes
+
+<IfModule mod_setenvif.c>
+    SetEnvIf HTTPS "on" HTTPS_ON=1
+    SetEnvIf X-Forwarded-Proto "^https$" HTTPS_ON=1
+</IfModule>
+
+<IfModule mod_headers.c>
+    Header always set X-Content-Type-Options "nosniff"
+    Header always set X-Frame-Options "SAMEORIGIN"
+    Header always set Referrer-Policy "strict-origin-when-cross-origin"
+    Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
+    Header always set Cross-Origin-Resource-Policy "same-origin"
+    Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none'; upgrade-insecure-requests"
+    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" env=HTTPS_ON
+</IfModule>
+
+<IfModule mod_rewrite.c>
+    RewriteEngine On
+
+    # Block hidden files/folders except ACME challenge path.
+    RewriteRule "(^|/)\.(?!well-known/)" - [F]
+
+    # Deny direct access to the whole storage tree. Packages and backups are
+    # only ever served through the authenticated admin UI or the token-gated API.
+    RewriteRule ^storage(?:/|$) - [F,L]
+
+    # Deny direct access to the shared server includes.
+    RewriteRule ^includes(?:/|$) - [F,L]
+
+    # The client package is handed out as a ZIP, not browsed over HTTP.
+    RewriteRule ^client-package(?:/|$) - [F,L]
+</IfModule>
+
+<IfModule mod_authz_core.c>
+    <FilesMatch "^(config\.php|.*\.(json|zip|log|md))$">
+        Require all denied
+    </FilesMatch>
+</IfModule>
+
+<IfModule !mod_authz_core.c>
+    <FilesMatch "^(config\.php|.*\.(json|zip|log|md))$">
+        Order allow,deny
+        Deny from all
+    </FilesMatch>
+</IfModule>

+ 99 - 0
README.md

@@ -0,0 +1,99 @@
+# Manage
+
+Update- und Backup-Server für PHP-Projekte, plus ein weitergebbares Client-Paket.
+
+## Zweck
+
+Mehrere eigenständige PHP-Projekte brauchen dieselben zwei Dinge: eine Möglichkeit,
+neue Versionen einzuspielen, und regelmäßige Sicherungen der Betriebsdaten. `manage`
+stellt beides zentral bereit, statt es in jedem Projekt erneut zu bauen.
+
+Eine Installation betreut **ein Produkt** mit einer überschaubaren Zahl von
+Instanzen. Für ein weiteres Produkt wird `manage` erneut ausgerollt.
+
+## Kernfunktionen
+
+- Release-Verwaltung: Pakete hochladen, Prüfsummen serverseitig berechnen, aktuelles
+  Release festlegen
+- Backup-Empfang je Instanz, mit zwei Aufbewahrungsstufen und optionalem S3-Archiv
+- Instanzregister mit Token-Authentifizierung, Deaktivierung und Token-Erneuerung
+- Übersicht über installierte Versionen, letzte Sicherungen und offene Migrationen
+- Client-Paket mit Kommandozeile, fertiger Adminoberfläche und Funktions-API
+
+## Voraussetzungen
+
+- PHP 8.x
+- ein Webserver (Apache mit aktiver `.htaccess` oder nginx mit gesetzten Sperren)
+- Schreibrechte auf `storage/`
+
+Keine Datenbank, kein Composer, kein Build-Schritt, keine externen Bibliotheken.
+
+## Wichtige Dateien
+
+- Konfiguration: `config.php` (aus `config.sample.php`)
+- Oberfläche: `admin/`
+- Schnittstelle für Clients: `api/v1/`
+- Gemeinsame Bibliothek: `includes/`
+- Zustand: `storage/` (nicht öffentlich)
+- Weitergebbares Client-Paket: `client-package/`
+
+## Einrichtung
+
+1. `config.sample.php` nach `config.php` kopieren.
+2. Passwort-Hash erzeugen und als `MANAGE_ADMIN_PASSWORD_HASH` eintragen:
+   `php -r 'echo password_hash("…", PASSWORD_DEFAULT), PHP_EOL;'`
+3. `MANAGE_PUBLIC_URL` auf die absolute Adresse dieser Installation setzen.
+4. `MANAGE_PRODUCT_NAME` und `MANAGE_PACKAGE_PREFIX` auf das betreute Produkt setzen.
+5. Schreibrechte auf `storage/` sicherstellen.
+6. `admin/login.php` öffnen und unter **Einstellungen → Diagnose** prüfen, dass alles
+   Wesentliche stimmt.
+
+Ausführlich: [docs/SERVER_SETUP.md](docs/SERVER_SETUP.md).
+
+## Ein Projekt anbinden
+
+1. Im Manage-Server unter **Instanzen** eine Instanz anlegen und das einmalig
+   angezeigte Token notieren.
+2. Client-Paket bauen und übergeben:
+   `./scripts/build-client-package.sh --server-url https://manage.example.org`
+3. Im Projekt: `manage-client/` einkopieren, `config.php` anlegen,
+   `php manage-client/bin/manage-client.php status` ausführen.
+
+Die vollständige Anleitung dafür liegt **im Paket selbst**
+([client-package/README.md](client-package/README.md)), damit die empfangende Seite
+keinen Zugriff auf dieses Repository braucht.
+
+## Dokumentation
+
+Serverseitig, in `docs/`:
+
+- [ARCHITECTURE.md](docs/ARCHITECTURE.md) – Aufbau, Datenfluss, Speicherformate, Herkunft des Codes
+- [SERVER_SETUP.md](docs/SERVER_SETUP.md) – Installation, Webserver, Limits, S3
+- [INSTANCE_MANAGEMENT.md](docs/INSTANCE_MANAGEMENT.md) – Instanzen, Tokens, Übergabe des Client-Pakets
+- [RELEASING.md](docs/RELEASING.md) – Pakete bauen und veröffentlichen
+- [CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md) – alle Serverkonstanten
+- [MIGRATION_PSA.md](docs/MIGRATION_PSA.md) – was eine spätere Ablösung im PSA-Bestellsystem bedeuten würde
+
+Für Projekte, in `client-package/docs/`: Quickstart, Integration, Konfiguration,
+Funktions-API, Backup-Quellen, Paketbau, Post-Update-Hooks, Protokoll, Fehlersuche,
+Sicherheit.
+
+Im Browser lesbar über `docs/index.php` beziehungsweise `client-package/docs/index.php`
+(Markdown wird mit dem mitgelieferten [marked](https://marked.js.org/) gerendert).
+
+## Was bewusst fehlt
+
+- **Keine Wiederherstellung.** Backups werden erstellt, übertragen und zum Download
+  bereitgestellt, aber nie automatisch zurückgespielt. Ein Update sichert die
+  überschriebenen Dateien, kann sie aber nicht zurückholen.
+- **Keine automatischen Updates.** Der Server bietet an, die Instanz entscheidet.
+- **Keine Signatur der Pakete.** Prüfsumme und Paket kommen vom selben Server; die
+  Absicherung ist TLS plus Token. Der Manage-Server muss entsprechend geschützt sein.
+- **Keine Kanäle pro Instanz.** Es gibt ein aktuelles Release für alle Instanzen
+  eines Servers.
+
+## Hinweise
+
+- Das PSA-Bestellsystem wurde beim Herauslösen nicht verändert und läuft unverändert
+  gegen seine bisherigen Server weiter.
+- Kein automatisiertes Test-/CI-Setup vorgesehen.

+ 153 - 0
admin/backups.php

@@ -0,0 +1,153 @@
+<?php
+
+declare(strict_types=1);
+
+require_once __DIR__ . "/../includes/auth.php";
+require_once __DIR__ . "/../includes/layout.php";
+require_once __DIR__ . "/../includes/backups.php";
+
+manageRequireLogin();
+
+$messages = [];
+$errors = [];
+
+if (($_SERVER["REQUEST_METHOD"] ?? "") === "POST") {
+    try {
+        manageRequireCsrf();
+        $action = (string) ($_POST["action"] ?? "");
+        $instance = trim((string) ($_POST["instance"] ?? ""));
+        $filename = trim((string) ($_POST["filename"] ?? ""));
+
+        if ($action === "download") {
+            // Sends headers and exits on success.
+            manageBackupSendDownload($instance, $filename);
+        } elseif ($action === "delete") {
+            manageBackupDelete($instance, $filename);
+            $messages[] = "Backup " . $filename . " wurde gelöscht.";
+        } elseif ($action === "s3_sync") {
+            $result = manageBackupSyncAllS3();
+            if ($result["error"] !== null) {
+                $errors[] = "S3-Sync abgebrochen: " . $result["error"];
+            }
+            $messages[] = "S3-Sync: " . $result["uploaded"] . " hochgeladen, " . $result["pending"] . " offen.";
+        }
+    } catch (Throwable $exception) {
+        $errors[] = $exception->getMessage();
+    }
+}
+
+try {
+    $grouped = manageBackupGroupByInstance();
+    $settings = manageBackupSettings();
+    $registered = [];
+    foreach (manageInstanceList() as $instance) {
+        $registered[$instance["id"]] = $instance;
+    }
+} catch (Throwable $exception) {
+    $grouped = [];
+    $settings = ["retention" => 0, "s3_retention" => 0];
+    $registered = [];
+    $errors[] = $exception->getMessage();
+}
+
+manageRenderHeader("Backups", $messages, $errors);
+?>
+
+<section class="cards">
+    <div class="card">
+        <p class="card-label">Lokale Aufbewahrung</p>
+        <p class="card-value"><?php echo (int) $settings["retention"]; ?></p>
+        <p class="card-hint">pro Instanz</p>
+    </div>
+    <div class="card">
+        <p class="card-label">S3-Aufbewahrung</p>
+        <p class="card-value"><?php echo manageS3Enabled() ? (int) $settings["s3_retention"] : "–"; ?></p>
+        <p class="card-hint"><?php echo manageS3Enabled() ? "pro Instanz" : "S3 ist deaktiviert"; ?></p>
+    </div>
+    <div class="card">
+        <p class="card-label">Instanzen mit Backups</p>
+        <p class="card-value"><?php echo count($grouped); ?></p>
+    </div>
+</section>
+
+<?php if (manageS3Enabled()): ?>
+    <form method="POST" class="inline-form">
+        <?php echo manageCsrfField(); ?>
+        <input type="hidden" name="action" value="s3_sync">
+        <button type="submit" class="btn btn-secondary">S3-Uploads jetzt nachholen</button>
+        <span class="hint">Lädt alle lokal vorhandenen Backups hoch, die noch nicht im Bucket bestätigt sind.</span>
+    </form>
+<?php endif; ?>
+
+<?php if ($grouped === []): ?>
+    <p class="empty">Es wurden noch keine Backups empfangen.</p>
+<?php else: ?>
+    <?php foreach ($grouped as $instanceId => $backups): ?>
+        <section class="panel" id="<?php echo manageEscape($instanceId); ?>">
+            <div class="panel-head">
+                <h2><?php echo manageEscape($instanceId); ?></h2>
+                <?php if (!isset($registered[$instanceId])): ?>
+                    <span class="badge badge-warn">nicht mehr registriert</span>
+                <?php elseif (!$registered[$instanceId]["enabled"]): ?>
+                    <span class="badge badge-error">deaktiviert</span>
+                <?php endif; ?>
+                <span class="muted"><?php echo count($backups); ?> Backup(s)</span>
+            </div>
+
+            <div class="table-scroll">
+                <table class="data-table">
+                    <thead>
+                    <tr>
+                        <th>Datei</th>
+                        <th>Empfangen</th>
+                        <th>Größe</th>
+                        <th>Auslöser</th>
+                        <th>Version</th>
+                        <th>Speicherort</th>
+                        <th>Aktionen</th>
+                    </tr>
+                    </thead>
+                    <tbody>
+                    <?php foreach ($backups as $backup): ?>
+                        <?php $filename = (string) ($backup["filename"] ?? ""); ?>
+                        <tr>
+                            <td>
+                                <code><?php echo manageEscape($filename); ?></code>
+                                <?php if (!empty($backup["s3_last_error"])): ?>
+                                    <br><span class="badge badge-error">S3-Fehler</span>
+                                    <span class="muted"><?php echo manageEscape($backup["s3_last_error"]); ?></span>
+                                <?php endif; ?>
+                            </td>
+                            <td><?php echo manageEscape($backup["uploaded_at"] ?? ""); ?></td>
+                            <td><?php echo manageEscape(manageFormatBytes((int) ($backup["size"] ?? 0))); ?></td>
+                            <td><?php echo manageEscape($backup["trigger"] !== "" ? ($backup["trigger"] ?? "") : "–"); ?></td>
+                            <td><?php echo manageEscape(($backup["app_version"] ?? "") !== "" ? $backup["app_version"] : "–"); ?></td>
+                            <td><?php echo manageEscape(manageBackupStorageLabel($backup)); ?></td>
+                            <td>
+                                <div class="button-row">
+                                    <form method="POST">
+                                        <?php echo manageCsrfField(); ?>
+                                        <input type="hidden" name="action" value="download">
+                                        <input type="hidden" name="instance" value="<?php echo manageEscape($instanceId); ?>">
+                                        <input type="hidden" name="filename" value="<?php echo manageEscape($filename); ?>">
+                                        <button type="submit" class="btn btn-secondary">Herunterladen</button>
+                                    </form>
+                                    <form method="POST" onsubmit="return confirm('Dieses Backup endgültig löschen?');">
+                                        <?php echo manageCsrfField(); ?>
+                                        <input type="hidden" name="action" value="delete">
+                                        <input type="hidden" name="instance" value="<?php echo manageEscape($instanceId); ?>">
+                                        <input type="hidden" name="filename" value="<?php echo manageEscape($filename); ?>">
+                                        <button type="submit" class="btn btn-danger">Löschen</button>
+                                    </form>
+                                </div>
+                            </td>
+                        </tr>
+                    <?php endforeach; ?>
+                    </tbody>
+                </table>
+            </div>
+        </section>
+    <?php endforeach; ?>
+<?php endif; ?>
+
+<?php manageRenderFooter(); ?>

+ 163 - 0
admin/index.php

@@ -0,0 +1,163 @@
+<?php
+
+declare(strict_types=1);
+
+require_once __DIR__ . "/../includes/auth.php";
+require_once __DIR__ . "/../includes/layout.php";
+require_once __DIR__ . "/../includes/instances.php";
+require_once __DIR__ . "/../includes/releases.php";
+require_once __DIR__ . "/../includes/backups.php";
+
+manageRequireLogin();
+
+$messages = [];
+$errors = [];
+
+$instances = [];
+$latest = "";
+$releaseCount = 0;
+
+try {
+    $instances = manageInstanceList();
+    $latest = manageReleaseLatestVersion();
+    $releaseCount = count(manageReleaseReadManifest()["releases"]);
+} catch (Throwable $exception) {
+    $errors[] = $exception->getMessage();
+}
+
+if (trim((string) MANAGE_PUBLIC_URL) === "") {
+    $errors[] = "MANAGE_PUBLIC_URL ist nicht gesetzt. Clients können ohne diesen Wert kein Paket herunterladen.";
+}
+if (!managePasswordConfigured()) {
+    $errors[] = "Es ist kein Admin-Passwort konfiguriert.";
+}
+
+// An instance is "stale" when it has not been seen for a week. Cheap signal
+// that a cron job died or the instance was decommissioned without being removed.
+function manageDashboardIsStale(string $lastSeenAt): bool
+{
+    if ($lastSeenAt === "") {
+        return true;
+    }
+
+    $timestamp = strtotime($lastSeenAt);
+
+    return $timestamp === false || time() - $timestamp > 604800;
+}
+
+function manageDashboardUpdateState(array $instance, string $latest): string
+{
+    if ($latest === "" || $instance["version"] === "") {
+        return "unbekannt";
+    }
+
+    if (version_compare(ltrim($latest, "vV"), ltrim($instance["version"], "vV"), ">")) {
+        return "Update verfügbar";
+    }
+
+    return "aktuell";
+}
+
+manageRenderHeader("Übersicht", $messages, $errors);
+?>
+
+<section class="cards">
+    <div class="card">
+        <p class="card-label">Produkt</p>
+        <p class="card-value"><?php echo manageEscape(manageProductName()); ?></p>
+    </div>
+    <div class="card">
+        <p class="card-label">Aktuelles Release</p>
+        <p class="card-value"><?php echo manageEscape($latest !== "" ? $latest : "keines"); ?></p>
+        <p class="card-hint"><?php echo (int) $releaseCount; ?> Release(s) im Manifest</p>
+    </div>
+    <div class="card">
+        <p class="card-label">Instanzen</p>
+        <p class="card-value"><?php echo count($instances); ?></p>
+        <p class="card-hint"><a href="instances.php">verwalten</a></p>
+    </div>
+    <div class="card">
+        <p class="card-label">S3-Archiv</p>
+        <p class="card-value"><?php echo manageS3Enabled() ? "aktiv" : "aus"; ?></p>
+        <p class="card-hint"><a href="settings.php">Einstellungen</a></p>
+    </div>
+</section>
+
+<h2>Instanzen</h2>
+
+<?php if ($instances === []): ?>
+    <p class="empty">Noch keine Instanz registriert. <a href="instances.php">Jetzt anlegen</a> – dabei wird das
+        Token erzeugt, das in die <code>config.php</code> des Clients gehört.</p>
+<?php else: ?>
+    <div class="table-scroll">
+        <table class="data-table">
+            <thead>
+            <tr>
+                <th>Instanz</th>
+                <th>Status</th>
+                <th>Version</th>
+                <th>Update</th>
+                <th>Zuletzt gesehen</th>
+                <th>Letztes Backup</th>
+                <th>Backups</th>
+                <th>Migrationen</th>
+            </tr>
+            </thead>
+            <tbody>
+            <?php foreach ($instances as $instance): ?>
+                <?php
+                $stale = manageDashboardIsStale($instance["last_seen_at"]);
+                $updateState = manageDashboardUpdateState($instance, $latest);
+                ?>
+                <tr>
+                    <td>
+                        <strong><?php echo manageEscape($instance["id"]); ?></strong>
+                        <?php if ($instance["label"] !== ""): ?>
+                            <br><span class="muted"><?php echo manageEscape($instance["label"]); ?></span>
+                        <?php endif; ?>
+                    </td>
+                    <td>
+                        <?php if (!$instance["enabled"]): ?>
+                            <span class="badge badge-error">deaktiviert</span>
+                        <?php elseif ($stale): ?>
+                            <span class="badge badge-warn">inaktiv</span>
+                        <?php else: ?>
+                            <span class="badge badge-ok">aktiv</span>
+                        <?php endif; ?>
+                    </td>
+                    <td><?php echo manageEscape($instance["version"] !== "" ? $instance["version"] : "–"); ?></td>
+                    <td>
+                        <?php if ($updateState === "Update verfügbar"): ?>
+                            <span class="badge badge-warn">Update verfügbar</span>
+                        <?php else: ?>
+                            <?php echo manageEscape($updateState); ?>
+                        <?php endif; ?>
+                    </td>
+                    <td><?php echo manageEscape($instance["last_seen_at"] !== "" ? $instance["last_seen_at"] : "nie"); ?></td>
+                    <td><?php echo manageEscape($instance["last_backup_at"] !== "" ? $instance["last_backup_at"] : "nie"); ?></td>
+                    <td><a href="backups.php#<?php echo manageEscape($instance["id"]); ?>"><?php echo (int) $instance["backup_count"]; ?></a></td>
+                    <td>
+                        <?php if ($instance["pending_migrations"] > 0): ?>
+                            <span class="badge badge-error"><?php echo (int) $instance["pending_migrations"]; ?> offen</span>
+                        <?php else: ?>
+                            –
+                        <?php endif; ?>
+                    </td>
+                </tr>
+            <?php endforeach; ?>
+            </tbody>
+        </table>
+    </div>
+<?php endif; ?>
+
+<h2>Client-Paket</h2>
+<p>
+    Jedes Projekt erhält den Ordner <code>client-package/</code> aus diesem Repository: er enthält den
+    einzukopierenden Client-Code <em>und</em> die vollständige Integrationsdokumentation.
+</p>
+<p>
+    Verteilbares ZIP erzeugen: <code>scripts/build-client-package.sh</code> –
+    das Ergebnis liegt danach unter <code>build/</code>.
+</p>
+
+<?php manageRenderFooter(); ?>

+ 179 - 0
admin/instances.php

@@ -0,0 +1,179 @@
+<?php
+
+declare(strict_types=1);
+
+require_once __DIR__ . "/../includes/auth.php";
+require_once __DIR__ . "/../includes/layout.php";
+require_once __DIR__ . "/../includes/instances.php";
+
+manageRequireLogin();
+
+$messages = [];
+$errors = [];
+
+// Plaintext token to display once after create/rotate. Never persisted.
+$newToken = null;
+$newTokenInstance = "";
+
+if (($_SERVER["REQUEST_METHOD"] ?? "") === "POST") {
+    try {
+        manageRequireCsrf();
+        $action = (string) ($_POST["action"] ?? "");
+        $id = trim((string) ($_POST["instance"] ?? ""));
+
+        if ($action === "create") {
+            $created = manageInstanceCreate(
+                trim((string) ($_POST["new_instance"] ?? "")),
+                trim((string) ($_POST["label"] ?? "")),
+                trim((string) ($_POST["notes"] ?? "")),
+            );
+            $newToken = $created["token"];
+            $newTokenInstance = $created["instance"]["id"];
+            $messages[] = "Instanz \"" . $created["instance"]["id"] . "\" wurde angelegt.";
+        } elseif ($action === "rotate") {
+            $newToken = manageInstanceRotateToken($id);
+            $newTokenInstance = $id;
+            $messages[] = "Neues Token für \"" . $id . "\" erzeugt. Das alte Token ist ab sofort ungültig.";
+        } elseif ($action === "toggle") {
+            $instance = manageInstanceFind($id);
+            if ($instance === null) {
+                throw new RuntimeException("Instanz wurde nicht gefunden.");
+            }
+            manageInstanceUpdate($id, ["enabled" => !$instance["enabled"]]);
+            $messages[] = $instance["enabled"]
+                ? "Instanz \"" . $id . "\" wurde deaktiviert."
+                : "Instanz \"" . $id . "\" wurde aktiviert.";
+        } elseif ($action === "save") {
+            manageInstanceUpdate($id, [
+                "label" => trim((string) ($_POST["label"] ?? "")),
+                "notes" => trim((string) ($_POST["notes"] ?? "")),
+            ]);
+            $messages[] = "Instanz \"" . $id . "\" wurde gespeichert.";
+        } elseif ($action === "delete") {
+            manageInstanceDelete($id);
+            $messages[] = "Instanz \"" . $id . "\" wurde entfernt. Vorhandene Backups bleiben erhalten.";
+        }
+    } catch (Throwable $exception) {
+        $errors[] = $exception->getMessage();
+    }
+}
+
+try {
+    $instances = manageInstanceList();
+} catch (Throwable $exception) {
+    $instances = [];
+    $errors[] = $exception->getMessage();
+}
+
+manageRenderHeader("Instanzen", $messages, $errors);
+?>
+
+<?php if ($newToken !== null): ?>
+    <div class="token-box">
+        <p><strong>Token für <?php echo manageEscape($newTokenInstance); ?></strong></p>
+        <p class="muted">Dieses Token wird nur jetzt angezeigt. Der Server speichert lediglich einen Hash davon.</p>
+        <pre class="token-value"><?php echo manageEscape($newToken); ?></pre>
+        <p>In die <code>config.php</code> des Clients eintragen:</p>
+        <pre class="code-block">define("MANAGE_SERVER_URL", "<?php echo manageEscape(rtrim((string) MANAGE_PUBLIC_URL, "/")); ?>");
+define("MANAGE_INSTANCE",   "<?php echo manageEscape($newTokenInstance); ?>");
+define("MANAGE_TOKEN",      "<?php echo manageEscape($newToken); ?>");</pre>
+    </div>
+<?php endif; ?>
+
+<h2>Neue Instanz</h2>
+<form method="POST" class="panel">
+    <?php echo manageCsrfField(); ?>
+    <input type="hidden" name="action" value="create">
+    <div class="form-row">
+        <div class="form-group">
+            <label for="new_instance">Kennung</label>
+            <input type="text" id="new_instance" name="new_instance" required
+                   pattern="[A-Za-z0-9][A-Za-z0-9._\-]*" maxlength="120" placeholder="psa-prod">
+            <p class="hint">Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich.</p>
+        </div>
+        <div class="form-group">
+            <label for="label">Bezeichnung</label>
+            <input type="text" id="label" name="label" maxlength="200" placeholder="Stadt Freising Produktiv">
+        </div>
+    </div>
+    <div class="form-group">
+        <label for="notes">Notiz</label>
+        <input type="text" id="notes" name="notes" maxlength="500">
+    </div>
+    <button type="submit" class="btn">Instanz anlegen</button>
+</form>
+
+<h2>Registrierte Instanzen</h2>
+
+<?php if ($instances === []): ?>
+    <p class="empty">Noch keine Instanz registriert.</p>
+<?php else: ?>
+    <?php foreach ($instances as $instance): ?>
+        <section class="panel" id="<?php echo manageEscape($instance["id"]); ?>">
+            <div class="panel-head">
+                <h3><?php echo manageEscape($instance["id"]); ?></h3>
+                <?php if ($instance["enabled"]): ?>
+                    <span class="badge badge-ok">aktiv</span>
+                <?php else: ?>
+                    <span class="badge badge-error">deaktiviert</span>
+                <?php endif; ?>
+            </div>
+
+            <dl class="meta-list">
+                <div><dt>Version</dt><dd><?php echo manageEscape($instance["version"] !== "" ? $instance["version"] : "–"); ?></dd></div>
+                <div><dt>PHP</dt><dd><?php echo manageEscape($instance["php_version"] !== "" ? $instance["php_version"] : "–"); ?></dd></div>
+                <div><dt>Zuletzt gesehen</dt><dd><?php echo manageEscape($instance["last_seen_at"] !== "" ? $instance["last_seen_at"] : "nie"); ?></dd></div>
+                <div><dt>Letzte IP</dt><dd><?php echo manageEscape($instance["last_ip"] !== "" ? $instance["last_ip"] : "–"); ?></dd></div>
+                <div><dt>Letztes Backup</dt><dd><?php echo manageEscape($instance["last_backup_at"] !== "" ? $instance["last_backup_at"] : "nie"); ?></dd></div>
+                <div><dt>Backups</dt><dd><?php echo (int) $instance["backup_count"]; ?></dd></div>
+                <div><dt>Offene Migrationen</dt><dd><?php echo (int) $instance["pending_migrations"]; ?></dd></div>
+                <div><dt>Token erneuert</dt><dd><?php echo manageEscape($instance["token_rotated_at"] !== "" ? $instance["token_rotated_at"] : "–"); ?></dd></div>
+                <div><dt>Freier Speicher</dt><dd><?php echo $instance["disk_free"] > 0 ? manageEscape(manageFormatBytes($instance["disk_free"])) : "–"; ?></dd></div>
+            </dl>
+
+            <form method="POST" class="inline-form">
+                <?php echo manageCsrfField(); ?>
+                <input type="hidden" name="action" value="save">
+                <input type="hidden" name="instance" value="<?php echo manageEscape($instance["id"]); ?>">
+                <div class="form-row">
+                    <div class="form-group">
+                        <label>Bezeichnung
+                            <input type="text" name="label" maxlength="200" value="<?php echo manageEscape($instance["label"]); ?>">
+                        </label>
+                    </div>
+                    <div class="form-group">
+                        <label>Notiz
+                            <input type="text" name="notes" maxlength="500" value="<?php echo manageEscape($instance["notes"]); ?>">
+                        </label>
+                    </div>
+                </div>
+                <button type="submit" class="btn btn-secondary">Speichern</button>
+            </form>
+
+            <div class="button-row">
+                <form method="POST" onsubmit="return confirm('Neues Token erzeugen? Das alte Token wird sofort ungültig.');">
+                    <?php echo manageCsrfField(); ?>
+                    <input type="hidden" name="action" value="rotate">
+                    <input type="hidden" name="instance" value="<?php echo manageEscape($instance["id"]); ?>">
+                    <button type="submit" class="btn btn-secondary">Token erneuern</button>
+                </form>
+                <form method="POST">
+                    <?php echo manageCsrfField(); ?>
+                    <input type="hidden" name="action" value="toggle">
+                    <input type="hidden" name="instance" value="<?php echo manageEscape($instance["id"]); ?>">
+                    <button type="submit" class="btn btn-secondary">
+                        <?php echo $instance["enabled"] ? "Deaktivieren" : "Aktivieren"; ?>
+                    </button>
+                </form>
+                <form method="POST" onsubmit="return confirm('Instanz wirklich entfernen? Backups bleiben erhalten.');">
+                    <?php echo manageCsrfField(); ?>
+                    <input type="hidden" name="action" value="delete">
+                    <input type="hidden" name="instance" value="<?php echo manageEscape($instance["id"]); ?>">
+                    <button type="submit" class="btn btn-danger">Entfernen</button>
+                </form>
+            </div>
+        </section>
+    <?php endforeach; ?>
+<?php endif; ?>
+
+<?php manageRenderFooter(); ?>

+ 81 - 0
admin/login.php

@@ -0,0 +1,81 @@
+<?php
+
+declare(strict_types=1);
+
+require_once __DIR__ . "/../includes/auth.php";
+
+manageStartSession();
+
+$errors = [];
+$messages = [];
+$next = basename((string) ($_GET["next"] ?? "index.php"));
+if (!array_key_exists($next, manageNavItemsSafe())) {
+    $next = "index.php";
+}
+
+function manageNavItemsSafe(): array
+{
+    // Only ever redirect to a known admin page, never to a caller-supplied URL.
+    return [
+        "index.php" => true,
+        "instances.php" => true,
+        "releases.php" => true,
+        "backups.php" => true,
+        "settings.php" => true,
+    ];
+}
+
+if (manageIsLoggedIn()) {
+    header("Location: " . $next);
+    exit;
+}
+
+if (($_SERVER["REQUEST_METHOD"] ?? "") === "POST") {
+    if (!managePasswordConfigured()) {
+        $errors[] = "Es ist kein Admin-Passwort konfiguriert. Bitte MANAGE_ADMIN_PASSWORD_HASH in config.php setzen.";
+    } elseif (manageLoginIsRateLimited()) {
+        $errors[] = "Zu viele Fehlversuche. Bitte später erneut versuchen.";
+    } elseif (managePasswordMatches((string) ($_POST["password"] ?? ""))) {
+        manageLoginClearRateLimit();
+        manageLogin();
+        manageLogAccess("Admin login succeeded");
+        header("Location: " . $next);
+        exit;
+    } else {
+        manageLoginRecordFailure();
+        manageLogError("Admin login failed", [], "WARNING");
+        $errors[] = "Falsches Passwort.";
+    }
+}
+
+?>
+<!DOCTYPE html>
+<html lang="de">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Anmeldung – Manage</title>
+    <link rel="stylesheet" href="../assets/css/style.css">
+</head>
+<body class="login-body">
+<main class="login-panel">
+    <h1>Manage</h1>
+    <p class="muted"><?php echo manageEscape(manageProductName()); ?></p>
+
+    <?php foreach ($messages as $message): ?>
+        <p class="alert alert-success"><?php echo manageEscape($message); ?></p>
+    <?php endforeach; ?>
+    <?php foreach ($errors as $error): ?>
+        <p class="alert alert-error"><?php echo manageEscape($error); ?></p>
+    <?php endforeach; ?>
+
+    <form method="POST">
+        <div class="form-group">
+            <label for="password">Passwort</label>
+            <input type="password" id="password" name="password" required autofocus autocomplete="current-password">
+        </div>
+        <button type="submit" class="btn">Anmelden</button>
+    </form>
+</main>
+</body>
+</html>

+ 16 - 0
admin/logout.php

@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+require_once __DIR__ . "/../includes/auth.php";
+
+manageStartSession();
+
+// POST only with a valid CSRF token, so a third-party page cannot log the
+// admin out (and the session cannot be cycled by a stray GET).
+if (($_SERVER["REQUEST_METHOD"] ?? "") === "POST" && manageCsrfIsValid((string) ($_POST["csrf_token"] ?? ""))) {
+    manageLogout();
+}
+
+header("Location: login.php");
+exit;

+ 171 - 0
admin/releases.php

@@ -0,0 +1,171 @@
+<?php
+
+declare(strict_types=1);
+
+require_once __DIR__ . "/../includes/auth.php";
+require_once __DIR__ . "/../includes/layout.php";
+require_once __DIR__ . "/../includes/releases.php";
+
+manageRequireLogin();
+
+$messages = [];
+$errors = [];
+
+if (($_SERVER["REQUEST_METHOD"] ?? "") === "POST") {
+    try {
+        manageRequireCsrf();
+        $action = (string) ($_POST["action"] ?? "");
+        $version = trim((string) ($_POST["version"] ?? ""));
+
+        if ($action === "upload") {
+            $release = manageReleasePublishUpload($version, $_FILES["package"] ?? []);
+            $messages[] = "Release " . $version . " wurde hochgeladen und als aktuell veröffentlicht.";
+            $messages[] = "SHA-256: " . $release["sha256"];
+        } elseif ($action === "set_latest") {
+            manageReleaseSetLatest($version);
+            $messages[] = "Aktuelles Release ist jetzt " . $version . ".";
+        } elseif ($action === "delete") {
+            manageReleaseDelete($version);
+            $messages[] = "Release " . $version . " wurde gelöscht.";
+        }
+    } catch (Throwable $exception) {
+        $errors[] = $exception->getMessage();
+    }
+}
+
+try {
+    $manifest = manageReleaseReadManifest();
+    $releases = manageReleaseListDescending();
+} catch (Throwable $exception) {
+    $manifest = ["latest" => "", "releases" => []];
+    $releases = [];
+    $errors[] = $exception->getMessage();
+}
+
+// Server-side upload ceiling, so the form can state the real limit instead of
+// failing with an empty $_FILES entry.
+function manageUploadLimitBytes(): int
+{
+    $toBytes = static function (string $value): int {
+        $value = trim($value);
+        if ($value === "") {
+            return 0;
+        }
+        $unit = strtolower(substr($value, -1));
+        $number = (int) $value;
+
+        return match ($unit) {
+            "g" => $number * 1073741824,
+            "m" => $number * 1048576,
+            "k" => $number * 1024,
+            default => $number,
+        };
+    };
+
+    $limits = array_filter([
+        $toBytes((string) ini_get("upload_max_filesize")),
+        $toBytes((string) ini_get("post_max_size")),
+    ]);
+
+    return $limits === [] ? 0 : (int) min($limits);
+}
+
+$uploadLimit = manageUploadLimitBytes();
+
+manageRenderHeader("Releases", $messages, $errors);
+?>
+
+<section class="cards">
+    <div class="card">
+        <p class="card-label">Aktuelles Release</p>
+        <p class="card-value"><?php echo manageEscape($manifest["latest"] !== "" ? $manifest["latest"] : "keines"); ?></p>
+    </div>
+    <div class="card">
+        <p class="card-label">Paket-Präfix</p>
+        <p class="card-value"><?php echo manageEscape(MANAGE_PACKAGE_PREFIX); ?></p>
+    </div>
+    <div class="card">
+        <p class="card-label">Upload-Limit</p>
+        <p class="card-value"><?php echo $uploadLimit > 0 ? manageEscape(manageFormatBytes($uploadLimit)) : "unbekannt"; ?></p>
+        <p class="card-hint">PHP: upload_max_filesize / post_max_size</p>
+    </div>
+</section>
+
+<h2>Release hochladen</h2>
+<form method="POST" enctype="multipart/form-data" class="panel">
+    <?php echo manageCsrfField(); ?>
+    <input type="hidden" name="action" value="upload">
+    <div class="form-row">
+        <div class="form-group">
+            <label for="version">Version</label>
+            <input type="text" id="version" name="version" required placeholder="v1.3.15"
+                   pattern="v[0-9]+\.[0-9]+\.[0-9]+">
+        </div>
+        <div class="form-group">
+            <label for="package">ZIP-Paket</label>
+            <input type="file" id="package" name="package" accept=".zip,application/zip" required>
+        </div>
+    </div>
+    <p class="hint">
+        Prüfsumme und Größe werden serverseitig berechnet. Ein Upload setzt das Release automatisch als aktuell.
+    </p>
+    <button type="submit" class="btn">Hochladen und veröffentlichen</button>
+</form>
+
+<h2>Veröffentlichte Releases</h2>
+
+<?php if ($releases === []): ?>
+    <p class="empty">Noch keine Releases vorhanden. Paket mit <code>scripts/create-release-zip.sh</code> bauen und hier hochladen.</p>
+<?php else: ?>
+    <div class="table-scroll">
+        <table class="data-table">
+            <thead>
+            <tr>
+                <th>Version</th>
+                <th>Paket</th>
+                <th>SHA-256</th>
+                <th>Größe</th>
+                <th>Veröffentlicht</th>
+                <th>Aktionen</th>
+            </tr>
+            </thead>
+            <tbody>
+            <?php foreach ($releases as $version => $release): ?>
+                <?php $isLatest = $manifest["latest"] === $version; ?>
+                <tr>
+                    <td>
+                        <strong><?php echo manageEscape($version); ?></strong>
+                        <?php if ($isLatest): ?>
+                            <br><span class="badge badge-ok">aktuell</span>
+                        <?php endif; ?>
+                    </td>
+                    <td><code><?php echo manageEscape($release["package"] ?? ""); ?></code></td>
+                    <td class="mono-cell"><?php echo manageEscape($release["sha256"] ?? ""); ?></td>
+                    <td><?php echo manageEscape(manageFormatBytes((int) ($release["size"] ?? 0))); ?></td>
+                    <td><?php echo manageEscape($release["published_at"] ?? ""); ?></td>
+                    <td>
+                        <div class="button-row">
+                            <?php if (!$isLatest): ?>
+                                <form method="POST">
+                                    <?php echo manageCsrfField(); ?>
+                                    <input type="hidden" name="action" value="set_latest">
+                                    <input type="hidden" name="version" value="<?php echo manageEscape($version); ?>">
+                                    <button type="submit" class="btn btn-secondary">Als aktuell setzen</button>
+                                </form>
+                            <?php endif; ?>
+                            <form method="POST" onsubmit="return confirm('Dieses Release wirklich löschen?');">
+                                <?php echo manageCsrfField(); ?>
+                                <input type="hidden" name="action" value="delete">
+                                <input type="hidden" name="version" value="<?php echo manageEscape($version); ?>">
+                                <button type="submit" class="btn btn-danger">Löschen</button>
+                            </form>
+                        </div>
+                    </td>
+                </tr>
+            <?php endforeach; ?>
+            </tbody>
+        </table>
+    </div>
+<?php endif; ?>
+
+<?php manageRenderFooter(); ?>

+ 104 - 0
admin/settings.php

@@ -0,0 +1,104 @@
+<?php
+
+declare(strict_types=1);
+
+require_once __DIR__ . "/../includes/auth.php";
+require_once __DIR__ . "/../includes/layout.php";
+require_once __DIR__ . "/../includes/backups.php";
+
+manageRequireLogin();
+
+$messages = [];
+$errors = [];
+
+if (($_SERVER["REQUEST_METHOD"] ?? "") === "POST") {
+    try {
+        manageRequireCsrf();
+        $action = (string) ($_POST["action"] ?? "");
+
+        if ($action === "save_retention") {
+            manageBackupWriteSettings([
+                "retention" => (int) ($_POST["retention"] ?? 0),
+                "s3_retention" => (int) ($_POST["s3_retention"] ?? 0),
+            ]);
+            // Retention changes take effect immediately, not only on next upload.
+            manageBackupApplyRetentionAll();
+            $messages[] = "Aufbewahrung gespeichert und angewendet.";
+        }
+    } catch (Throwable $exception) {
+        $errors[] = $exception->getMessage();
+    }
+}
+
+$settings = manageBackupSettings();
+$s3Log = manageLogS3Tail(30);
+
+// Everything an operator needs to sanity-check a deployment at a glance.
+$diagnostics = [
+    "Produkt" => manageProductName(),
+    "Paket-Präfix" => (string) MANAGE_PACKAGE_PREFIX,
+    "Öffentliche URL" => trim((string) MANAGE_PUBLIC_URL) !== "" ? (string) MANAGE_PUBLIC_URL : "NICHT GESETZT",
+    "Storage-Verzeichnis" => manageStorageDir(),
+    "Storage beschreibbar" => is_writable(manageStorageDir()) ? "ja" : "NEIN",
+    "Admin-Passwort" => managePasswordConfigured() ? "konfiguriert" : "NICHT KONFIGURIERT",
+    "S3-Archiv" => manageS3Enabled() ? "aktiv" : "aus",
+    "PHP-Version" => PHP_VERSION,
+    "upload_max_filesize" => (string) ini_get("upload_max_filesize"),
+    "post_max_size" => (string) ini_get("post_max_size"),
+];
+
+manageRenderHeader("Einstellungen", $messages, $errors);
+?>
+
+<h2>Aufbewahrung</h2>
+<form method="POST" class="panel">
+    <?php echo manageCsrfField(); ?>
+    <input type="hidden" name="action" value="save_retention">
+    <div class="form-row">
+        <div class="form-group">
+            <label for="retention">Lokale Backups pro Instanz</label>
+            <input type="number" id="retention" name="retention" min="1" required
+                   value="<?php echo (int) $settings["retention"]; ?>">
+            <p class="hint">Mindestens 1. Bei aktivem S3 gilt dieser Wert nur für die lokale Kopie.</p>
+        </div>
+        <div class="form-group">
+            <label for="s3_retention">S3-Backups pro Instanz</label>
+            <input type="number" id="s3_retention" name="s3_retention" min="1" required
+                   value="<?php echo (int) $settings["s3_retention"]; ?>">
+            <p class="hint">Nur wirksam, wenn das S3-Archiv aktiviert ist.</p>
+        </div>
+    </div>
+    <button type="submit" class="btn">Speichern und anwenden</button>
+</form>
+
+<h2>Diagnose</h2>
+<div class="table-scroll">
+    <table class="data-table">
+        <tbody>
+        <?php foreach ($diagnostics as $label => $value): ?>
+            <tr>
+                <th scope="row"><?php echo manageEscape($label); ?></th>
+                <td><code><?php echo manageEscape($value); ?></code></td>
+            </tr>
+        <?php endforeach; ?>
+        </tbody>
+    </table>
+</div>
+
+<h2>Zugangsdaten</h2>
+<p>
+    Passwort und S3-Zugangsdaten stehen ausschließlich in <code>config.php</code> und werden hier bewusst nicht
+    bearbeitbar gemacht. Neuen Passwort-Hash erzeugen:
+</p>
+<pre class="code-block">php -r 'echo password_hash("neues-passwort", PASSWORD_DEFAULT), PHP_EOL;'</pre>
+
+<?php if (manageS3Enabled()): ?>
+    <h2>S3-Protokoll</h2>
+    <?php if ($s3Log === []): ?>
+        <p class="empty">Keine S3-Meldungen protokolliert.</p>
+    <?php else: ?>
+        <pre class="code-block log-tail"><?php echo manageEscape(implode(PHP_EOL, $s3Log)); ?></pre>
+    <?php endif; ?>
+<?php endif; ?>
+
+<?php manageRenderFooter(); ?>

+ 76 - 0
api/v1/backup.php

@@ -0,0 +1,76 @@
+<?php
+
+declare(strict_types=1);
+
+// POST api/v1/backup.php
+// Receives a backup archive from an authenticated instance.
+//
+// multipart/form-data fields:
+//   filename  backup-YYYYmmdd-HHMMSS[-N].zip
+//   sha256    client-side checksum, re-verified here
+//   meta      optional JSON (trigger, file_count, source_bytes, app_version)
+//   backup    the ZIP file itself
+
+require_once __DIR__ . "/../../includes/api.php";
+require_once __DIR__ . "/../../includes/backups.php";
+
+manageApiRequireMethod("POST");
+$instance = manageApiAuthenticate();
+
+try {
+    $file = $_FILES["backup"] ?? null;
+    if (!is_array($file)) {
+        throw new RuntimeException("Backup-Datei fehlt.");
+    }
+
+    $uploadError = (int) ($file["error"] ?? UPLOAD_ERR_NO_FILE);
+    if ($uploadError !== UPLOAD_ERR_OK) {
+        // INI_SIZE / FORM_SIZE are the common real-world failure and deserve a
+        // message that names the cause instead of a bare error code.
+        if ($uploadError === UPLOAD_ERR_INI_SIZE || $uploadError === UPLOAD_ERR_FORM_SIZE) {
+            throw new RuntimeException(
+                "Backup überschreitet das Upload-Limit des Servers (upload_max_filesize / post_max_size).",
+            );
+        }
+        throw new RuntimeException("Upload fehlgeschlagen (Fehlercode " . $uploadError . ").");
+    }
+
+    $tmpName = (string) ($file["tmp_name"] ?? "");
+    if ($tmpName === "" || !is_uploaded_file($tmpName)) {
+        throw new RuntimeException("Upload ist ungültig.");
+    }
+
+    $maxBytes = (int) MANAGE_BACKUP_MAX_UPLOAD_BYTES;
+    if ($maxBytes > 0 && (int) ($file["size"] ?? 0) > $maxBytes) {
+        throw new RuntimeException("Backup überschreitet die konfigurierte Maximalgröße.");
+    }
+
+    if (!manageFileIsZip($tmpName)) {
+        throw new RuntimeException("Die hochgeladene Datei muss ein ZIP-Archiv sein.");
+    }
+
+    $meta = [];
+    $rawMeta = trim((string) ($_POST["meta"] ?? ""));
+    if ($rawMeta !== "") {
+        $decoded = json_decode($rawMeta, true);
+        if (is_array($decoded)) {
+            $meta = $decoded;
+        }
+    }
+
+    $result = manageBackupStoreUpload(
+        $instance["id"],
+        $tmpName,
+        (string) ($_POST["filename"] ?? ""),
+        (string) ($_POST["sha256"] ?? ""),
+        $meta,
+    );
+
+    manageApiSendJson(200, array_merge(["success" => true, "instance" => $instance["id"]], $result));
+} catch (Throwable $exception) {
+    manageLogError("Backup upload rejected", [
+        "instance" => $instance["id"],
+        "error" => $exception->getMessage(),
+    ]);
+    manageApiFail(400, $exception->getMessage());
+}

+ 58 - 0
api/v1/heartbeat.php

@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+// POST api/v1/heartbeat.php
+// Instance status report. The response doubles as a cheap update check, so a
+// cron heartbeat is enough to keep the dashboard current and to learn about a
+// pending update in one request.
+//
+// JSON body: version, php_version, disk_free, pending_migrations, last_backup_at
+
+require_once __DIR__ . "/../../includes/api.php";
+require_once __DIR__ . "/../../includes/releases.php";
+
+manageApiRequireMethod("POST");
+$instance = manageApiAuthenticate();
+
+$body = manageApiReadJsonBody();
+
+$report = [
+    "version" => trim((string) ($body["version"] ?? "")),
+    "php_version" => trim((string) ($body["php_version"] ?? "")),
+    "disk_free" => (int) ($body["disk_free"] ?? 0),
+    "pending_migrations" => max(0, (int) ($body["pending_migrations"] ?? 0)),
+];
+
+manageInstanceTouch($instance["id"], $report);
+
+if (isset($body["last_backup_at"])) {
+    try {
+        manageInstanceUpdate($instance["id"], [
+            "last_backup_at" => trim((string) $body["last_backup_at"]),
+        ]);
+    } catch (Throwable $exception) {
+        manageLogError("Heartbeat backup timestamp update failed", [
+            "instance" => $instance["id"],
+            "error" => $exception->getMessage(),
+        ]);
+    }
+}
+
+$latest = manageReleaseLatestVersion();
+$updateAvailable = false;
+if ($latest !== "" && $report["version"] !== "") {
+    $updateAvailable = version_compare(
+        ltrim($latest, "vV"),
+        ltrim($report["version"], "vV"),
+        ">",
+    );
+}
+
+manageApiSendJson(200, [
+    "success" => true,
+    "instance" => $instance["id"],
+    "latest" => $latest,
+    "update_available" => $updateAvailable,
+    "server_time" => date(DATE_ATOM),
+]);

+ 42 - 0
api/v1/manifest.php

@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+// GET api/v1/manifest.php
+// Returns the release the calling instance should install.
+//
+// Unlike the PSA update server this endpoint requires a valid instance token;
+// release metadata is no longer public.
+
+require_once __DIR__ . "/../../includes/api.php";
+require_once __DIR__ . "/../../includes/releases.php";
+
+manageApiRequireMethod("GET");
+$instance = manageApiAuthenticate();
+manageInstanceTouch($instance["id"], []);
+
+$latest = manageReleaseLatestVersion();
+if ($latest === "") {
+    manageApiFail(404, "Es ist kein gültiges Release veröffentlicht.");
+}
+
+try {
+    $release = manageReleaseResolve($latest);
+} catch (Throwable $exception) {
+    manageLogError("Manifest resolve failed", [
+        "instance" => $instance["id"],
+        "version" => $latest,
+        "error" => $exception->getMessage(),
+    ]);
+    manageApiFail(500, $exception->getMessage());
+}
+
+manageApiSendJson(200, [
+    "success" => true,
+    "latest" => $release["version"],
+    "version" => $release["version"],
+    "package_url" => $release["package_url"],
+    "sha256" => $release["sha256"],
+    "size" => $release["size"],
+    "published_at" => $release["published_at"],
+]);

+ 47 - 0
api/v1/package.php

@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+// GET api/v1/package.php?version=vX.Y.Z
+// Streams a release ZIP to an authenticated instance.
+
+require_once __DIR__ . "/../../includes/api.php";
+require_once __DIR__ . "/../../includes/releases.php";
+
+manageApiRequireMethod("GET");
+$instance = manageApiAuthenticate();
+
+$version = trim((string) ($_GET["version"] ?? ""));
+if (!manageIsVersion($version)) {
+    manageApiFail(400, "Ungültige Version.");
+}
+
+try {
+    $release = manageReleaseResolve($version);
+} catch (Throwable $exception) {
+    manageApiFail(404, $exception->getMessage());
+}
+
+$path = $release["package_path"];
+$size = filesize($path);
+$handle = fopen($path, "rb");
+if ($size === false || $handle === false) {
+    manageApiFail(500, "Release-Paket konnte nicht geöffnet werden.");
+}
+
+manageInstanceTouch($instance["id"], []);
+manageLogAccess("Package downloaded", [
+    "instance" => $instance["id"],
+    "version" => $version,
+]);
+
+header("Content-Type: application/zip");
+header("Content-Disposition: attachment; filename=\"" . addcslashes(basename($path), "\"\\") . "\"");
+header("Content-Length: " . (string) $size);
+// Private: the response is tied to an authenticated instance, so no shared
+// cache may keep a copy.
+header("Cache-Control: private, no-store");
+header("X-Content-Type-Options: nosniff");
+
+fpassthru($handle);
+fclose($handle);

+ 508 - 0
assets/css/style.css

@@ -0,0 +1,508 @@
+/*
+ * Manage admin UI. Single hand-written stylesheet, no framework and no build
+ * step, following the same custom-property approach as the PSA order system
+ * (docs/STYLE_SYSTEM.md there). Neutral palette, because this is an operations
+ * tool rather than a branded surface.
+ */
+
+:root {
+    --c-text: #16191d;
+    --c-muted: #5c6470;
+    --c-bg: #f4f5f7;
+    --c-surface: #ffffff;
+    --c-surface-alt: #fafbfc;
+    --c-border: #d9dde3;
+    --c-border-strong: #b9c0c9;
+    --c-primary: #1f3b63;
+    --c-primary-soft: #2c527f;
+    --c-ok: #1a6b3c;
+    --c-ok-soft: #eaf6ee;
+    --c-warn: #8a5a00;
+    --c-warn-soft: #fdf3e0;
+    --c-error: #a52218;
+    --c-error-soft: #fceceb;
+    --c-shadow: rgba(16, 20, 26, 0.08);
+    --radius: 8px;
+}
+
+* {
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+}
+
+body {
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+    line-height: 1.55;
+    color: var(--c-text);
+    background: var(--c-bg);
+}
+
+code, pre, .mono-cell {
+    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+    font-size: 0.875em;
+}
+
+a {
+    color: var(--c-primary);
+    text-underline-offset: 0.18em;
+}
+
+a:hover {
+    color: var(--c-primary-soft);
+}
+
+.container {
+    width: min(1180px, 100% - 2.5rem);
+    margin: 0 auto;
+    padding: 1.75rem 0 3rem;
+}
+
+/* --- Top bar ----------------------------------------------------------- */
+
+.topbar {
+    background: var(--c-primary);
+    color: #fff;
+}
+
+.topbar-inner {
+    width: min(1180px, 100% - 2.5rem);
+    margin: 0 auto;
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 1rem 1.5rem;
+    padding: 0.85rem 0;
+}
+
+.brand {
+    display: flex;
+    flex-direction: column;
+    line-height: 1.25;
+}
+
+.brand-name {
+    font-weight: 600;
+    font-size: 1.05rem;
+}
+
+.brand-product {
+    font-size: 0.8rem;
+    opacity: 0.75;
+}
+
+.topnav {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.25rem;
+    margin-right: auto;
+}
+
+.topnav a {
+    color: #fff;
+    text-decoration: none;
+    padding: 0.4rem 0.75rem;
+    border-radius: var(--radius);
+    font-size: 0.925rem;
+}
+
+.topnav a:hover {
+    background: rgba(255, 255, 255, 0.12);
+    color: #fff;
+}
+
+.topnav a[aria-current="page"] {
+    background: rgba(255, 255, 255, 0.2);
+    font-weight: 600;
+}
+
+.topbar-logout {
+    margin-left: auto;
+}
+
+/* --- Typography -------------------------------------------------------- */
+
+h1 {
+    font-size: 1.6rem;
+    margin-bottom: 1.25rem;
+}
+
+h2 {
+    font-size: 1.2rem;
+    margin: 2rem 0 0.85rem;
+}
+
+h3 {
+    font-size: 1.05rem;
+}
+
+p + p {
+    margin-top: 0.6rem;
+}
+
+.muted {
+    color: var(--c-muted);
+}
+
+.hint {
+    color: var(--c-muted);
+    font-size: 0.85rem;
+    margin-top: 0.3rem;
+}
+
+.empty {
+    background: var(--c-surface);
+    border: 1px dashed var(--c-border-strong);
+    border-radius: var(--radius);
+    padding: 1.25rem;
+    color: var(--c-muted);
+}
+
+/* --- Alerts ------------------------------------------------------------ */
+
+.alert {
+    border-radius: var(--radius);
+    padding: 0.7rem 0.9rem;
+    margin-bottom: 0.6rem;
+    border: 1px solid transparent;
+}
+
+.alert-success {
+    background: var(--c-ok-soft);
+    border-color: #bfe0cb;
+    color: var(--c-ok);
+}
+
+.alert-error {
+    background: var(--c-error-soft);
+    border-color: #f0c3bf;
+    color: var(--c-error);
+}
+
+/* --- Cards ------------------------------------------------------------- */
+
+.cards {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+    gap: 0.9rem;
+    margin-bottom: 1.5rem;
+}
+
+.card {
+    background: var(--c-surface);
+    border: 1px solid var(--c-border);
+    border-radius: var(--radius);
+    padding: 0.9rem 1rem;
+    box-shadow: 0 1px 2px var(--c-shadow);
+}
+
+.card-label {
+    font-size: 0.775rem;
+    text-transform: uppercase;
+    letter-spacing: 0.04em;
+    color: var(--c-muted);
+}
+
+.card-value {
+    font-size: 1.35rem;
+    font-weight: 600;
+    margin-top: 0.15rem;
+}
+
+.card-hint {
+    font-size: 0.8rem;
+    color: var(--c-muted);
+}
+
+/* --- Panels ------------------------------------------------------------ */
+
+.panel {
+    background: var(--c-surface);
+    border: 1px solid var(--c-border);
+    border-radius: var(--radius);
+    padding: 1.15rem;
+    margin-bottom: 1.25rem;
+    box-shadow: 0 1px 2px var(--c-shadow);
+}
+
+.panel-head {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 0.65rem;
+    margin-bottom: 0.9rem;
+}
+
+.panel-head h2,
+.panel-head h3 {
+    margin: 0;
+}
+
+/* --- Tables ------------------------------------------------------------ */
+
+.table-scroll {
+    overflow-x: auto;
+    background: var(--c-surface);
+    border: 1px solid var(--c-border);
+    border-radius: var(--radius);
+}
+
+.data-table {
+    width: 100%;
+    border-collapse: collapse;
+    font-size: 0.925rem;
+}
+
+.data-table th,
+.data-table td {
+    text-align: left;
+    padding: 0.6rem 0.8rem;
+    border-bottom: 1px solid var(--c-border);
+    vertical-align: top;
+}
+
+.data-table thead th {
+    background: var(--c-surface-alt);
+    font-size: 0.8rem;
+    text-transform: uppercase;
+    letter-spacing: 0.03em;
+    color: var(--c-muted);
+    white-space: nowrap;
+}
+
+.data-table tbody tr:last-child th,
+.data-table tbody tr:last-child td {
+    border-bottom: none;
+}
+
+.mono-cell {
+    word-break: break-all;
+    max-width: 22ch;
+}
+
+/* --- Definition lists -------------------------------------------------- */
+
+.meta-list {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+    gap: 0.5rem 1.25rem;
+    margin-bottom: 1rem;
+}
+
+.meta-list dt {
+    font-size: 0.775rem;
+    text-transform: uppercase;
+    letter-spacing: 0.03em;
+    color: var(--c-muted);
+}
+
+.meta-list dd {
+    font-size: 0.95rem;
+}
+
+/* --- Forms ------------------------------------------------------------- */
+
+.form-row {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
+    gap: 0.9rem;
+}
+
+.form-group {
+    margin-bottom: 0.9rem;
+}
+
+.form-group label {
+    display: block;
+    font-weight: 600;
+    font-size: 0.9rem;
+    margin-bottom: 0.3rem;
+}
+
+input[type="text"],
+input[type="password"],
+input[type="number"],
+input[type="file"] {
+    width: 100%;
+    padding: 0.5rem 0.65rem;
+    border: 1px solid var(--c-border-strong);
+    border-radius: var(--radius);
+    font: inherit;
+    background: #fff;
+    color: inherit;
+}
+
+input:focus-visible,
+button:focus-visible,
+a:focus-visible {
+    outline: 2px solid var(--c-primary);
+    outline-offset: 2px;
+}
+
+.inline-form {
+    margin-bottom: 1rem;
+}
+
+.button-row {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+    align-items: center;
+}
+
+/* --- Buttons ----------------------------------------------------------- */
+
+.btn {
+    display: inline-block;
+    padding: 0.45rem 0.9rem;
+    border: 1px solid var(--c-primary);
+    border-radius: var(--radius);
+    background: var(--c-primary);
+    color: #fff;
+    font: inherit;
+    font-size: 0.9rem;
+    cursor: pointer;
+}
+
+.btn:hover {
+    background: var(--c-primary-soft);
+    border-color: var(--c-primary-soft);
+}
+
+.btn-secondary {
+    background: #fff;
+    color: var(--c-primary);
+}
+
+.btn-secondary:hover {
+    background: var(--c-surface-alt);
+    color: var(--c-primary-soft);
+}
+
+.btn-danger {
+    background: #fff;
+    border-color: var(--c-error);
+    color: var(--c-error);
+}
+
+.btn-danger:hover {
+    background: var(--c-error);
+    color: #fff;
+}
+
+.btn-quiet {
+    background: transparent;
+    border-color: rgba(255, 255, 255, 0.45);
+    color: #fff;
+}
+
+.btn-quiet:hover {
+    background: rgba(255, 255, 255, 0.14);
+    border-color: rgba(255, 255, 255, 0.7);
+}
+
+/* --- Badges ------------------------------------------------------------ */
+
+.badge {
+    display: inline-block;
+    padding: 0.1rem 0.5rem;
+    border-radius: 999px;
+    font-size: 0.775rem;
+    font-weight: 600;
+    white-space: nowrap;
+}
+
+.badge-ok {
+    background: var(--c-ok-soft);
+    color: var(--c-ok);
+}
+
+.badge-warn {
+    background: var(--c-warn-soft);
+    color: var(--c-warn);
+}
+
+.badge-error {
+    background: var(--c-error-soft);
+    color: var(--c-error);
+}
+
+/* --- Code and tokens --------------------------------------------------- */
+
+.code-block {
+    background: #16191d;
+    color: #e8ecf1;
+    padding: 0.85rem 1rem;
+    border-radius: var(--radius);
+    overflow-x: auto;
+    white-space: pre;
+}
+
+.log-tail {
+    max-height: 20rem;
+    overflow-y: auto;
+}
+
+.token-box {
+    background: var(--c-warn-soft);
+    border: 1px solid #e6cf9d;
+    border-radius: var(--radius);
+    padding: 1.15rem;
+    margin-bottom: 1.5rem;
+}
+
+.token-value {
+    background: #16191d;
+    color: #ffd98a;
+    padding: 0.7rem 0.9rem;
+    border-radius: var(--radius);
+    overflow-x: auto;
+    word-break: break-all;
+    white-space: pre-wrap;
+    margin: 0.5rem 0 0.9rem;
+}
+
+/* --- Login ------------------------------------------------------------- */
+
+.login-body {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 100vh;
+    padding: 1.5rem;
+}
+
+.login-panel {
+    background: var(--c-surface);
+    border: 1px solid var(--c-border);
+    border-radius: var(--radius);
+    padding: 1.75rem;
+    width: min(380px, 100%);
+    box-shadow: 0 2px 10px var(--c-shadow);
+}
+
+.login-panel h1 {
+    margin-bottom: 0.15rem;
+}
+
+.login-panel form {
+    margin-top: 1.25rem;
+}
+
+.login-panel .btn {
+    width: 100%;
+}
+
+/* --- Footer ------------------------------------------------------------ */
+
+.footer {
+    border-top: 1px solid var(--c-border);
+    background: var(--c-surface);
+    padding: 1.15rem 0;
+    color: var(--c-muted);
+    font-size: 0.875rem;
+}
+
+.footer .container {
+    padding: 0;
+}

+ 122 - 0
client-package/README.md

@@ -0,0 +1,122 @@
+# Manage Client-Paket
+
+Update- und Backup-Client für PHP-Projekte, zusammen mit der vollständigen
+Dokumentation zur Einbindung. Dieser Ordner ist so gebaut, dass er als einzelnes
+ZIP weitergegeben werden kann: Wer ihn erhält, braucht weder Zugriff auf das
+Repository des Manage-Servers noch weitere Erklärungen.
+
+## Was das ist
+
+Ein PHP-Client, der
+
+- Releases von einem zentralen Manage-Server holt, prüft und einspielt,
+- Backups der Betriebsdaten erstellt und dorthin hochlädt,
+- den Status der Installation an den Server meldet.
+
+Der Client läuft eigenständig über die Kommandozeile, bringt eine fertige
+Oberfläche für den Adminbereich mit und stellt alle Funktionen zum direkten
+Aufruf aus dem Projekt bereit. Alle drei Wege rufen dieselben Funktionen auf, ein
+Vorgang verhält sich also überall gleich.
+
+Keine Abhängigkeiten: kein Composer, kein Build-Schritt, keine externen
+Bibliotheken. PHP 8.0 oder neuer, `ext-zip` für Updates, Schreibrechte auf dem
+Datenverzeichnis.
+
+## In fünf Minuten
+
+```bash
+# 1. Ordner ins Projekt kopieren
+cp -r manage-client /pfad/zum/projekt/manage-client
+
+# 2. Instanz im Manage-Server anlegen und das einmalig angezeigte Token kopieren
+
+# 3. Konfiguration anlegen
+cd /pfad/zum/projekt/manage-client
+cp config.sample.php config.php
+# MANAGE_SERVER_URL, MANAGE_INSTANCE, MANAGE_TOKEN und MANAGE_BACKUP_SOURCES setzen
+
+# 4. Verbindung prüfen
+php bin/manage-client.php status
+
+# 5. Erstes Backup
+php bin/manage-client.php backup
+```
+
+Ausführlich: [docs/01_QUICKSTART.md](docs/01_QUICKSTART.md).
+
+## Inhalt
+
+```text
+manage-client/            <- dieser Ordner wird ins Projekt kopiert
+  config.sample.php       Vorlage, wird zu config.php
+  lib/                    die Bibliothek; client.php ist der einzige Einstiegspunkt
+  bin/manage-client.php   Kommandozeile für Cron und Shell
+  ui/panel.php            fertige Seite für den Adminbereich
+  ui/status-partial.php   kleiner Statusblock für eine bestehende Seite
+
+docs/                     die Dokumentation, siehe unten
+examples/                 lauffähige Beispiele zum Abschreiben
+```
+
+Im Browser lesbar: `docs/index.php` über einen beliebigen PHP-Server öffnen, zum
+Beispiel `php -S localhost:8080 -t docs`. Die Dateien sind auch als reines
+Markdown lesbar.
+
+## Dokumentation
+
+| Dokument | Inhalt |
+|---|---|
+| [01_QUICKSTART](docs/01_QUICKSTART.md) | Von diesem Ordner zum ersten Backup |
+| [02_INTEGRATION](docs/02_INTEGRATION.md) | Einbindung in ein bestehendes Projekt, Cron, Rechte |
+| [03_CONFIG_REFERENCE](docs/03_CONFIG_REFERENCE.md) | Jede Konstante mit Standardwert und Bedeutung |
+| [04_FUNCTION_API](docs/04_FUNCTION_API.md) | Die aufrufbaren Funktionen mit Rückgabewerten |
+| [05_BACKUP_SOURCES](docs/05_BACKUP_SOURCES.md) | Was gesichert wird, Datenbank-Dump, zusätzliche Ziele |
+| [06_UPDATE_PACKAGING](docs/06_UPDATE_PACKAGING.md) | Wie ein Release-Paket gebaut werden muss |
+| [07_POST_UPDATE_HOOKS](docs/07_POST_UPDATE_HOOKS.md) | Migrationen und Projekt-Callback nach dem Update |
+| [08_PROTOCOL](docs/08_PROTOCOL.md) | Die HTTP-Schnittstelle, für eigene Clients und Debugging |
+| [09_TROUBLESHOOTING](docs/09_TROUBLESHOOTING.md) | Jede Fehlermeldung mit Ursache und Behebung |
+| [10_SECURITY](docs/10_SECURITY.md) | Token, Berechtigungen, Checkliste vor dem Produktivgang |
+
+Empfohlene Reihenfolge beim ersten Mal: 01, 02, 05, 06. Der Rest ist Nachschlagewerk.
+
+## Beispiele
+
+| Datei | Inhalt |
+|---|---|
+| `examples/flat-file-project/` | Projekt mit JSON-Dateien, inklusive Beispielmigration |
+| `examples/mysql-project/` | Projekt mit MySQL, Datenbank-Dump und `ALTER TABLE`-Migration |
+| `examples/after-update.php` | Vorlage für den Post-Update-Callback |
+| `examples/integration-snippet.php` | Die Zeilen, die ein bestehendes Projekt braucht |
+| `examples/cron/manage-client.cron` | Fertige Crontab-Zeilen |
+
+## Befehle
+
+```text
+php manage-client/bin/manage-client.php status
+                                        check                      Exit 2 = Update verfügbar
+                                        update [--force] [--yes] [--skip-hook]
+                                        migrate [--dry-run]
+                                        backup [--trigger=cron]
+                                        heartbeat
+```
+
+Exit-Codes: `0` Erfolg, `1` Fehler, `2` Update verfügbar (nur bei `check`).
+`--quiet` unterdrückt die normale Ausgabe, Fehler gehen weiterhin auf STDERR.
+
+## Was der Client bewusst nicht tut
+
+- **Keine Wiederherstellung.** Backups werden erstellt und übertragen, aber nie
+  zurückgespielt. Ein Update sichert die überschriebenen Dateien, kann sie aber
+  nicht zurückholen. Beides ist bewusst Handarbeit.
+- **Keine automatischen Updates.** `update` ist immer eine bewusste Entscheidung.
+- **Kein Entfernen gelöschter Dateien.** Ein Update überlagert den Bestand; was im
+  neuen Release fehlt, bleibt liegen. Soll es verschwinden, gehört das in eine
+  Migration.
+- **Kein Wartungsmodus.** Die Anwendung bleibt während des Ausrollens erreichbar.
+
+## Anpassen
+
+Der Ordner wird pro Projekt gepflegt. Anpassungen an `ui/panel.php` – etwa an die
+Anmeldung oder das Aussehen des Projekts – sind vorgesehen und ausdrücklich
+erwünscht. Änderungen in `lib/` sollten sparsam bleiben, damit eine neuere Fassung
+des Client-Pakets noch übernommen werden kann.

+ 2 - 0
client-package/docs/.htaccess

@@ -0,0 +1,2 @@
+# The viewer serves Markdown through PHP; raw .md stays blocked by parent rules.
+Options -Indexes

+ 93 - 0
client-package/docs/01_QUICKSTART.md

@@ -0,0 +1,93 @@
+# Quickstart
+
+## Überblick
+
+Fünf Schritte von diesem Ordner bis zum ersten Backup. Ausführliche Erklärungen
+stehen in [02_INTEGRATION](02_INTEGRATION.md) und [03_CONFIG_REFERENCE](03_CONFIG_REFERENCE.md).
+
+Relevante Dateien:
+
+- `manage-client/` – der Ordner, der in das Projekt kopiert wird
+- `manage-client/config.sample.php` – Vorlage für die Konfiguration
+- `manage-client/bin/manage-client.php` – Kommandozeile
+- `manage-client/ui/panel.php` – fertige Oberfläche für den Adminbereich
+
+Voraussetzungen: PHP 8.0 oder neuer, die Erweiterung `zip` (für Updates), Schreibrechte
+auf dem Datenverzeichnis des Projekts. Weder Composer noch ein Build-Schritt werden benötigt.
+
+## 1. Ordner kopieren
+
+```bash
+cp -r manage-client /pfad/zum/projekt/manage-client
+```
+
+Der Ordner liegt üblicherweise direkt im Projektstamm, also neben `index.php`.
+Liegt er woanders, muss `MANAGE_APP_ROOT` angepasst werden.
+
+## 2. Instanz auf dem Server anlegen
+
+Im Manage-Server unter **Instanzen** eine neue Instanz anlegen. Direkt danach wird
+das Token **einmalig** angezeigt – zusammen mit einem fertigen Konfigurationsblock
+zum Kopieren. Danach ist das Token nicht mehr abrufbar; es kann nur neu erzeugt werden.
+
+## 3. Konfiguration anlegen
+
+```bash
+cd /pfad/zum/projekt/manage-client
+cp config.sample.php config.php
+```
+
+Mindestens diese Werte eintragen:
+
+```php
+define("MANAGE_SERVER_URL", "https://manage.example.org");
+define("MANAGE_INSTANCE",   "mein-projekt-prod");
+define("MANAGE_TOKEN",      "…");
+```
+
+Danach festlegen, was gesichert werden soll:
+
+```php
+define("MANAGE_BACKUP_SOURCES", [
+    ["as" => "data",         "glob" => "data/*.json"],
+    ["as" => "data/uploads", "dir"  => "data/uploads"],
+]);
+```
+
+`config.php` gehört **nicht** ins Repository des Projekts. Siehe [10_SECURITY](10_SECURITY.md).
+
+## 4. Verbindung prüfen
+
+```bash
+php manage-client/bin/manage-client.php status
+```
+
+Erwartete Ausgabe:
+
+```text
+Instanz:           mein-projekt-prod
+Server:            https://manage.example.org
+Konfiguriert:      ja
+Installierte Ver.: v1.0.0
+Aktuelles Release: v1.0.0
+Update verfügbar:  nein
+Lokale Backups:    0
+```
+
+Steht dort `Konfiguriert: NEIN` oder erscheint ein Fehler, hilft
+[09_TROUBLESHOOTING](09_TROUBLESHOOTING.md).
+
+## 5. Erstes Backup
+
+```bash
+php manage-client/bin/manage-client.php backup
+```
+
+Das Archiv liegt danach lokal unter `data/manage/backups/` und ist im Manage-Server
+unter **Backups** sichtbar.
+
+## Weiter
+
+- Oberfläche in den Adminbereich einbinden: [02_INTEGRATION](02_INTEGRATION.md)
+- Regelmäßige Backups per Cron: [02_INTEGRATION](02_INTEGRATION.md)
+- Release-Pakete richtig bauen: [06_UPDATE_PACKAGING](06_UPDATE_PACKAGING.md)

+ 141 - 0
client-package/docs/02_INTEGRATION.md

@@ -0,0 +1,141 @@
+# Integration in ein bestehendes Projekt
+
+## Überblick
+
+Der Client funktioniert eigenständig über die Kommandozeile. Zusätzlich lässt er
+sich in drei Stufen in das Projekt einbinden – von "gar nicht" bis "vollständig".
+
+Relevante Dateien:
+
+- `manage-client/lib/client.php` – einziger Einstiegspunkt, lädt alles Weitere
+- `manage-client/ui/panel.php` – vollständige Oberfläche
+- `manage-client/ui/status-partial.php` – kleiner Statusblock
+- `manage-client/bin/manage-client.php` – Kommandozeile für Cron
+
+## Stufe 1: Standalone
+
+Nichts zu tun. Der Client wird ausschließlich über die Kommandozeile bedient und
+das Projekt weiß nichts von ihm. Nur der Ordner muss vorhanden und konfiguriert sein.
+
+Diese Stufe genügt, wenn Updates und Backups von einer Person mit Shell-Zugang
+gepflegt werden.
+
+## Stufe 2: Oberfläche im Adminbereich
+
+`ui/panel.php` ist eine fertige Seite. Sie erwartet, dass das Projekt seine
+**eigene Anmeldung bereits geprüft hat**, bevor die Datei eingebunden wird.
+
+Neue Datei `admin/manage.php` im Projekt:
+
+```php
+<?php
+
+require_once __DIR__ . "/../config.php";
+require_once __DIR__ . "/../includes/functions.php";
+
+// Anmeldung des Projekts – hier steht, was das Projekt ohnehin verwendet.
+if (empty($_SESSION["admin_logged_in"])) {
+    header("Location: login.php");
+    exit;
+}
+
+require __DIR__ . "/../manage-client/ui/panel.php";
+```
+
+Das Panel bringt eine eigene Prüfung auf `$_SESSION["admin_logged_in"]` mit, damit
+ein direkter Aufruf nicht ungeschützt ist. Verwendet das Projekt ein anderes
+Session-Flag, gibt es zwei Möglichkeiten:
+
+1. den Guard in `ui/panel.php` anpassen (der Ordner wird ohnehin pro Projekt gepflegt), oder
+2. vor dem Einbinden `define("MANAGE_PANEL_SKIP_AUTH_GUARD", true);` setzen, wenn die
+   Anmeldung im aufrufenden Skript bereits sichergestellt ist.
+
+Die zweite Variante deaktiviert **nur** die zusätzliche Prüfung des Panels. Wer sie
+setzt, ohne vorher selbst zu prüfen, veröffentlicht Update- und Backup-Funktionen
+im Internet.
+
+### Statusblock auf einer bestehenden Seite
+
+Für eine Einstellungsseite genügt oft der kleine Block:
+
+```php
+<?php
+$manageStatusPanelUrl = "manage.php";
+include __DIR__ . "/../manage-client/ui/status-partial.php";
+?>
+```
+
+Er rendert nur ein Fragment, wirft nie eine Exception und zeigt Version,
+Update-Verfügbarkeit, letztes Backup und offene Migrationen.
+
+## Stufe 3: Funktionsaufrufe im Projekt
+
+Alle Funktionen aus [04_FUNCTION_API](04_FUNCTION_API.md) können direkt aufgerufen
+werden:
+
+```php
+require_once __DIR__ . "/manage-client/lib/client.php";
+
+// z. B. auf dem Admin-Dashboard: automatisches Backup, wenn fällig
+manageBackupCreateAutomaticIfDue();
+```
+
+Dieser Aufruf ist die Variante für Hosting ohne Cron: Er erstellt ein Backup, wenn
+seit dem letzten automatischen Backup `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` vergangen
+sind, und gibt sonst sofort `null` zurück. Ein Backup dauert je nach Datenmenge
+mehrere Sekunden – deshalb gehört der Aufruf auf eine selten geladene Adminseite,
+nicht auf jede Seite des Projekts.
+
+## Cron
+
+Cron ist der empfohlene Weg. Beispielzeilen liegen in
+`examples/cron/manage-client.cron`:
+
+```cron
+# Backup, jede Nacht um 03:20 Uhr
+20 3 * * * /usr/bin/php /pfad/zum/projekt/manage-client/bin/manage-client.php backup --trigger=cron --quiet
+
+# Statusmeldung an den Manage-Server, stündlich
+7 * * * * /usr/bin/php /pfad/zum/projekt/manage-client/bin/manage-client.php heartbeat --quiet
+
+# Update-Prüfung, werktags um 08:00 Uhr (Exit 2 = Update verfügbar)
+0 8 * * 1-5 /usr/bin/php /pfad/zum/projekt/manage-client/bin/manage-client.php check --quiet
+```
+
+`--quiet` unterdrückt die normale Ausgabe; Fehler gehen weiterhin auf STDERR und
+werden von Cron per Mail zugestellt. Updates werden bewusst **nicht** automatisch
+eingespielt: `update` bleibt eine bewusste Entscheidung.
+
+## .gitignore des Projekts
+
+Ins `.gitignore` des Projekts gehören:
+
+```gitignore
+manage-client/config.php
+data/manage/
+```
+
+Der übrige Inhalt von `manage-client/` **soll** eingecheckt werden, damit er Teil
+des Release-Pakets ist und mit ausgerollt wird.
+
+## Verzeichnisse und Rechte
+
+PHP braucht Schreibrechte auf:
+
+- `data/manage/backups/` – lokale Archive
+- `data/manage/work/` – Arbeitsverzeichnis für Updates (wird nach jedem Lauf geleert)
+- `data/manage/updates/` – Sicherungskopien der überschriebenen Dateien
+- den **gesamten Anwendungsstamm**, sofern Updates eingespielt werden sollen
+
+Fehlen Schreibrechte im Anwendungsstamm, schlägt ein Update mittendrin fehl. Siehe
+[09_TROUBLESHOOTING](09_TROUBLESHOOTING.md).
+
+Diese Verzeichnisse dürfen nicht über das Web erreichbar sein. Bei Apache erledigt
+das üblicherweise die `.htaccess` des Projekts; das mitgelieferte
+`manage-client/.htaccess` schützt zusätzlich `config.php`, `lib/` und `bin/`.
+
+## Weiter
+
+- [03_CONFIG_REFERENCE](03_CONFIG_REFERENCE.md) – alle Konstanten
+- [05_BACKUP_SOURCES](05_BACKUP_SOURCES.md) – was gesichert wird
+- [06_UPDATE_PACKAGING](06_UPDATE_PACKAGING.md) – wie Release-Pakete gebaut werden

+ 119 - 0
client-package/docs/03_CONFIG_REFERENCE.md

@@ -0,0 +1,119 @@
+# Konfigurationsreferenz
+
+## Überblick
+
+Alle Konstanten stehen in `manage-client/config.php`, kopiert aus
+`manage-client/config.sample.php`. Jede Konstante hat einen Standardwert in
+`manage-client/lib/client.php`; eine minimale `config.php` braucht nur die drei
+Verbindungswerte und `MANAGE_BACKUP_SOURCES`.
+
+Konstanten werden mit `define()` gesetzt, nicht als Array. Wird eine Konstante
+bereits vom Projekt definiert, gewinnt der Wert des Projekts.
+
+## Verbindung
+
+| Konstante | Standard | Bedeutung |
+|---|---|---|
+| `MANAGE_SERVER_URL` | `""` | Basis-URL des Manage-Servers, ohne `/api` und ohne Schrägstrich am Ende |
+| `MANAGE_INSTANCE` | `""` | Kennung der Instanz, wie auf dem Server angelegt |
+| `MANAGE_TOKEN` | `""` | Geheimes Token der Instanz, einmalig beim Anlegen angezeigt |
+| `MANAGE_HTTP_TIMEOUT` | `15` | Sekunden für Manifest und Heartbeat |
+| `MANAGE_HTTP_TIMEOUT_LONG` | `300` | Sekunden für Paket-Download und Backup-Upload |
+
+Fehlt einer der ersten drei Werte, melden alle Funktionen mit Serverkontakt einen
+Konfigurationsfehler. Lokale Backups funktionieren trotzdem, sofern
+`MANAGE_BACKUP_UPLOAD` auf `false` steht.
+
+## Anwendungslayout
+
+| Konstante | Standard | Bedeutung |
+|---|---|---|
+| `MANAGE_APP_ROOT` | Elternverzeichnis von `manage-client/` | Wurzel der Anwendung. Ziel des Updates, Basis aller relativen Backup-Pfade |
+| `MANAGE_VERSION_FILE` | `MANAGE_APP_ROOT . "/VERSION"` | Datei mit der installierten Version |
+| `MANAGE_VERSION_CONSTANT` | `null` | Name der Konstante in dieser Datei, oder `null` für eine reine Textdatei |
+| `MANAGE_WORK_DIR` | `data/manage/work/` | Arbeitsverzeichnis für Updates, wird nach jedem Lauf geleert |
+| `MANAGE_UPDATE_BACKUP_DIR` | `data/manage/updates/` | Sicherungskopien der überschriebenen Dateien |
+| `MANAGE_BACKUP_DIR` | `data/manage/backups/` | Lokale Backup-Archive |
+| `MANAGE_LOG_FILE` | `data/manage/manage-client.log` | JSONL-Protokoll des Clients |
+
+### Versionsdatei
+
+Zwei Varianten werden unterstützt.
+
+PHP-Datei mit Konstante (wie im PSA-Bestellsystem):
+
+```php
+define("MANAGE_VERSION_FILE", MANAGE_APP_ROOT . "/includes/version.php");
+define("MANAGE_VERSION_CONSTANT", "APP_VERSION");
+```
+
+Reine Textdatei, die nur `v1.2.3` enthält:
+
+```php
+define("MANAGE_VERSION_FILE", MANAGE_APP_ROOT . "/VERSION");
+define("MANAGE_VERSION_CONSTANT", null);
+```
+
+Die Version wird bei der Variante mit Konstante per regulärem Ausdruck aus der Datei
+gelesen und die Datei dabei **nicht** ausgeführt. Damit funktioniert das Auslesen
+auch dann, wenn die Konstante im laufenden Prozess bereits mit dem alten Wert
+definiert ist – zum Beispiel unmittelbar nach einem Update.
+
+Die Version selbst wird nie vom Client geschrieben: Sie ist Teil des Release-Pakets
+und ändert sich als Nebeneffekt des Dateikopierens.
+
+## Update
+
+| Konstante | Standard | Bedeutung |
+|---|---|---|
+| `MANAGE_UPDATE_PROTECTED_PATHS` | `["config.php", "data/", ".git/", "manage-client/config.php"]` | Pfade, die nie überschrieben werden |
+| `MANAGE_UPDATE_SANITY_PATHS` | `["index.php"]` | Das Paket muss mindestens einen dieser Pfade enthalten |
+| `MANAGE_UPDATE_POST_HOOK` | `null` | Callback nach erfolgreichem Update |
+| `MANAGE_MIGRATIONS_DIR` | `MANAGE_APP_ROOT . "/migrations"` | Verzeichnis mit Migrationsskripten, `null` deaktiviert sie |
+| `MANAGE_MIGRATIONS_STATE` | `data/manage/migrations.json` | Welche Migrationen bereits gelaufen sind |
+
+Pfade in `MANAGE_UPDATE_PROTECTED_PATHS` sind relativ zu `MANAGE_APP_ROOT`. Ein
+Eintrag mit Schrägstrich am Ende schützt das Verzeichnis samt Inhalt; ohne
+Schrägstrich wird der genaue Pfad geschützt, ein Verzeichnis aber ebenfalls
+mitsamt Inhalt. `config.php` und das Datenverzeichnis gehören immer dazu.
+
+`MANAGE_UPDATE_SANITY_PATHS` verhindert, dass ein völlig fremdes ZIP über die
+Anwendung kopiert wird. Der Wert sollte eine Datei oder ein Verzeichnis benennen,
+das in jedem Release enthalten ist.
+
+Hook und Migrationen sind in [07_POST_UPDATE_HOOKS](07_POST_UPDATE_HOOKS.md)
+beschrieben.
+
+## Backup
+
+| Konstante | Standard | Bedeutung |
+|---|---|---|
+| `MANAGE_BACKUP_SOURCES` | `[["as" => "data", "glob" => "data/*.json"]]` | Was ins Archiv kommt |
+| `MANAGE_BACKUP_DATABASE` | `null` | Optionaler MySQL-Dump |
+| `MANAGE_BACKUP_LOCAL_RETENTION` | `4` | Lokale Archive, die behalten werden. Minimum 1 |
+| `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` | `604800` | Intervall für `manageBackupCreateAutomaticIfDue()`, `0` deaktiviert |
+| `MANAGE_BACKUP_COMPRESS` | `true` | Einträge im ZIP komprimieren (Deflate) |
+| `MANAGE_BACKUP_UPLOAD` | `true` | Jedes neue Backup an den Manage-Server senden |
+| `MANAGE_BACKUP_REMOTE_TARGETS` | `[]` | Zusätzliche Ziele: `s3`, `sftp`, `custom` |
+
+Aufbau von Quellen und Zielen: [05_BACKUP_SOURCES](05_BACKUP_SOURCES.md).
+
+`MANAGE_BACKUP_COMPRESS` benötigt zlib, das in PHP standardmäßig vorhanden ist.
+Fehlt es, wird ohne Kompression geschrieben statt abzubrechen. Für Archive, die
+überwiegend aus JPEGs bestehen, bringt Kompression fast nichts; für SQL-Dumps sehr viel.
+
+Die lokale Aufbewahrung ist unabhängig von der Aufbewahrung auf dem Manage-Server.
+Auf dem Server wird sie zentral in den Servereinstellungen gepflegt, üblicherweise
+deutlich höher als lokal.
+
+## Zusammenspiel mit dem Projekt
+
+Definiert das Projekt eine Konstante bereits selbst, gewinnt sie, weil `config.php`
+des Clients vor den Standardwerten geladen wird und alle Standardwerte mit
+`if (!defined(...))` gesetzt sind. So kann ein Projekt seine Pfade zentral
+konfigurieren und der Client sie übernehmen.
+
+## Weiter
+
+- [04_FUNCTION_API](04_FUNCTION_API.md) – die aufrufbaren Funktionen
+- [09_TROUBLESHOOTING](09_TROUBLESHOOTING.md) – Fehlermeldungen und Ursachen

+ 230 - 0
client-package/docs/04_FUNCTION_API.md

@@ -0,0 +1,230 @@
+# Funktions-API
+
+## Überblick
+
+Alle Funktionen stehen nach einem einzigen `require` zur Verfügung:
+
+```php
+require_once __DIR__ . "/manage-client/lib/client.php";
+```
+
+Die Kommandozeile (`bin/manage-client.php`) und die Oberfläche (`ui/panel.php`)
+enthalten **keine eigene Logik**, sondern rufen genau diese Funktionen auf. Eine
+Funktion verhält sich deshalb identisch, egal wie sie ausgelöst wird.
+
+Fehler werden als `RuntimeException` geworfen. Ausnahmen von dieser Regel sind
+ausdrücklich vermerkt.
+
+## Status
+
+### `manageClientStatus(): array`
+
+Sammelaufruf für Oberflächen. **Wirft nie**: Jeder Fehler landet im Rückgabewert,
+damit eine Einstellungsseite auch bei nicht erreichbarem Server rendert.
+
+```php
+[
+    "instance"           => "mein-projekt-prod",
+    "server_url"         => "https://manage.example.org",
+    "configured"         => true,
+    "version"            => "v1.2.3",   // "" wenn nicht ermittelbar
+    "php_version"        => "8.3.6",
+    "update"             => [...],      // Rückgabe von manageUpdateCheck(), oder null
+    "update_error"       => null,       // Fehlermeldung, wenn die Prüfung scheiterte
+    "backups"            => [...],      // Rückgabe von manageBackupList()
+    "last_backup_at"     => "2026-08-20T09:21:04+00:00",
+    "pending_migrations" => [...],
+    "errors"             => [],         // nicht-fatale Warnungen
+]
+```
+
+### `manageClientVersion(): string`
+
+Installierte Version, zum Beispiel `"v1.2.3"`. Gibt `""` zurück, wenn sie nicht
+ermittelbar ist; das ist kein Fehler, sondern "unbekannt".
+
+### `manageClientConfigured(): bool`
+
+Ob Server-URL, Instanz und Token gesetzt sind.
+
+## Update
+
+### `manageUpdateCheck(): array`
+
+Holt das Manifest und vergleicht die Versionen.
+
+```php
+[
+    "current"   => "v1.2.3",
+    "latest"    => "v1.3.0",
+    "available" => true,
+    "manifest"  => [
+        "version"      => "v1.3.0",
+        "package_url"  => "https://…/api/v1/package.php?version=v1.3.0",
+        "sha256"       => "…",
+        "size"         => 421337,
+        "published_at" => "2026-08-20T09:20:43+00:00",
+    ],
+]
+```
+
+Ist die installierte Version unbekannt, gilt `available => true`, damit eine
+Installation ohne lesbare Versionsdatei nicht dauerhaft blockiert.
+
+### `manageUpdateApply(array $options = []): array`
+
+Lädt das Paket, prüft Größe und SHA-256, entpackt es, rollt es aus und führt
+anschließend den Post-Update-Schritt aus.
+
+Optionen:
+
+| Option | Bedeutung |
+|---|---|
+| `force` | Auch ausrollen, wenn keine neuere Version vorliegt |
+| `skip_hook` | Nur Dateien ausrollen, weder Migrationen noch Callback ausführen |
+
+Rückgabe:
+
+```php
+[
+    "deployed"        => true,
+    "from_version"    => "v1.2.3",
+    "to_version"      => "v1.3.0",
+    "copied"          => 128,
+    "backed_up"       => 126,
+    "skipped"         => 2,      // geschützte Pfade
+    "removed_backups" => 1,
+    "backup_dir"      => "/…/data/manage/updates/20260820-092114-v1.3.0",
+    "hook"            => [...],  // siehe unten
+]
+```
+
+Wirft, wenn das **Ausrollen** scheitert. Scheitert nur der Post-Update-Schritt,
+kehrt die Funktion normal zurück und `hook["success"]` ist `false` – die Dateien
+sind dann bereits ausgerollt. Aufrufer müssen beides unterscheiden; siehe
+[07_POST_UPDATE_HOOKS](07_POST_UPDATE_HOOKS.md).
+
+Wichtig: Das Ausrollen überschreibt Dateien im laufenden Betrieb. Es gibt keine
+Wartungsseite und keine Rücknahme. Die überschriebenen Dateien liegen als Kopie in
+`backup_dir`, aber ausschließlich für die manuelle Wiederherstellung.
+
+### `manageUpdatePendingMigrations(): array`
+
+Noch nicht ausgeführte Migrationen in Ausführungsreihenfolge:
+
+```php
+[["id" => "2026-08-20-01-add-index", "path" => "/…/migrations/2026-08-20-01-add-index.php"]]
+```
+
+### `manageUpdateRunMigrations(array $context = []): array`
+
+Führt die offenen Migrationen aus. **Wirft nicht**, sondern meldet:
+
+```php
+[
+    "success" => false,
+    "applied" => ["2026-08-20-01-add-index"],
+    "failed"  => "2026-08-20-02-backfill",
+    "error"   => "SQLSTATE[42S22]: …",
+    "pending" => 2,   // inklusive der fehlgeschlagenen
+]
+```
+
+## Backup
+
+### `manageBackupCreate(string $trigger = "manual"): array`
+
+Erstellt ein lokales Archiv und lädt es hoch, sofern `MANAGE_BACKUP_UPLOAD` aktiv ist.
+
+`$trigger` ist frei wählbar; üblich sind `manual`, `automatic`, `cron`, `update`.
+Nur `automatic` und `cron` zählen für die Intervallprüfung.
+
+```php
+[
+    "filename"       => "backup-20260820-092104.zip",
+    "created_at"     => "2026-08-20T09:21:04+00:00",
+    "trigger"        => "cron",
+    "size"           => 427,
+    "file_count"     => 3,
+    "source_bytes"   => 63,
+    "sha256"         => "…",
+    "app_version"    => "v1.2.3",
+    "database"       => null,   // oder ["tables" => 12, "rows" => 4711, …]
+    "remote_uploads" => [
+        ["target" => "Manage-Server", "type" => "manage", "success" => true, …],
+    ],
+]
+```
+
+Ein fehlgeschlagener Upload macht das lokale Archiv **nicht** ungültig: Der Fehler
+steht in `remote_uploads[].error` und im Protokoll, die Funktion wirft nicht. Wirft
+sie doch, ist das Archiv selbst nicht zustande gekommen.
+
+Gleichzeitige Läufe werden über eine Sperrdatei verhindert; der zweite Lauf wirft
+sofort `Es läuft bereits ein Backup.`
+
+### `manageBackupCreateAutomaticIfDue(): ?array`
+
+Erstellt ein Backup, wenn seit dem letzten automatischen Backup
+`MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` vergangen sind, sonst `null`. Für Hosting ohne
+Cron gedacht; gehört auf eine selten geladene Adminseite.
+
+### `manageBackupList(): array`
+
+Lokale Archive, neuestes zuerst. Selbstheilend: Einträge ohne Datei werden entfernt,
+Größen werden von der Festplatte aktualisiert.
+
+### `manageBackupUpload(string $archivePath, array $meta = []): array`
+
+Lädt ein vorhandenes Archiv zum Manage-Server. Wird von `manageBackupCreate()`
+automatisch aufgerufen und ist nur für Sonderfälle einzeln nötig – etwa um ein
+Archiv nach einem Serverausfall nachzureichen.
+
+### `manageBackupPath(string $filename): string`
+
+Absoluter Pfad eines lokalen Archivs. Validiert den Dateinamen streng, damit ein
+Download-Formular keinen beliebigen Pfad ausliefern kann.
+
+## Heartbeat
+
+### `manageHeartbeatSend(): array`
+
+Meldet Version, PHP-Version, freien Speicher, offene Migrationen und den Zeitpunkt
+des letzten Backups. Die Antwort enthält nebenbei die Update-Information:
+
+```php
+["success" => true, "latest" => "v1.3.0", "update_available" => true, "server_time" => "…"]
+```
+
+### `manageHeartbeatSendQuietly(): ?array`
+
+Wie oben, wirft aber nie und gibt bei Fehlern `null` zurück. Für Aufrufe innerhalb
+des Projekts, in denen ein nicht erreichbarer Server folgenlos bleiben soll.
+
+## Hilfsfunktionen
+
+| Funktion | Zweck |
+|---|---|
+| `manageFormatBytes(int $bytes): string` | `1.234.567` → `1,18 MB` |
+| `manageClientLog(string $level, string $message, array $context = []): void` | Eine Zeile ins Client-Protokoll. Wirft nie |
+| `manageRemoteCapabilities(): array` | Welche konfigurierten Zieltypen dieses System unterstützt |
+
+## Beispiel
+
+```php
+require_once __DIR__ . "/manage-client/lib/client.php";
+
+$check = manageUpdateCheck();
+if ($check["available"]) {
+    manageBackupCreate("update");          // vor dem Update sichern
+    $result = manageUpdateApply();
+
+    if (!$result["hook"]["success"]) {
+        // Dateien sind ausgerollt, der Post-Update-Schritt nicht.
+        error_log("Migration fehlgeschlagen: " . $result["hook"]["error"]);
+    }
+}
+```
+
+Ein Backup vor dem Update ist bewusst **nicht** eingebaut, sondern eine Zeile im
+Projekt – so bleibt sichtbar, dass es passiert.

+ 219 - 0
client-package/docs/05_BACKUP_SOURCES.md

@@ -0,0 +1,219 @@
+# Backup-Quellen und Ziele
+
+## Überblick
+
+Was gesichert wird, steht in `MANAGE_BACKUP_SOURCES`. Optional kommt ein
+MySQL-Dump dazu. Wohin gesichert wird, steuern `MANAGE_BACKUP_UPLOAD` und
+`MANAGE_BACKUP_REMOTE_TARGETS`.
+
+Relevante Dateien:
+
+- `manage-client/lib/backup.php` – Sammeln, Archivieren, Aufbewahrung, Upload
+- `manage-client/lib/zip.php` – ZIP-Erzeugung ohne externe Bibliothek
+- `manage-client/lib/mysql.php` – optionaler Datenbank-Dump
+- `manage-client/lib/remote.php` – zusätzliche Ziele
+
+## Quellen deklarieren
+
+Alle Pfade sind relativ zu `MANAGE_APP_ROOT`. Jeder Eintrag verwendet genau eine
+der drei Formen:
+
+```php
+define("MANAGE_BACKUP_SOURCES", [
+    // Nicht-rekursives Glob-Muster
+    ["as" => "data", "glob" => "data/*.json"],
+
+    // Verzeichnis, rekursiv
+    ["as" => "data/uploads", "dir" => "data/uploads"],
+
+    // Einzelne Datei
+    ["as" => "config", "file" => "config.dist.php"],
+]);
+```
+
+`as` ist das Präfix im Archiv. Ohne `as` landet der Eintrag auf oberster Ebene –
+bei `dir` wird der Verzeichnisname als Präfix verwendet.
+
+Das obige Beispiel erzeugt:
+
+```text
+data/orders.json
+data/settings.json
+data/uploads/2026/bild.jpg
+config/config.dist.php
+```
+
+### Was automatisch übersprungen wird
+
+- Dateien, deren Name mit einem Punkt beginnt
+- Dateien mit den Endungen `.tmp` und `.part`
+- nicht lesbare Dateien
+
+Ergeben zwei Quellen denselben Archivpfad, gewinnt die erste. Ein Archiv enthält
+nie zwei Einträge mit gleichem Namen.
+
+### Was **nicht** hineingehört
+
+- `config.php` mit echten Zugangsdaten – ein Backup wird an den Server übertragen
+  und dort heruntergeladen; siehe [10_SECURITY](10_SECURITY.md)
+- das Backup-Verzeichnis selbst
+- Protokolle und Cache-Verzeichnisse
+- die Anwendungsdateien: Die kommen aus dem Release-Paket, nicht aus dem Backup
+
+Ein Backup sichert **Betriebsdaten**, kein vollständiges Systemabbild.
+
+## Datenbank-Dump
+
+Nur aktiv, wenn `MANAGE_BACKUP_DATABASE` gesetzt ist:
+
+```php
+define("MANAGE_BACKUP_DATABASE", [
+    "dsn"      => "mysql:host=localhost;dbname=meinprojekt;charset=utf8mb4",
+    "user"     => "meinprojekt",
+    "password" => "…",
+
+    // Optional: von diesen Tabellen nur die Struktur sichern, nicht die Zeilen.
+    "skip_data_tables" => ["sessions", "cache"],
+
+    // Optional: Name im Archiv, falls SELECT DATABASE() nichts liefert.
+    "name" => "meinprojekt",
+]);
+```
+
+Der Dump landet im Archiv als `database/<name>.sql` und enthält für jede Tabelle
+`DROP TABLE IF EXISTS`, das `CREATE TABLE` aus `SHOW CREATE TABLE` und die Zeilen
+als einzelne `INSERT`-Anweisungen. `SET FOREIGN_KEY_CHECKS=0` steht am Anfang, am
+Ende wird wieder auf `1` gesetzt.
+
+Eigenschaften und Grenzen:
+
+- Nur PDO, kein `mysqldump`-Aufruf – das ist auf vielen Shared-Hostern die einzige
+  Möglichkeit, funktioniert aber nur für MySQL und MariaDB.
+- Die Zeilen werden in Blöcken von 500 gelesen und sofort geschrieben, sodass der
+  Speicherbedarf nicht mit der Tabellengröße wächst.
+- Binärwerte werden als `0x…`-Literal geschrieben, damit der Dump gültiges ASCII bleibt.
+- Views werden als Struktur gesichert, aber nicht mit Daten.
+- Der Dump ist **nicht** transaktional konsistent. Für eine Anwendung mit
+  gleichzeitigem Schreibverkehr sollte das Backup in einer ruhigen Zeit laufen.
+
+Wiederherstellung von Hand:
+
+```bash
+unzip -p backup-20260820-092104.zip database/meinprojekt.sql | mysql meinprojekt
+```
+
+## Kompression
+
+`MANAGE_BACKUP_COMPRESS` (Standard `true`) schaltet Deflate ein. Der Unterschied
+ist bei SQL-Dumps und JSON groß, bei bereits komprimierten Bildern praktisch null.
+Fehlt zlib, schreibt der Client unkomprimiert weiter statt abzubrechen.
+
+Grenzen des ZIP-Formats ohne Zip64: 4 GB pro Datei, 4 GB pro Archiv, 65535 Einträge.
+Wird eine Grenze überschritten, bricht das Backup mit einer klaren Meldung ab.
+
+## Ziele
+
+### Manage-Server
+
+Standardziel, aktiv über `MANAGE_BACKUP_UPLOAD` (Standard `true`). Konfiguriert
+wird nichts weiter: Es gelten `MANAGE_SERVER_URL`, `MANAGE_INSTANCE` und
+`MANAGE_TOKEN`. Der Server prüft die mitgesendete Prüfsumme erneut und lehnt
+abweichende Uploads ab.
+
+Für rein lokale Backups:
+
+```php
+define("MANAGE_BACKUP_UPLOAD", false);
+```
+
+### Zusätzliche Ziele
+
+Jedes Ziel wird unabhängig versucht. Ein Fehlschlag macht weder das lokale Archiv
+noch die anderen Ziele ungültig.
+
+S3-kompatibler Speicher:
+
+```php
+define("MANAGE_BACKUP_REMOTE_TARGETS", [
+    [
+        "name"       => "S3 Archiv",
+        "type"       => "s3",
+        "bucket"     => "example-bucket",
+        "region"     => "eu-central-1",
+        "prefix"     => "meinprojekt",
+        "access_key" => "AKIA…",
+        "secret_key" => "…",
+        // Für S3-kompatible Anbieter:
+        // "endpoint" => "https://fsn1.your-objectstorage.com",
+    ],
+]);
+```
+
+Signiert wird mit AWS Signature V4 über normale PHP-HTTPS-Streams; eine Bibliothek
+wird nicht benötigt. Das Archiv wird für die Signatur vollständig in den Speicher
+geladen – ein Backup größer als `memory_limit` kann dieses Ziel nicht nutzen.
+
+SFTP (benötigt die PHP-Erweiterung `ssh2`):
+
+```php
+[
+    "name"     => "SFTP Backup",
+    "type"     => "sftp",
+    "host"     => "backup.example.org",
+    "port"     => 22,
+    "username" => "backup-user",
+    "password" => "…",
+    "path"     => "/backups/meinprojekt",
+]
+```
+
+Mit Schlüssel statt Passwort:
+
+```php
+[
+    "type"        => "sftp",
+    "host"        => "backup.example.org",
+    "username"    => "backup-user",
+    "public_key"  => "/pfad/zu/backup.pub",
+    "private_key" => "/pfad/zu/backup",
+    "password"    => "",        // Passphrase des Schlüssels, sonst leer
+    "path"        => "/backups/meinprojekt",
+]
+```
+
+Das Zielverzeichnis muss existieren und beschreibbar sein; es wird nicht angelegt.
+
+Eigener Uploader:
+
+```php
+[
+    "name"     => "Eigenes Ziel",
+    "type"     => "custom",
+    "file"     => MANAGE_APP_ROOT . "/includes/backup-uploader.php",
+    "callback" => "myProjectUploadBackup",
+]
+```
+
+```php
+function myProjectUploadBackup(string $archivePath, array $metadata, array $target)
+{
+    // $metadata: filename, created_at, trigger, sha256, file_count, source_bytes
+    // Erfolg: true oder ein Array zurückgeben.
+    // Fehler: Exception werfen oder ["success" => false, "error" => "…"].
+    return ["remote_path" => "…"];
+}
+```
+
+## Protokollierung und Geheimnisse
+
+Fehlgeschlagene Uploads werden mit HTTP-Status und Antwortauszug protokolliert.
+Zugangsdaten sind davon ausgenommen: Aus der Zielkonfiguration wird nur eine
+Positivliste unkritischer Schlüssel übernommen (`name`, `type`, `url`, `bucket`,
+`region`, `prefix`, `endpoint`, `host`, `port`, `username`, `path`, `file`,
+`callback`, `timeout`). `access_key`, `secret_key` und `password` erscheinen nie
+im Protokoll.
+
+## Weiter
+
+- [04_FUNCTION_API](04_FUNCTION_API.md) – `manageBackupCreate()` und Rückgabewerte
+- [09_TROUBLESHOOTING](09_TROUBLESHOOTING.md) – Fehlermeldungen beim Backup

+ 131 - 0
client-package/docs/06_UPDATE_PACKAGING.md

@@ -0,0 +1,131 @@
+# Release-Pakete bauen
+
+## Überblick
+
+Der Updater rollt ein ZIP über den Anwendungsstamm aus: Jede Datei im Paket wird an
+dieselbe relative Position im Projekt kopiert. Damit das funktioniert, muss das
+Paket richtig geschnitten sein.
+
+Relevante Dateien:
+
+- `manage-client/lib/updater.php` – Prüfung, Entpacken, Ausrollen
+- auf dem Server: `scripts/create-release-zip.sh` – fertiges Build-Skript
+
+## Aufbau des Pakets
+
+Die Wurzel des ZIP **ist** der Anwendungsstamm. Kein zusätzliches Oberverzeichnis:
+
+```text
+richtig                        falsch
+------                         ------
+index.php                      meinprojekt-v1.3.0/index.php
+admin/orders.php               meinprojekt-v1.3.0/admin/orders.php
+includes/version.php           meinprojekt-v1.3.0/includes/version.php
+manage-client/lib/client.php
+migrations/2026-08-20-01-x.php
+```
+
+Ein Paket mit Oberverzeichnis würde das Projekt nicht aktualisieren, sondern einen
+neuen Unterordner anlegen.
+
+## Was hineingehört
+
+- alle Anwendungsdateien
+- `includes/version.php` (oder die konfigurierte Versionsdatei) mit der **neuen** Version
+- `manage-client/` **ohne** `config.php` – so wird der Client mit aktualisiert
+- `migrations/`, sofern das Release Migrationen mitbringt
+
+## Was draußen bleiben muss
+
+| Ausschluss | Grund |
+|---|---|
+| `config.php` | enthält Zugangsdaten der Zielinstallation |
+| `manage-client/config.php` | enthält Instanz-Token der Zielinstallation |
+| `data/` | Betriebsdaten der Zielinstallation |
+| `.git/` | gehört nicht auf einen Produktivserver |
+| `build/`, `storage/` | Artefakte |
+
+`config.php`, `data/` und `.git/` sind zusätzlich über
+`MANAGE_UPDATE_PROTECTED_PATHS` geschützt: Selbst wenn sie versehentlich im Paket
+landen, werden sie beim Ausrollen übersprungen. Der Ausschluss beim Bauen ist
+trotzdem nötig, weil das Paket sonst fremde Zugangsdaten enthält und auf dem
+Manage-Server heruntergeladen werden kann.
+
+## Versionsnummer
+
+Das Format ist `vX.Y.Z` – ohne Suffix, ohne Präfix. Sowohl der Client als auch der
+Server lehnen alles andere ab.
+
+Die Version steht an genau einer Stelle: in der Versionsdatei innerhalb des Pakets.
+Der Client schreibt sie nie selbst; sie ändert sich als Nebeneffekt des
+Dateikopierens. Wird sie beim Bauen vergessen, meldet die Instanz nach dem Update
+weiterhin die alte Version und bietet dasselbe Update erneut an.
+
+## Build mit dem mitgelieferten Skript
+
+Auf dem Manage-Server liegt `scripts/create-release-zip.sh`. Es wird einmal pro
+Projekt am Kopf angepasst (Produktname, Versionsdatei, Ausschlüsse) und dann im
+Projektverzeichnis aufgerufen:
+
+```bash
+./scripts/create-release-zip.sh v1.3.0
+```
+
+Das Skript
+
+1. schreibt die Version in die Versionsdatei,
+2. packt alle von Git verfolgten Dateien abzüglich der Ausschlussliste,
+3. gibt SHA-256 und Größe aus.
+
+Es verwendet `git ls-files`, damit nur eingecheckte Dateien im Paket landen –
+lokale Experimente und ignorierte Dateien bleiben automatisch draußen.
+
+## Build von Hand
+
+```bash
+cd /pfad/zum/projekt
+zip -r ../meinprojekt-v1.3.0.zip . \
+    -x 'config.php' \
+       'manage-client/config.php' \
+       'data/*' \
+       '.git/*' \
+       'build/*'
+```
+
+Prüfen, was tatsächlich drin ist – dieser Schritt lohnt sich immer:
+
+```bash
+unzip -l ../meinprojekt-v1.3.0.zip | head -30
+```
+
+## Veröffentlichen
+
+Im Manage-Server unter **Releases**: Version eintragen, ZIP hochladen. Prüfsumme
+und Größe berechnet der Server selbst; sie werden nie vom Hochladenden übernommen.
+Ein Upload setzt das Release automatisch als aktuell.
+
+## Was der Client beim Ausrollen prüft
+
+1. Größe und SHA-256 müssen dem Manifest entsprechen, sonst wird die Datei gelöscht.
+2. Jeder Eintrag im ZIP wird gegen Pfad-Ausbruch geprüft (`..`, absolute Pfade,
+   Laufwerksbuchstaben, Nullbytes).
+3. Das Paket muss mindestens einen der Pfade aus `MANAGE_UPDATE_SANITY_PATHS`
+   enthalten.
+4. Beim Kopieren wird jede vorhandene Zieldatei zuerst nach
+   `MANAGE_UPDATE_BACKUP_DIR` gesichert.
+
+## Grenzen des Verfahrens
+
+- **Gelöschte Dateien werden nicht entfernt.** Das Ausrollen ist ein Überlagern.
+  Eine Datei, die es im neuen Release nicht mehr gibt, bleibt in der Installation
+  liegen. Soll sie wirklich verschwinden, gehört das in eine Migration.
+- **Kein Wartungsmodus.** Die Anwendung bleibt während des Kopierens erreichbar.
+  Bei größeren Umbauten sollte in einer Randzeit aktualisiert werden.
+- **Keine Rücknahme.** Die Sicherungskopien in `MANAGE_UPDATE_BACKUP_DIR` sind für
+  die manuelle Wiederherstellung gedacht; es gibt keinen Befehl dafür. Aufbewahrt
+  wird nur der letzte Lauf.
+
+## Weiter
+
+- [07_POST_UPDATE_HOOKS](07_POST_UPDATE_HOOKS.md) – Migrationen im Paket
+- [04_FUNCTION_API](04_FUNCTION_API.md) – `manageUpdateApply()`

+ 234 - 0
client-package/docs/07_POST_UPDATE_HOOKS.md

@@ -0,0 +1,234 @@
+# Post-Update-Hook und Migrationen
+
+## Überblick
+
+Nach einem erfolgreichen Ausrollen führt der Client einen Post-Update-Schritt aus.
+Er besteht aus zwei unabhängigen Mechanismen, die einzeln oder gemeinsam genutzt
+werden:
+
+1. **Migrationen** – geordnete, einmalig laufende Skripte, die mit dem Release
+   ausgeliefert werden. Der übliche Ort für Datenbankänderungen.
+2. **Projekt-Callback** – eine Funktion des Projekts, die nach jedem Update läuft.
+   Für Cache leeren, abgeleitete Dateien neu bauen, Rechte setzen.
+
+Relevante Dateien:
+
+- `manage-client/lib/hooks.php` – beide Mechanismen
+- `MANAGE_MIGRATIONS_DIR`, `MANAGE_MIGRATIONS_STATE`, `MANAGE_UPDATE_POST_HOOK`
+
+Reihenfolge: erst die Migrationen, dann der Callback – damit der Callback sich auf
+das neue Schema verlassen kann. Scheitert eine Migration, wird der Callback
+**nicht** ausgeführt.
+
+## Migrationen
+
+### Ablage
+
+Migrationen liegen im Verzeichnis aus `MANAGE_MIGRATIONS_DIR` (Standard
+`migrations/` im Anwendungsstamm) und werden **mit dem Release-Paket ausgeliefert**.
+
+```text
+migrations/
+  2026-08-20-01-add-orders-index.php
+  2026-08-21-01-backfill-categories.php
+```
+
+Ausgeführt wird in **Dateinamen-Reihenfolge**. Ein Datum als Präfix mit laufender
+Nummer sortiert zuverlässig. Der Dateiname ohne `.php` ist die Kennung der
+Migration; wird eine bereits ausgeführte Datei umbenannt, läuft sie erneut.
+
+### Aufbau
+
+Empfohlene Form – die Datei gibt eine Funktion zurück:
+
+```php
+<?php
+
+return function (array $context): void {
+    $context["pdo"]->exec("ALTER TABLE orders ADD INDEX idx_created (created_at)");
+};
+```
+
+Alternativ definiert die Datei eine Funktion `up()`:
+
+```php
+<?php
+
+function up(array $context): void
+{
+    // …
+}
+```
+
+Die zurückgegebene Funktion ist die bessere Wahl: Zwei Migrationen, die beide `up()`
+definieren, würden sich im selben Prozess in die Quere kommen.
+
+### Der Kontext
+
+```php
+[
+    "app_root"     => "/var/www/meinprojekt",
+    "instance"     => "meinprojekt-prod",
+    "from_version" => "v1.2.3",
+    "to_version"   => "v1.3.0",
+    "backup_dir"   => "/…/data/manage/updates/20260820-092114-v1.3.0",
+    "run_id"       => "20260820-092114",
+    "migration_id" => "2026-08-20-01-add-orders-index",
+    "pdo"          => PDO,   // nur wenn MANAGE_BACKUP_DATABASE konfiguriert ist
+]
+```
+
+`pdo` verwendet die Zugangsdaten, die ohnehin für den Datenbank-Dump konfiguriert
+sind. Eine zweite Konfiguration ist nicht nötig. Projekte ohne Datenbank arbeiten
+mit `app_root`.
+
+### Beispiel: Datenbank
+
+```php
+<?php
+
+return function (array $context): void {
+    $pdo = $context["pdo"];
+
+    // Idempotent halten: die Migration kann nach einem Teilfehler erneut laufen.
+    $exists = $pdo->query(
+        "SELECT COUNT(*) FROM information_schema.statistics
+         WHERE table_schema = DATABASE()
+           AND table_name = 'orders'
+           AND index_name = 'idx_created'"
+    )->fetchColumn();
+
+    if ((int) $exists === 0) {
+        $pdo->exec("ALTER TABLE orders ADD INDEX idx_created (created_at)");
+    }
+};
+```
+
+### Beispiel: JSON-Daten
+
+```php
+<?php
+
+return function (array $context): void {
+    $file = $context["app_root"] . "/data/products.json";
+    $data = json_decode((string) file_get_contents($file), true) ?: [];
+
+    foreach ($data as $index => $product) {
+        if (!array_key_exists("category_id", $product)) {
+            $data[$index]["category_id"] = null;
+        }
+    }
+
+    file_put_contents(
+        $file,
+        json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
+    );
+};
+```
+
+### Zustand
+
+Ausgeführte Migrationen werden in `MANAGE_MIGRATIONS_STATE` festgehalten:
+
+```json
+{
+    "applied": [
+        {
+            "id": "2026-08-20-01-add-orders-index",
+            "applied_at": "2026-08-20T09:21:14+00:00",
+            "version": "v1.3.0",
+            "duration_ms": 42
+        }
+    ]
+}
+```
+
+Diese Datei liegt im Datenverzeichnis und ist damit von Updates ausgenommen. Sie
+sollte im Backup enthalten sein, wenn `data/manage/` in den Quellen steht – ist es
+standardmäßig nicht, weil das Backup-Verzeichnis sich sonst selbst sichern würde.
+Wer den Migrationszustand mitsichern will, nimmt ihn einzeln auf:
+
+```php
+["as" => "manage", "file" => "data/manage/migrations.json"],
+```
+
+## Projekt-Callback
+
+```php
+define("MANAGE_UPDATE_POST_HOOK", [
+    "file"     => MANAGE_APP_ROOT . "/includes/after-update.php",
+    "callback" => "myProjectAfterUpdate",
+]);
+```
+
+```php
+<?php
+
+function myProjectAfterUpdate(array $context): void
+{
+    // Cache leeren, abgeleitete Dateien neu bauen, Verzeichnis anlegen …
+    array_map("unlink", glob($context["app_root"] . "/data/cache/*.php") ?: []);
+}
+```
+
+Als Fehlschlag gilt: eine geworfene Exception, `return false` oder
+`return ["success" => false, "error" => "…"]`. Alles andere gilt als Erfolg.
+
+Der Callback erhält denselben Kontext wie eine Migration, zusätzlich `migrations`
+mit der Liste der in diesem Lauf ausgeführten Kennungen.
+
+## Fehlerverhalten
+
+Der wichtigste Punkt: **Zu diesem Zeitpunkt sind die Dateien bereits ausgerollt,
+und es gibt keine Rücknahme.** Ein Fehler wird deshalb laut gemeldet statt still
+verschluckt.
+
+Konkret:
+
+- Der Lauf stoppt bei der ersten fehlgeschlagenen Migration. Die folgenden bleiben
+  offen und werden nicht versucht.
+- Der Callback wird bei einer fehlgeschlagenen Migration übersprungen.
+- `manageUpdateApply()` kehrt **normal zurück**, mit `deployed => true` und
+  `hook["success"] => false`.
+- Die Kommandozeile beendet sich mit Exit-Code `1` und nennt die betroffene Datei –
+  meldet aber ausdrücklich, dass das Ausrollen erfolgreich war.
+- Die Oberfläche zeigt einen roten Hinweis mit dem Namen der Migration.
+- Beides steht im Client-Protokoll.
+
+Wiederherstellung nach einem Fehler:
+
+```bash
+# 1. Ursache beheben (Migration korrigieren, Rechte setzen, Datenbank prüfen)
+# 2. Offene Migrationen ansehen
+php manage-client/bin/manage-client.php migrate --dry-run
+# 3. Nachziehen
+php manage-client/bin/manage-client.php migrate
+```
+
+Ein erneutes `update --force` rollt die Dateien nochmals aus, führt aber **keine
+bereits ausgeführten Migrationen erneut aus**.
+
+## Idempotenz
+
+Migrationen sollen mehrfach ausführbar sein. Grund: Wenn eine Migration mittendrin
+scheitert – etwa nach der Hälfte einer Datenumstellung – wird sie nicht als
+ausgeführt vermerkt und läuft beim nächsten `migrate` erneut von vorn. Nur eine
+idempotente Migration übersteht das unbeschadet.
+
+Praktisch heißt das: vor dem Ändern prüfen, ob die Änderung schon da ist, und
+Datenumstellungen so schreiben, dass bereits umgestellte Sätze übersprungen werden.
+
+## Ohne Post-Update-Schritt ausrollen
+
+```bash
+php manage-client/bin/manage-client.php update --skip-hook
+```
+
+Rollt nur die Dateien aus. Die Migrationen bleiben offen und können später mit
+`migrate` nachgezogen werden. Nützlich, wenn die Dateien dringend gebraucht werden,
+die Datenbankänderung aber in ein Wartungsfenster gehört.
+
+## Weiter
+
+- [06_UPDATE_PACKAGING](06_UPDATE_PACKAGING.md) – Migrationen ins Paket bekommen
+- [04_FUNCTION_API](04_FUNCTION_API.md) – `manageUpdateRunMigrations()`

+ 171 - 0
client-package/docs/08_PROTOCOL.md

@@ -0,0 +1,171 @@
+# Protokoll v1
+
+## Überblick
+
+Die Schnittstelle zwischen Client und Manage-Server. Wer den mitgelieferten Client
+verwendet, braucht dieses Dokument nicht – es ist für eigene Clients, für Debugging
+und für die Fehlersuche mit `curl` gedacht.
+
+Basis-URL: `<MANAGE_SERVER_URL>/api/v1/`
+
+## Authentifizierung
+
+Jede Anfrage trägt zwei Header:
+
+```http
+X-Manage-Instance: meinprojekt-prod
+X-Manage-Token:    e4032c4dc51e9100…
+```
+
+Der Server speichert nur den SHA-256-Hash des Tokens und vergleicht in konstanter
+Zeit. Es gibt keine Sitzung, kein Cookie und kein gemeinsames Passwort.
+
+Fehlerantworten:
+
+| Status | Bedeutung |
+|---|---|
+| `401` | Header fehlen, Instanz unbekannt oder Token falsch – bewusst nicht unterscheidbar |
+| `403` | Instanz existiert, ist aber deaktiviert |
+| `405` | Falsche HTTP-Methode |
+| `429` | Zu viele fehlgeschlagene Authentifizierungen von dieser IP |
+
+Fehlgeschlagene Anmeldungen sind pro IP begrenzt, damit Instanz-Kennungen nicht
+durchprobiert werden können. Eine erfolgreiche Anmeldung setzt den Zähler zurück.
+
+Alle Fehlerantworten haben denselben Aufbau:
+
+```json
+{
+    "success": false,
+    "error": "Authentifizierung fehlgeschlagen."
+}
+```
+
+## GET manifest.php
+
+Liefert das Release, das die Instanz installieren soll.
+
+```bash
+curl -s https://manage.example.org/api/v1/manifest.php \
+  -H "X-Manage-Instance: meinprojekt-prod" \
+  -H "X-Manage-Token: $TOKEN"
+```
+
+```json
+{
+    "success": true,
+    "latest": "v1.3.0",
+    "version": "v1.3.0",
+    "package_url": "https://manage.example.org/api/v1/package.php?version=v1.3.0",
+    "sha256": "70f17aae44a9afdd948de1767daa61f936bbecb52096753757791e230a22f024",
+    "size": 2199,
+    "published_at": "2026-08-20T09:20:43+00:00"
+}
+```
+
+`404`, wenn kein gültiges Release veröffentlicht ist.
+
+`package_url` wird aus der Serverkonfiguration (`MANAGE_PUBLIC_URL`) gebildet, nicht
+aus dem `Host`-Header der Anfrage. Ein gefälschter Header kann einen Client daher
+nicht auf einen fremden Server umlenken.
+
+## GET package.php
+
+Liefert das Release-ZIP.
+
+```bash
+curl -s -o release.zip \
+  "https://manage.example.org/api/v1/package.php?version=v1.3.0" \
+  -H "X-Manage-Instance: meinprojekt-prod" \
+  -H "X-Manage-Token: $TOKEN"
+```
+
+Antwort: `application/zip` mit `Content-Length` und
+`Cache-Control: private, no-store`. Bei Erfolg kein JSON.
+
+`400` bei ungültigem Versionsformat, `404`, wenn das Release nicht existiert.
+
+Der Client vergleicht Größe und SHA-256 mit dem Manifest und löscht die Datei bei
+Abweichung. Ein eigener Client muss das ebenso tun – ohne diese Prüfung wird
+beliebiger Code ausgerollt.
+
+## POST backup.php
+
+Nimmt ein Backup-Archiv entgegen. `multipart/form-data`:
+
+| Feld | Pflicht | Bedeutung |
+|---|---|---|
+| `backup` | ja | die ZIP-Datei |
+| `filename` | nein | `backup-YYYYmmdd-HHMMSS[-N].zip`; ohne Angabe vergibt der Server einen Namen |
+| `sha256` | nein | Prüfsumme; wird serverseitig neu berechnet und verglichen |
+| `meta` | nein | JSON mit `trigger`, `file_count`, `source_bytes`, `app_version` |
+
+```bash
+curl -s https://manage.example.org/api/v1/backup.php \
+  -H "X-Manage-Instance: meinprojekt-prod" \
+  -H "X-Manage-Token: $TOKEN" \
+  -F "filename=backup-20260820-092104.zip" \
+  -F "sha256=824f3f80…" \
+  -F 'meta={"trigger":"cron","file_count":3}' \
+  -F "backup=@backup-20260820-092104.zip"
+```
+
+```json
+{
+    "success": true,
+    "instance": "meinprojekt-prod",
+    "filename": "backup-20260820-092104.zip",
+    "size": 427,
+    "sha256": "824f3f80…",
+    "retention": 30,
+    "s3": { "enabled": false, "uploaded": false, "pending": 0 }
+}
+```
+
+Der Server prüft in dieser Reihenfolge: Upload-Fehlercode, `is_uploaded_file`,
+Größenlimit, ZIP-Signatur, Dateinamensmuster, Prüfsumme nach dem Speichern. Weicht
+die Prüfsumme ab, wird die Datei wieder gelöscht und `400` gemeldet.
+
+Ein vorhandener Dateiname wird nie überschrieben: Der Server hängt `-2`, `-3` an.
+
+Fehler beim S3-Archivieren lassen den Upload **nicht** fehlschlagen – die lokale
+Kopie ist gespeichert und wird später nachgezogen.
+
+## POST heartbeat.php
+
+Statusmeldung. `application/json`:
+
+```json
+{
+    "version": "v1.3.0",
+    "php_version": "8.3.6",
+    "disk_free": 12884901888,
+    "pending_migrations": 0,
+    "last_backup_at": "2026-08-20T09:21:04+00:00"
+}
+```
+
+```json
+{
+    "success": true,
+    "instance": "meinprojekt-prod",
+    "latest": "v1.3.0",
+    "update_available": false,
+    "server_time": "2026-08-20T09:23:11+00:00"
+}
+```
+
+Alle Felder der Anfrage sind optional; fehlende Felder lassen den bisherigen Wert
+auf dem Server unverändert. Die Antwort ersetzt für einfache Überwachung einen
+eigenen Aufruf von `manifest.php`.
+
+## Nebenwirkung jeder Anfrage
+
+Jede erfolgreich authentifizierte Anfrage aktualisiert `last_seen_at` und die
+letzte IP der Instanz. Die Übersicht im Manage-Server bleibt dadurch aktuell, auch
+wenn nur Backups laufen und nie ein Heartbeat gesendet wird.
+
+## Weiter
+
+- [09_TROUBLESHOOTING](09_TROUBLESHOOTING.md) – was einzelne Fehlermeldungen bedeuten
+- [10_SECURITY](10_SECURITY.md) – Umgang mit dem Token

+ 225 - 0
client-package/docs/09_TROUBLESHOOTING.md

@@ -0,0 +1,225 @@
+# Fehlersuche
+
+## Überblick
+
+Jede Fehlermeldung, die der Client erzeugen kann, mit Ursache und Behebung.
+Die Meldungen stammen aus `manage-client/lib/`.
+
+Erste Anlaufstelle ist immer:
+
+```bash
+php manage-client/bin/manage-client.php status
+```
+
+und danach das Protokoll in `MANAGE_LOG_FILE` (Standard
+`data/manage/manage-client.log`, eine JSON-Zeile pro Ereignis):
+
+```bash
+tail -20 data/manage/manage-client.log | php -r 'while($l=fgets(STDIN)) { $e=json_decode($l,true); echo $e["timestamp"]," ",$e["level"]," ",$e["message"],"\n"; }'
+```
+
+## Konfiguration und Verbindung
+
+**`Manage-Client ist nicht konfiguriert. MANAGE_SERVER_URL, MANAGE_INSTANCE und MANAGE_TOKEN müssen in manage-client/config.php gesetzt sein.`**
+`config.php` fehlt oder einer der drei Werte ist leer. `config.sample.php` kopieren
+und die Werte aus dem Manage-Server eintragen. Prüfen, ob die Datei wirklich
+`manage-client/config.php` heißt.
+
+**`Manage-Server ist nicht erreichbar: <URL>`**
+Keine Antwort. Mögliche Ursachen: falsche `MANAGE_SERVER_URL`, DNS, Firewall,
+ausgehende Verbindungen auf dem Hoster gesperrt, TLS-Zertifikat nicht vertrauenswürdig.
+Prüfen mit `curl -v <URL>/api/v1/manifest.php` vom selben Server aus.
+
+**`Ungültige Server-URL: <URL>`**
+`MANAGE_SERVER_URL` ist keine gültige URL. Sie muss mit `https://` beginnen und darf
+weder `/api` noch einen Schrägstrich am Ende enthalten.
+
+**`Authentifizierung fehlgeschlagen. (HTTP 401)`**
+Instanz-Kennung oder Token stimmen nicht. Beides ist absichtlich nicht
+unterscheidbar. Im Manage-Server unter **Instanzen** ein neues Token erzeugen und
+eintragen; das alte wird dabei sofort ungültig.
+
+**`Diese Instanz ist deaktiviert. (HTTP 403)`**
+Die Instanz existiert, ist aber im Manage-Server deaktiviert. Dort wieder aktivieren.
+
+**`Zu viele Anfragen. Bitte später erneut versuchen. (HTTP 429)`**
+Zu viele fehlgeschlagene Authentifizierungen von dieser IP. Nach Ablauf des
+Zeitfensters (Standard 5 Minuten) mit korrektem Token erneut versuchen.
+
+**`Antwort des Servers ist kein gültiges JSON.`**
+Die Antwort kam nicht vom Manage-Server: meist eine Fehlerseite des Webservers,
+eine Umleitung oder ein Captive Portal. Antwort direkt mit `curl` ansehen.
+
+## Update
+
+**`Es ist kein neueres Update verfügbar. Mit der Option "force" kann dasselbe Paket erneut ausgerollt werden.`**
+Kein Fehler. Auf der Kommandozeile `update --force`, in der Oberfläche das Häkchen
+"erneut ausrollen".
+
+**`Version im Manifest ist ungültig.` / `Prüfsumme im Manifest ist ungültig.` / `Paket-URL im Manifest ist ungültig.`**
+Der Server liefert ein unbrauchbares Manifest. Auf dem Server prüfen, ob ein Release
+veröffentlicht und als aktuell gesetzt ist. Bei "Paket-URL ungültig" ist meist
+`MANAGE_PUBLIC_URL` in der Serverkonfiguration nicht oder falsch gesetzt.
+
+**`Größe des heruntergeladenen Pakets stimmt nicht überein.` / `Prüfsumme des Pakets stimmt nicht überein.`**
+Das Paket entspricht nicht dem Manifest. Die heruntergeladene Datei wird sofort
+gelöscht und **nichts** ausgerollt. Ursachen: abgebrochener Download, ein Proxy der
+den Inhalt verändert, oder ein auf dem Server ausgetauschtes Paket. Release neu
+hochladen und erneut versuchen. Wiederholt sich der Fehler, ist die Übertragungskette
+zu prüfen, bevor ausgerollt wird.
+
+**`Das heruntergeladene Paket ist keine lesbare ZIP-Datei.`**
+Die Datei ist beschädigt oder es wurde etwas anderes als ein ZIP hochgeladen.
+
+**`Das Paket enthält einen unsicheren Pfad: <Pfad>`**
+Ein Eintrag versucht aus dem Zielverzeichnis auszubrechen (`..`, absoluter Pfad,
+Laufwerksbuchstabe, Nullbyte). Es wird nichts entpackt. Ein solches Paket darf nicht
+ausgerollt werden – Herkunft klären.
+
+**`Das Paket sieht nicht wie ein Release dieser Anwendung aus (erwartet: index.php)`**
+Keiner der Pfade aus `MANAGE_UPDATE_SANITY_PATHS` ist im Paket. Fast immer wurde das
+ZIP mit einem Oberverzeichnis gebaut. Siehe
+[06_UPDATE_PACKAGING](06_UPDATE_PACKAGING.md).
+
+**`Die PHP-Erweiterung ZipArchive ist nicht verfügbar.`**
+`ext-zip` fehlt. Updates brauchen sie; Backups funktionieren auch ohne, weil der
+Client dort einen eigenen ZIP-Writer verwendet. Beim Hoster aktivieren lassen.
+
+**`Datei konnte nicht ausgerollt werden: <Pfad>` / `Datei konnte nicht gesichert werden: <Pfad>`**
+Fehlende Schreibrechte im Anwendungsstamm. **Wichtig:** Dieser Fehler tritt
+mittendrin auf, das Ausrollen ist dann unvollständig. Rechte korrigieren und
+`update --force` erneut ausführen – der Lauf beginnt von vorn und stellt den
+vollständigen Zustand her.
+
+**`Verzeichnis konnte nicht erstellt werden: <Pfad>`**
+Fehlende Schreibrechte auf dem übergeordneten Verzeichnis.
+
+**`Altes Backup-Verzeichnis konnte nicht entfernt werden: <Pfad>`**
+Das Ausrollen war erfolgreich, nur das Aufräumen alter Sicherungen scheiterte.
+Verzeichnis von Hand entfernen.
+
+**`MANAGE_APP_ROOT existiert nicht: <Pfad>`**
+Der konfigurierte Anwendungsstamm ist falsch. Standard ist das Elternverzeichnis von
+`manage-client/`.
+
+## Migrationen und Hook
+
+**`Migration <id> liefert keine Funktion zurück und definiert kein up().`**
+Die Migrationsdatei muss entweder eine Funktion zurückgeben (`return function (array $context) {...};`)
+oder eine Funktion `up(array $context)` definieren.
+
+**Migration schlägt mit einem eigenen Fehler fehl**
+Der Lauf stoppt, die folgenden Migrationen bleiben offen, die Dateien sind aber
+bereits ausgerollt. Ursache beheben, dann:
+
+```bash
+php manage-client/bin/manage-client.php migrate --dry-run
+php manage-client/bin/manage-client.php migrate
+```
+
+**`Hook-Datei wurde nicht gefunden: <Pfad>` / `Hook-Callback ist nicht aufrufbar: <Name>`**
+`MANAGE_UPDATE_POST_HOOK` verweist auf eine fehlende Datei oder eine Funktion, die
+dort nicht definiert wird. Häufig, wenn die Hook-Datei nicht im Release-Paket liegt.
+
+**`Post-Update-Hook meldet einen Fehler`**
+Der Callback hat `false` oder `["success" => false]` zurückgegeben. Die Dateien sind
+ausgerollt; die Details stehen im Protokoll.
+
+## Backup
+
+**`Es läuft bereits ein Backup.`**
+Die Sperrdatei ist belegt: ein zweiter Lauf startete, während der erste noch lief.
+Meist überlappen Cron-Job und manueller Aufruf. Warten und erneut versuchen. Bleibt
+es dauerhaft, wurde ein früherer Lauf hart abgebrochen – die Sperre löst sich mit
+dem Prozessende von selbst; hilft das nicht, `data/manage/backups/.backup.lock`
+entfernen, wenn sicher kein Backup läuft.
+
+**`Keine Dateien für das Backup gefunden.` / `Keine lesbaren Dateien für das Backup gefunden.`**
+`MANAGE_BACKUP_SOURCES` trifft auf keine existierende Datei. Pfade sind relativ zu
+`MANAGE_APP_ROOT`. Prüfen mit:
+
+```bash
+php -r 'require "manage-client/lib/client.php"; print_r(manageBackupCollectSources());'
+```
+
+**`Backup-ZIP konnte nicht erstellt werden.` / `Backup-ZIP konnte nicht finalisiert werden.`**
+Keine Schreibrechte auf `MANAGE_BACKUP_DIR` oder die Festplatte ist voll.
+
+**`Datei ist zu groß für dieses Backup-Format: <Name>` / `Backup-ZIP ist zu groß für dieses Backup-Format.` / `Zu viele Dateien für dieses Backup-Format.`**
+Grenzen des ZIP-Formats ohne Zip64: 4 GB pro Datei, 4 GB pro Archiv, 65535 Einträge.
+Quellen aufteilen oder große Mediendateien getrennt sichern.
+
+**`Ungültiger Pfad im Backup: <Name>` / `Pfad im Backup ist zu lang: <Name>`**
+Ein Dateiname enthält ungültige Zeichen oder das `as`-Präfix erzeugt einen ungültigen
+Archivpfad. Präfixe ohne führenden Schrägstrich und ohne `..` verwenden.
+
+**`Der Manage-Server hat das Backup abgelehnt: Ungültiger Backup-Dateiname.`**
+Der Dateiname entspricht nicht `backup-YYYYmmdd-HHMMSS[-N].zip`. Tritt nur bei
+selbst gebauten Uploads auf.
+
+**`Der Manage-Server hat das Backup abgelehnt: Prüfsumme des Backups stimmt nicht überein.`**
+Das Archiv wurde unterwegs verändert oder unvollständig übertragen. Die Datei wird
+serverseitig gelöscht. Das lokale Archiv ist in Ordnung; erneut versuchen.
+
+**`Backup überschreitet das Upload-Limit des Servers (upload_max_filesize / post_max_size).`**
+Die PHP-Grenzen auf dem **Manage-Server** sind kleiner als das Archiv. Dort
+`upload_max_filesize` und `post_max_size` erhöhen (beide!) oder den Backup-Umfang
+reduzieren. Die aktuellen Werte stehen im Manage-Server unter **Einstellungen → Diagnose**.
+
+Der Upload-Fehler macht das lokale Archiv nicht ungültig – es liegt vollständig in
+`data/manage/backups/`.
+
+## Datenbank
+
+**`Die PHP-PDO-Erweiterung ist nicht verfügbar.`**
+`pdo_mysql` fehlt. Ohne sie kann kein Dump erstellt werden;
+`MANAGE_BACKUP_DATABASE` auf `null` setzen oder die Erweiterung aktivieren lassen.
+
+**`Datenbankverbindung fehlgeschlagen: <Meldung>`**
+DSN, Benutzer oder Passwort stimmen nicht, oder der Server ist nicht erreichbar. Die
+Originalmeldung von PDO steht dahinter.
+
+**`MANAGE_BACKUP_DATABASE benötigt einen DSN.`**
+Das Array ist gesetzt, aber `dsn` fehlt oder ist leer.
+
+**`Datenbank-Dump fehlgeschlagen: <Meldung>`**
+Meist fehlende Rechte: Der Benutzer braucht `SELECT` und `SHOW VIEW` auf allen
+Tabellen. Der unvollständige Dump wird gelöscht, das Backup bricht ab.
+
+## Zusätzliche Ziele
+
+**`Die PHP-SSH2-Erweiterung ist nicht verfügbar.`**
+SFTP braucht `ext-ssh2`. Ohne sie schlägt nur dieses Ziel fehl; das lokale Archiv
+und alle anderen Ziele bleiben davon unberührt.
+
+**`SFTP-Zieldatei konnte nicht geöffnet werden. Existiert das Verzeichnis?`**
+Das entfernte Verzeichnis muss vorhanden und beschreibbar sein; es wird nicht angelegt.
+
+**`S3-Ziel ist unvollständig konfiguriert.`**
+`bucket`, `region`, `access_key` und `secret_key` sind alle Pflicht.
+
+**`S3-Upload fehlgeschlagen (HTTP 403)`**
+Bei `SignatureDoesNotMatch` stimmen Region oder Secret Key nicht. Bei `AccessDenied`
+passt meist die Adressierungsart nicht – für S3-kompatible Anbieter `endpoint` setzen.
+
+**`Unbekannter Backup-Zieltyp: <typ>`**
+`type` muss `s3`, `sftp` oder `custom` sein. Der frühere Typ `managed` entfällt: Der
+Upload zum Manage-Server ist eingebaut und wird über `MANAGE_BACKUP_UPLOAD` gesteuert.
+
+## Rechte auf einen Blick
+
+```bash
+# Schreibrechte für PHP prüfen
+php -r '
+foreach (["data/manage/backups", "data/manage/work", "data/manage/updates", "."] as $d) {
+    printf("%-26s %s\n", $d, is_writable($d) ? "beschreibbar" : "NICHT beschreibbar");
+}'
+```
+
+Der letzte Eintrag `.` ist der Anwendungsstamm – ohne Schreibrecht dort sind keine
+Updates möglich.
+
+## Weiter
+
+- [03_CONFIG_REFERENCE](03_CONFIG_REFERENCE.md) – alle Konstanten
+- [08_PROTOCOL](08_PROTOCOL.md) – Anfragen mit `curl` nachstellen

+ 139 - 0
client-package/docs/10_SECURITY.md

@@ -0,0 +1,139 @@
+# Sicherheit
+
+## Überblick
+
+Was der Client tut, wem er vertraut und was in der Verantwortung des Projekts bleibt.
+
+## Das Token
+
+Das Instanz-Token ist ein 64-stelliger Hexadezimalwert aus 32 zufälligen Bytes. Es
+ist das einzige Geheimnis zwischen Instanz und Server.
+
+- Es wird **einmalig** beim Anlegen der Instanz angezeigt. Der Server speichert nur
+  den SHA-256-Hash und kann es nicht wieder ausgeben.
+- Es steht ausschließlich in `manage-client/config.php`.
+- Diese Datei gehört **nicht ins Repository** und **nicht ins Release-Paket**.
+- Bei Verdacht auf Kompromittierung im Manage-Server unter **Instanzen** →
+  "Token erneuern". Das alte Token ist damit sofort ungültig.
+
+```gitignore
+manage-client/config.php
+data/manage/
+```
+
+Ein kompromittiertes Token erlaubt: Releases herunterzuladen und Backups
+hochzuladen – also Zugriff auf den Anwendungscode und das Belegen von
+Speicherplatz. Es erlaubt **nicht**, Backups herunterzuladen oder Releases zu
+verändern; beides geht nur über die Anmeldung am Manage-Server.
+
+## Transport
+
+Alle Anfragen laufen über HTTPS. Der Client verwendet die PHP-Standardeinstellungen
+für die Zertifikatsprüfung; diese wird **nicht** abgeschaltet. Ein Server mit
+selbstsigniertem Zertifikat funktioniert deshalb nicht ohne passendes CA-Bundle im
+System – das ist Absicht.
+
+Umleitungen werden nicht verfolgt (`follow_location => 0`). Eine umgeleitete
+Anfrage schlägt fehl, statt Zugangsdaten an ein anderes Ziel zu senden.
+
+## Vertrauen ins Release-Paket
+
+Ein Update rollt fremden Code auf dem Server aus. Abgesichert ist das durch:
+
+1. **TLS** zum Manage-Server.
+2. **Token-Pflicht** für Manifest und Paket – beides ist nicht öffentlich abrufbar.
+3. **SHA-256-Prüfung** von Größe und Inhalt gegen das Manifest. Bei Abweichung wird
+   die Datei gelöscht und nichts ausgerollt.
+4. **Pfadprüfung** jedes ZIP-Eintrags gegen Ausbruch aus dem Zielverzeichnis.
+5. **Plausibilitätsprüfung** über `MANAGE_UPDATE_SANITY_PATHS`.
+
+Die Grenze dieses Modells: Die Prüfsumme kommt vom selben Server wie das Paket. Wer
+den Manage-Server übernimmt, kann ein Paket **und** die passende Prüfsumme
+veröffentlichen. Eine Signatur mit einem im Client hinterlegten öffentlichen
+Schlüssel gibt es bewusst nicht – der Manage-Server muss entsprechend abgesichert
+werden.
+
+## Backups enthalten Betriebsdaten
+
+Ein Backup wird an den Manage-Server übertragen, dort gespeichert und kann von jedem
+heruntergeladen werden, der sich am Manage-Server anmeldet. Daraus folgt:
+
+- **Keine Zugangsdaten ins Backup.** `config.php` mit Datenbankpasswörtern oder
+  API-Schlüsseln gehört nicht in `MANAGE_BACKUP_SOURCES`.
+- Enthält das Backup personenbezogene Daten – bei Bestell- oder Kundendaten die
+  Regel – gelten für den Manage-Server dieselben Anforderungen wie für die
+  Anwendung selbst: Zugriffsschutz, Verschlüsselung im Transport, Löschfristen.
+  Die Aufbewahrung auf dem Server ist einstellbar.
+- Das S3-Ziel legt Archive unverschlüsselt im Bucket ab. Der Bucket muss privat sein.
+
+## Lokale Verzeichnisse
+
+Diese Verzeichnisse dürfen nicht über das Web erreichbar sein:
+
+| Pfad | Inhalt |
+|---|---|
+| `data/manage/backups/` | vollständige Betriebsdaten |
+| `data/manage/updates/` | Kopien der überschriebenen Anwendungsdateien |
+| `data/manage/work/` | entpackte Pakete während eines Updates |
+| `manage-client/config.php` | Instanz-Token |
+
+Das mitgelieferte `manage-client/.htaccess` sperrt `config.php`, `lib/` und `bin/`.
+Für `data/` ist die `.htaccess` des Projekts zuständig. Auf nginx müssen die
+entsprechenden `location`-Regeln von Hand gesetzt werden – dort greift keine
+`.htaccess`.
+
+Prüfen lässt sich das direkt:
+
+```bash
+curl -s -o /dev/null -w "%{http_code}\n" https://meinprojekt.example.org/manage-client/config.php
+curl -s -o /dev/null -w "%{http_code}\n" https://meinprojekt.example.org/data/manage/backups/
+```
+
+Beides muss `403` oder `404` liefern, niemals `200`.
+
+## Die Oberfläche
+
+`ui/panel.php` erlaubt es, Updates auszurollen und Backups herunterzuladen – es ist
+die mächtigste Seite der Anwendung. Deshalb:
+
+- Das Projekt muss seine Anmeldung **vor** dem Einbinden prüfen.
+- Das Panel bringt eine zusätzliche Prüfung auf `$_SESSION["admin_logged_in"]` mit.
+- `MANAGE_PANEL_SKIP_AUTH_GUARD` deaktiviert nur diese zusätzliche Prüfung. Wer sie
+  setzt, ohne selbst zu prüfen, veröffentlicht Update- und Backup-Funktionen im Netz.
+- Alle Formulare sind CSRF-geschützt.
+- Der Download validiert den Dateinamen streng, damit kein beliebiger Pfad
+  ausgeliefert werden kann.
+
+Nach Möglichkeit sollte die Seite nur Administratoren zugänglich sein, nicht allen
+angemeldeten Benutzern.
+
+## Kommandozeile
+
+`bin/manage-client.php` verweigert die Ausführung über HTTP (`PHP_SAPI`-Prüfung) und
+ist zusätzlich per `.htaccess` gesperrt. Auf dem Server sollte die Datei trotzdem
+nicht im öffentlichen Verzeichnisbaum liegen, wenn sich das vermeiden lässt.
+
+## Protokolle
+
+Das Client-Protokoll enthält Dateinamen, Versionen, HTTP-Status und Fehlermeldungen.
+Zugangsdaten werden ausgefiltert: Aus Zielkonfigurationen übernimmt der Client nur
+eine Positivliste unkritischer Schlüssel; `access_key`, `secret_key`, `password` und
+das Instanz-Token erscheinen nie im Protokoll.
+
+Antwortauszüge von fehlgeschlagenen Uploads werden auf 500 Zeichen gekürzt.
+
+## Checkliste vor dem Produktivgang
+
+- [ ] `manage-client/config.php` ist in `.gitignore`
+- [ ] `config.php` und `manage-client/config.php` sind aus dem Release-Paket ausgeschlossen
+- [ ] `data/manage/` ist über das Web nicht erreichbar (geprüft mit `curl`)
+- [ ] `manage-client/config.php` ist über das Web nicht erreichbar (geprüft mit `curl`)
+- [ ] Die Panel-Seite verlangt eine Administrator-Anmeldung
+- [ ] `MANAGE_SERVER_URL` verwendet `https://`
+- [ ] Im Backup stecken keine Zugangsdaten
+- [ ] Ein Backup wurde einmal heruntergeladen und der Inhalt geprüft
+
+## Weiter
+
+- [08_PROTOCOL](08_PROTOCOL.md) – Authentifizierung im Detail
+- [05_BACKUP_SOURCES](05_BACKUP_SOURCES.md) – was ins Archiv gehört

+ 200 - 0
client-package/docs/assets/docs.css

@@ -0,0 +1,200 @@
+:root {
+    color-scheme: light;
+    --docs-bg: #f5f6f8;
+    --docs-surface: #fff;
+    --docs-text: #1a1a1a;
+    --docs-muted: #5c6370;
+    --docs-accent: #003366;
+    --docs-border: #d8dde6;
+    --docs-code-bg: #eef1f5;
+    font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+    line-height: 1.6;
+}
+
+*,
+*::before,
+*::after {
+    box-sizing: border-box;
+}
+
+body {
+    margin: 0;
+    background: var(--docs-bg);
+    color: var(--docs-text);
+}
+
+.docs-header {
+    background: var(--docs-accent);
+    color: #fff;
+}
+
+.docs-header-inner {
+    max-width: 72rem;
+    margin: 0 auto;
+    padding: 0.75rem 1.25rem;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 1rem;
+}
+
+.docs-brand,
+.docs-back {
+    color: inherit;
+    text-decoration: none;
+}
+
+.docs-back {
+    font-size: 0.9rem;
+    opacity: 0.9;
+}
+
+.docs-back:hover,
+.docs-brand:hover {
+    text-decoration: underline;
+}
+
+.docs-layout {
+    max-width: 72rem;
+    margin: 0 auto;
+    padding: 1.25rem;
+    display: grid;
+    grid-template-columns: minmax(12rem, 16rem) 1fr;
+    gap: 1.5rem;
+    align-items: start;
+}
+
+@media (max-width: 768px) {
+    .docs-layout {
+        grid-template-columns: 1fr;
+    }
+}
+
+.docs-nav {
+    background: var(--docs-surface);
+    border: 1px solid var(--docs-border);
+    border-radius: 0.5rem;
+    padding: 1rem;
+    position: sticky;
+    top: 1rem;
+}
+
+.docs-nav-title {
+    margin: 0 0 0.5rem;
+    font-size: 0.75rem;
+    text-transform: uppercase;
+    letter-spacing: 0.04em;
+    color: var(--docs-muted);
+}
+
+.docs-nav ul {
+    margin: 0;
+    padding: 0;
+    list-style: none;
+}
+
+.docs-nav li + li {
+    margin-top: 0.25rem;
+}
+
+.docs-nav a {
+    display: block;
+    padding: 0.35rem 0.5rem;
+    border-radius: 0.25rem;
+    color: var(--docs-accent);
+    text-decoration: none;
+    font-size: 0.9rem;
+}
+
+.docs-nav a:hover {
+    background: var(--docs-code-bg);
+}
+
+.docs-nav a[aria-current="page"] {
+    background: var(--docs-accent);
+    color: #fff;
+}
+
+.docs-main {
+    background: var(--docs-surface);
+    border: 1px solid var(--docs-border);
+    border-radius: 0.5rem;
+    padding: 1.5rem 2rem;
+    min-width: 0;
+}
+
+.docs-index-list {
+    padding-left: 1.25rem;
+}
+
+.docs-index-list a {
+    color: var(--docs-accent);
+}
+
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3 {
+    line-height: 1.25;
+    margin-top: 1.5em;
+    margin-bottom: 0.5em;
+}
+
+.markdown-body h1:first-child {
+    margin-top: 0;
+}
+
+.markdown-body p,
+.markdown-body ul,
+.markdown-body ol,
+.markdown-body pre,
+.markdown-body table {
+    margin: 0.75em 0;
+}
+
+.markdown-body a {
+    color: var(--docs-accent);
+}
+
+.markdown-body code {
+    font-family: ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
+    font-size: 0.9em;
+    background: var(--docs-code-bg);
+    padding: 0.1em 0.35em;
+    border-radius: 0.2em;
+}
+
+.markdown-body pre {
+    background: var(--docs-code-bg);
+    padding: 1rem;
+    overflow-x: auto;
+    border-radius: 0.35rem;
+}
+
+.markdown-body pre code {
+    padding: 0;
+    background: none;
+}
+
+.markdown-body table {
+    border-collapse: collapse;
+    width: 100%;
+    font-size: 0.95rem;
+}
+
+.markdown-body th,
+.markdown-body td {
+    border: 1px solid var(--docs-border);
+    padding: 0.4rem 0.6rem;
+    text-align: left;
+}
+
+.markdown-body th {
+    background: var(--docs-code-bg);
+}
+
+.markdown-body blockquote {
+    margin: 1em 0;
+    padding-left: 1em;
+    border-left: 4px solid var(--docs-border);
+    color: var(--docs-muted);
+}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 11 - 0
client-package/docs/assets/marked.min.js


+ 151 - 0
client-package/docs/index.php

@@ -0,0 +1,151 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * Documentation viewer (Markdown -> HTML via the vendored marked.js).
+ * Adapted from the PSA order system (docs/index.php). Self-contained: no build
+ * step and no external requests, so it also works from an unpacked ZIP.
+ */
+
+$docsDir = __DIR__;
+$docMap = [];
+
+foreach (glob($docsDir . "/*.md") ?: [] as $path) {
+    $base = basename($path, ".md");
+    if (preg_match('/^[A-Z0-9_]+$/', $base) !== 1) {
+        continue;
+    }
+    $docMap[$base] = $path;
+}
+
+ksort($docMap);
+
+function docTitle(string $key): string
+{
+    // Drop a leading sort prefix like "01_" for display only.
+    $key = preg_replace('/^\d+_/', "", $key) ?? $key;
+
+    return ucwords(strtolower(str_replace("_", " ", $key)));
+}
+
+function docEscape(string $value): string
+{
+    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
+}
+
+$docsTitle = basename(dirname($docsDir)) === "client-package"
+    ? "Manage Client"
+    : "Manage Server";
+
+$requested = isset($_GET["doc"]) ? (string) $_GET["doc"] : "";
+$activeDoc = null;
+$markdown = null;
+$pageTitle = "Dokumentation";
+
+if ($requested !== "") {
+    if (!isset($docMap[$requested])) {
+        http_response_code(404);
+        $pageTitle = "Nicht gefunden";
+    } else {
+        $activeDoc = $requested;
+        $markdown = file_get_contents($docMap[$requested]);
+        if ($markdown === false) {
+            http_response_code(500);
+            $pageTitle = "Fehler";
+            $markdown = null;
+        } else {
+            $pageTitle = docTitle($activeDoc);
+        }
+    }
+}
+
+// Works both when docs/ is a subdirectory and when it is served as the web
+// root (php -S localhost:8080 -t docs), where dirname() yields "/".
+$baseHref = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/") . "/";
+if ($baseHref === "") {
+    $baseHref = "/";
+}
+?>
+<!DOCTYPE html>
+<html lang="de">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><?php echo docEscape($pageTitle); ?> – <?php echo docEscape($docsTitle); ?></title>
+    <link rel="stylesheet" href="<?php echo docEscape($baseHref); ?>assets/docs.css">
+</head>
+<body>
+<header class="docs-header">
+    <div class="docs-header-inner">
+        <a class="docs-brand" href="<?php echo docEscape($baseHref); ?>index.php"><?php echo docEscape($docsTitle); ?></a>
+    </div>
+</header>
+<div class="docs-layout">
+    <nav class="docs-nav" aria-label="Dokumentation">
+        <p class="docs-nav-title">Inhalt</p>
+        <ul>
+            <?php foreach ($docMap as $key => $_path): ?>
+                <li>
+                    <a href="<?php echo docEscape($baseHref); ?>index.php?doc=<?php echo docEscape($key); ?>"
+                       <?php echo $key === $activeDoc ? 'aria-current="page"' : ""; ?>>
+                        <?php echo docEscape(docTitle($key)); ?>
+                    </a>
+                </li>
+            <?php endforeach; ?>
+        </ul>
+    </nav>
+    <main class="docs-main">
+        <?php if ($requested === ""): ?>
+            <h1>Dokumentation</h1>
+            <p>Alle Dokumente in empfohlener Lesereihenfolge.</p>
+            <ul class="docs-index-list">
+                <?php foreach ($docMap as $key => $_path): ?>
+                    <li>
+                        <a href="<?php echo docEscape($baseHref); ?>index.php?doc=<?php echo docEscape($key); ?>">
+                            <?php echo docEscape(docTitle($key)); ?>
+                        </a>
+                    </li>
+                <?php endforeach; ?>
+            </ul>
+        <?php elseif ($markdown === null): ?>
+            <h1><?php echo docEscape($pageTitle); ?></h1>
+            <p>Das angeforderte Dokument ist nicht verfügbar.</p>
+            <p><a href="<?php echo docEscape($baseHref); ?>index.php">Zur Übersicht</a></p>
+        <?php else: ?>
+            <article id="doc-content" class="markdown-body"></article>
+            <script type="application/json" id="doc-source"><?php
+                echo json_encode(
+                    $markdown,
+                    JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE,
+                );
+            ?></script>
+        <?php endif; ?>
+    </main>
+</div>
+<?php if ($markdown !== null): ?>
+    <script src="<?php echo docEscape($baseHref); ?>assets/marked.min.js"></script>
+    <script>
+        (function () {
+            var source = document.getElementById('doc-source');
+            var target = document.getElementById('doc-content');
+            if (!source || !target || typeof marked === 'undefined') {
+                return;
+            }
+            var text = JSON.parse(source.textContent || '""');
+            target.innerHTML = marked.parse(text, { gfm: true, breaks: false });
+            target.querySelectorAll('a[href]').forEach(function (link) {
+                var href = link.getAttribute('href');
+                if (!href || /^[a-z]+:/i.test(href) || href.charAt(0) === '#') {
+                    return;
+                }
+                var name = href.split('/').pop().replace(/\.md$/i, '');
+                if (/^[A-Z0-9_]+$/i.test(name)) {
+                    link.setAttribute('href', 'index.php?doc=' + encodeURIComponent(name.toUpperCase()));
+                }
+            });
+        })();
+    </script>
+<?php endif; ?>
+</body>
+</html>

+ 54 - 0
client-package/examples/after-update.php

@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+// Vorlage für MANAGE_UPDATE_POST_HOOK.
+//
+// Wird nach jedem erfolgreichen Update ausgeführt – auch dann, wenn keine
+// Migration gelaufen ist. Läuft NACH den Migrationen und wird übersprungen,
+// wenn eine Migration fehlgeschlagen ist.
+//
+// In manage-client/config.php eintragen:
+//
+//     define("MANAGE_UPDATE_POST_HOOK", [
+//         "file"     => MANAGE_APP_ROOT . "/includes/after-update.php",
+//         "callback" => "myProjectAfterUpdate",
+//     ]);
+//
+// Diese Datei muss Teil des Release-Pakets sein, sonst fehlt sie nach dem
+// ersten Update.
+
+/**
+ * @param array $context app_root, instance, from_version, to_version,
+ *                       backup_dir, run_id, migrations, ggf. pdo
+ */
+function myProjectAfterUpdate(array $context): void
+{
+    // 1. Kompilierte Templates und Caches verwerfen.
+    foreach (glob($context["app_root"] . "/data/cache/*.php") ?: [] as $file) {
+        @unlink($file);
+    }
+
+    // 2. Verzeichnisse anlegen, die ein neues Release voraussetzt.
+    $newDir = $context["app_root"] . "/data/exports";
+    if (!is_dir($newDir) && !mkdir($newDir, 02775, true) && !is_dir($newDir)) {
+        throw new RuntimeException("Verzeichnis konnte nicht angelegt werden: " . $newDir);
+    }
+
+    // 3. Für die Nachvollziehbarkeit protokollieren.
+    @file_put_contents(
+        $context["app_root"] . "/data/update-history.log",
+        sprintf(
+            "%s  %s -> %s  (Migrationen: %s)%s",
+            date(DATE_ATOM),
+            $context["from_version"] !== "" ? $context["from_version"] : "unbekannt",
+            $context["to_version"],
+            $context["migrations"] === [] ? "keine" : implode(", ", $context["migrations"]),
+            PHP_EOL,
+        ),
+        FILE_APPEND | LOCK_EX,
+    );
+
+    // Fehler melden: Exception werfen, false zurückgeben oder
+    // return ["success" => false, "error" => "…"].
+}

+ 28 - 0
client-package/examples/cron/manage-client.cron

@@ -0,0 +1,28 @@
+# Manage-Client – Cron-Beispiele
+#
+# Pfade anpassen und mit `crontab -e` eintragen.
+# PHP-Pfad prüfen mit: which php
+#
+# --quiet unterdrückt die normale Ausgabe. Fehler gehen weiterhin auf STDERR
+# und werden von Cron per Mail zugestellt.
+
+MAILTO=admin@example.org
+
+# Nächtliches Backup um 03:20 Uhr.
+20 3 * * * /usr/bin/php /var/www/meinprojekt/manage-client/bin/manage-client.php backup --trigger=cron --quiet
+
+# Statusmeldung an den Manage-Server, stündlich zur Minute 7.
+7 * * * * /usr/bin/php /var/www/meinprojekt/manage-client/bin/manage-client.php heartbeat --quiet
+
+# Update-Prüfung werktags um 08:00 Uhr.
+# Exit-Code 2 bedeutet "Update verfügbar" – Cron meldet das nicht von sich aus,
+# deshalb hier eine ausdrückliche Mail.
+0 8 * * 1-5 /usr/bin/php /var/www/meinprojekt/manage-client/bin/manage-client.php check --quiet || true
+
+# Updates werden bewusst NICHT automatisch eingespielt.
+# Ein Update überschreibt Dateien im laufenden Betrieb und kann Migrationen
+# auslösen; das gehört unter Aufsicht. Falls es dennoch automatisiert werden
+# soll, vorher ein Backup erzwingen:
+#
+# 30 2 * * 0 /usr/bin/php /var/www/meinprojekt/manage-client/bin/manage-client.php backup --trigger=update --quiet && \
+#            /usr/bin/php /var/www/meinprojekt/manage-client/bin/manage-client.php update --yes --quiet

+ 48 - 0
client-package/examples/flat-file-project/migrations/2026-08-20-01-add-category-id.php

@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+// Beispielmigration für ein Projekt mit JSON-Dateien:
+// ergänzt ein neues Feld in allen bestehenden Datensätzen.
+//
+// Idempotent: bereits ergänzte Datensätze bleiben unverändert, damit die
+// Migration nach einem Teilfehler gefahrlos erneut laufen kann.
+
+return function (array $context): void {
+    $file = $context["app_root"] . "/data/products.json";
+    if (!is_file($file)) {
+        return;
+    }
+
+    $data = json_decode((string) file_get_contents($file), true);
+    if (!is_array($data)) {
+        throw new RuntimeException("products.json ist nicht lesbar oder kein gültiges JSON.");
+    }
+
+    $changed = false;
+    foreach ($data as $index => $product) {
+        if (is_array($product) && !array_key_exists("category_id", $product)) {
+            $data[$index]["category_id"] = null;
+            $changed = true;
+        }
+    }
+
+    if (!$changed) {
+        return;
+    }
+
+    $json = json_encode(
+        $data,
+        JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
+    );
+    if ($json === false) {
+        throw new RuntimeException("products.json konnte nicht kodiert werden.");
+    }
+
+    // Atomar schreiben, damit ein Abbruch keine halbe Datei hinterlässt.
+    $tmpFile = $file . ".tmp";
+    if (file_put_contents($tmpFile, $json, LOCK_EX) === false || !rename($tmpFile, $file)) {
+        @unlink($tmpFile);
+        throw new RuntimeException("products.json konnte nicht geschrieben werden.");
+    }
+};

+ 54 - 0
client-package/examples/integration-snippet.php

@@ -0,0 +1,54 @@
+<?php
+
+// Die Zeilen, die ein bestehendes Projekt braucht.
+// Zum Kopieren gedacht, nicht zum direkten Ausführen.
+
+// ---------------------------------------------------------------------------
+// 1. Adminseite für Update und Backup: <projekt>/admin/manage.php
+// ---------------------------------------------------------------------------
+
+require_once __DIR__ . "/../config.php";
+require_once __DIR__ . "/../includes/functions.php";
+
+// Anmeldung des Projekts zuerst prüfen – das Panel ist die mächtigste Seite
+// der Anwendung.
+if (empty($_SESSION["admin_logged_in"])) {
+    header("Location: login.php");
+    exit;
+}
+
+require __DIR__ . "/../manage-client/ui/panel.php";
+
+// ---------------------------------------------------------------------------
+// 2. Statusblock auf einer bestehenden Einstellungsseite
+// ---------------------------------------------------------------------------
+
+$manageStatusPanelUrl = "manage.php";
+include __DIR__ . "/../manage-client/ui/status-partial.php";
+
+// ---------------------------------------------------------------------------
+// 3. Automatisches Backup ohne Cron, z. B. auf dem Admin-Dashboard
+// ---------------------------------------------------------------------------
+
+require_once __DIR__ . "/../manage-client/lib/client.php";
+
+try {
+    // Legt nur an, wenn MANAGE_BACKUP_AUTO_INTERVAL_SECONDS abgelaufen ist.
+    manageBackupCreateAutomaticIfDue();
+} catch (Throwable $exception) {
+    // Ein fehlgeschlagenes Backup darf das Dashboard nicht blockieren.
+    error_log("Automatisches Backup fehlgeschlagen: " . $exception->getMessage());
+}
+
+// ---------------------------------------------------------------------------
+// 4. Update-Hinweis in der eigenen Navigation
+// ---------------------------------------------------------------------------
+
+require_once __DIR__ . "/../manage-client/lib/client.php";
+
+$manageStatus = manageClientStatus(); // wirft nie
+if ($manageStatus["update"] !== null && $manageStatus["update"]["available"]) {
+    echo '<a href="manage.php">Update ' .
+        htmlspecialchars($manageStatus["update"]["latest"], ENT_QUOTES, "UTF-8") .
+        " verfügbar</a>";
+}

+ 34 - 0
client-package/examples/mysql-project/migrations/2026-08-20-01-add-orders-index.php

@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+// Beispielmigration mit Datenbank.
+//
+// $context["pdo"] ist die Verbindung aus MANAGE_BACKUP_DATABASE.
+// Idempotent: prüft erst, ob der Index bereits existiert. MySQL kennt kein
+// "ADD INDEX IF NOT EXISTS", deshalb die Abfrage über information_schema.
+
+return function (array $context): void {
+    if (!isset($context["pdo"])) {
+        throw new RuntimeException(
+            "Diese Migration benötigt eine Datenbank. MANAGE_BACKUP_DATABASE ist nicht konfiguriert.",
+        );
+    }
+
+    /** @var PDO $pdo */
+    $pdo = $context["pdo"];
+
+    $statement = $pdo->prepare(
+        "SELECT COUNT(*) FROM information_schema.statistics
+         WHERE table_schema = DATABASE()
+           AND table_name = :table
+           AND index_name = :index",
+    );
+    $statement->execute(["table" => "orders", "index" => "idx_orders_created"]);
+
+    if ((int) $statement->fetchColumn() > 0) {
+        return;
+    }
+
+    $pdo->exec("ALTER TABLE orders ADD INDEX idx_orders_created (created_at)");
+};

+ 21 - 0
client-package/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
client-package/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
client-package/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", "psa-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", []);

+ 520 - 0
client-package/manage-client/lib/backup.php

@@ -0,0 +1,520 @@
+<?php
+
+declare(strict_types=1);
+
+// Client-side backup: collecting sources, writing the archive, local retention
+// and uploading to the manage server.
+//
+// Ported from the PSA order system (includes/backup.php). The one structural
+// change is that the source list is no longer hardcoded to data/*.json plus
+// data/uploads/**: 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
client-package/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
client-package/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
client-package/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
client-package/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 (like the PSA order system) 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,
+    ];
+}

+ 367 - 0
client-package/manage-client/lib/remote.php

@@ -0,0 +1,367 @@
+<?php
+
+declare(strict_types=1);
+
+// Extra backup destinations besides the manage server. Ported from the PSA
+// order system (includes/backup.php): s3, sftp and custom.
+//
+// The old "managed" target type is gone: uploading to the manage server is now
+// 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;
+}

+ 424 - 0
client-package/manage-client/lib/updater.php

@@ -0,0 +1,424 @@
+<?php
+
+declare(strict_types=1);
+
+// Update pipeline: check, download, verify, extract, deploy, post-update hook.
+//
+// Ported from the PSA order system (admin/updater.php) with the HTML stripped
+// out and three hardcoded assumptions made configurable:
+//   - the version file (was includes/version.php with APP_VERSION)
+//   - the protected paths (was config.php, data/, .git/)
+//   - the package sanity marker (was 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;
+}

+ 330 - 0
client-package/manage-client/lib/zip.php

@@ -0,0 +1,330 @@
+<?php
+
+declare(strict_types=1);
+
+// Pure-PHP ZIP writer. Ported from the PSA order system (includes/backup.php),
+// which builds the archive with pack() rather than requiring ext-zip, exec or
+// a temp copy of the whole archive in memory.
+//
+// One addition against the original: optional deflate compression (method 8).
+// The original stored everything uncompressed, which is fine for JPEGs but
+// wasteful for the SQL dumps this client can now 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) ?: "",
+    ];
+}

+ 348 - 0
client-package/manage-client/ui/panel.php

@@ -0,0 +1,348 @@
+<?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. The default guard below matches the PSA order
+// system; adjust it 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>
+
+    <?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
client-package/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>

+ 85 - 0
config.sample.php

@@ -0,0 +1,85 @@
+<?php
+
+// Manage server configuration.
+//
+// Copy this file to config.php on the server and adjust the values.
+// config.php is not tracked in Git (.gitignore).
+//
+// Every constant below has a default in includes/bootstrap.php, so a minimal
+// config.php only needs MANAGE_PUBLIC_URL and MANAGE_ADMIN_PASSWORD_HASH.
+
+// ---------------------------------------------------------------------------
+// Product
+// ---------------------------------------------------------------------------
+// One manage deployment serves exactly one product. Deploy it again for another.
+define("MANAGE_PRODUCT_NAME", "PSA Orderform");
+
+// Prefix for stored release packages: <prefix>-vX.Y.Z.zip
+define("MANAGE_PACKAGE_PREFIX", "psa-orderform");
+
+// ---------------------------------------------------------------------------
+// Public URL
+// ---------------------------------------------------------------------------
+// Absolute base URL of this installation without a trailing slash. The package
+// download URL handed to clients is built from this value, never from the
+// client-controlled Host header.
+define("MANAGE_PUBLIC_URL", "https://manage.example.org");
+
+// ---------------------------------------------------------------------------
+// Admin login
+// ---------------------------------------------------------------------------
+// Generate a hash with:
+//   php -r 'echo password_hash("your-password", PASSWORD_DEFAULT), PHP_EOL;'
+define("MANAGE_ADMIN_PASSWORD_HASH", '$2y$12$replace-this-with-a-real-hash');
+
+// Plaintext fallback for a quick first start. Prefer the hash above.
+// define("MANAGE_ADMIN_PASSWORD", "change-me");
+
+// ---------------------------------------------------------------------------
+// Storage
+// ---------------------------------------------------------------------------
+define("MANAGE_STORAGE_DIR", __DIR__ . "/storage/");
+
+// ---------------------------------------------------------------------------
+// Backups
+// ---------------------------------------------------------------------------
+// Local backups kept per instance. The UI value in storage/settings.json takes
+// precedence once it has been saved. Minimum 1.
+define("MANAGE_BACKUP_RETENTION", 30);
+
+// Maximum accepted upload size in bytes (0 disables the check). PHP's own
+// upload_max_filesize / post_max_size still apply and are usually lower.
+define("MANAGE_BACKUP_MAX_UPLOAD_BYTES", 0);
+
+// ---------------------------------------------------------------------------
+// S3 archive (optional)
+// ---------------------------------------------------------------------------
+// When enabled, every received backup is additionally archived to S3-compatible
+// object storage. Local disk then acts as a hot cache holding the newest copies.
+define("MANAGE_S3_ENABLED", false);
+define("MANAGE_S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
+define("MANAGE_S3_REGION", "fsn1");
+define("MANAGE_S3_BUCKET", "");
+define("MANAGE_S3_PREFIX", "manage-backups");
+define("MANAGE_S3_ACCESS_KEY", "");
+define("MANAGE_S3_SECRET_KEY", "");
+// false = virtual-hosted (https://<bucket>.<endpoint>/<key>), the default for
+// Hetzner and most providers. true = path-style (https://<endpoint>/<bucket>/<key>).
+define("MANAGE_S3_PATH_STYLE", false);
+define("MANAGE_S3_TIMEOUT", 120);
+define("MANAGE_S3_RETENTION", 365);
+
+// ---------------------------------------------------------------------------
+// Rate limits
+// ---------------------------------------------------------------------------
+define("MANAGE_LOGIN_RATE_LIMIT_MAX", 10);
+define("MANAGE_LOGIN_RATE_LIMIT_WINDOW", 900);
+define("MANAGE_API_RATE_LIMIT_MAX", 240);
+define("MANAGE_API_RATE_LIMIT_WINDOW", 300);
+
+// ---------------------------------------------------------------------------
+// Log retention
+// ---------------------------------------------------------------------------
+define("MANAGE_LOG_MAX_BYTES", 1048576);
+define("MANAGE_LOG_KEEP_FILES", 5);
+define("MANAGE_LOG_MAX_AGE_SECONDS", 2592000);

+ 2 - 0
docs/.htaccess

@@ -0,0 +1,2 @@
+# The viewer serves Markdown through PHP; raw .md stays blocked by parent rules.
+Options -Indexes

+ 186 - 0
docs/ARCHITECTURE.md

@@ -0,0 +1,186 @@
+# Architektur
+
+## Überblick
+
+`manage` besteht aus zwei Hälften: dem Server in diesem Repository und dem
+Client-Paket, das in jedes betreute Projekt kopiert wird.
+
+Eine Server-Installation betreut **ein Produkt** mit einer überschaubaren Zahl von
+Instanzen. Für ein weiteres Produkt wird `manage` erneut ausgerollt.
+
+Relevante Verzeichnisse:
+
+- `admin/` – Oberfläche
+- `api/v1/` – Schnittstelle für Clients
+- `includes/` – gemeinsame Bibliothek
+- `storage/` – sämtlicher Zustand, nicht über das Web erreichbar
+- `client-package/` – der weitergebbare Ordner für Projekte
+
+## Datenfluss
+
+```text
+Projekt (Instanz)                        Manage-Server
+─────────────────                        ─────────────
+manage-client/
+  bin/manage-client.php  ──── check ───►  api/v1/manifest.php  ──► storage/releases/manifest.json
+  ui/panel.php           ──── update ──►  api/v1/package.php   ──► storage/releases/packages/
+  lib/*.php              ──── backup ──►  api/v1/backup.php    ──► storage/backups/<instanz>/
+                         ──── status ──►  api/v1/heartbeat.php ──► storage/instances.json
+                                                                        │
+                                                                        ▼
+                                                            admin/  (Anmeldung mit Passwort)
+```
+
+Der Client zieht; der Server schiebt nie. Es gibt keine Verbindung vom Server zur
+Instanz, was den Betrieb hinter NAT und Firewalls unkompliziert macht.
+
+## Authentifizierung
+
+Zwei getrennte Wege:
+
+| Weg | Wer | Mittel |
+|---|---|---|
+| `admin/` | Menschen | ein Passwort, Sitzung, CSRF, Ratenbegrenzung |
+| `api/v1/` | Instanzen | Instanz-Kennung + Token in zwei Headern, zustandslos |
+
+Tokens werden als SHA-256-Hash gespeichert und in konstanter Zeit verglichen. Das
+Klartext-Token erscheint genau einmal, beim Anlegen oder Erneuern.
+
+Details: [client-package/docs/08_PROTOCOL.md](../client-package/docs/08_PROTOCOL.md).
+
+## Instanzregister
+
+`storage/instances.json` ist das Bindeglied zwischen beiden Modulen. Es ersetzt
+zwei getrennte Mechanismen der Vorgängerlösung: die Namensliste des Backup-Servers
+und die vollständig fehlende Client-Identität des Update-Servers.
+
+```json
+{
+    "instances": [
+        {
+            "id": "psa-prod",
+            "label": "Stadt Freising Produktiv",
+            "enabled": true,
+            "token_hash": "…",
+            "created_at": "…",
+            "token_rotated_at": "…",
+            "last_seen_at": "…",
+            "last_ip": "…",
+            "version": "v1.3.14",
+            "php_version": "8.3.6",
+            "disk_free": 12884901888,
+            "pending_migrations": 0,
+            "last_backup_at": "…",
+            "backup_count": 3,
+            "notes": ""
+        }
+    ]
+}
+```
+
+Jede erfolgreich authentifizierte Anfrage aktualisiert `last_seen_at` und `last_ip`.
+Die Übersicht bleibt dadurch aktuell, auch ohne eigenen Heartbeat.
+
+Eine gelöschte Instanz kann nichts mehr hochladen; ihre bereits gespeicherten
+Backups bleiben aber erhalten und in der Oberfläche sichtbar.
+
+## Releases
+
+`storage/releases/manifest.json` ist die Release-Datenbank, die Pakete liegen
+daneben in `packages/`:
+
+```json
+{
+    "latest": "v1.3.0",
+    "releases": {
+        "v1.3.0": {
+            "version": "v1.3.0",
+            "package": "packages/psa-orderform-v1.3.0.zip",
+            "sha256": "…",
+            "size": 2199,
+            "published_at": "…"
+        }
+    }
+}
+```
+
+Prüfsumme und Größe berechnet immer der Server nach dem Upload; sie werden nie vom
+Hochladenden übernommen. Ein Upload setzt das Release automatisch als `latest`.
+
+Die Download-URL wird aus `MANAGE_PUBLIC_URL` gebildet, **nicht** aus dem
+`Host`-Header. Die Vorgängerlösung leitete sie aus `HTTP_HOST` ab, also aus einem
+vom Client kontrollierten Wert.
+
+## Backups
+
+```text
+storage/backups/
+  index.json                     Metadaten aller Backups
+  <instanz>/backup-YYYYmmdd-HHMMSS[-N].zip
+```
+
+Der Server prüft nach dem Speichern die Prüfsumme erneut und löscht die Datei bei
+Abweichung. Ein vorhandener Dateiname wird nie überschrieben.
+
+### Zwei Aufbewahrungsstufen
+
+Bei aktivem S3-Archiv arbeitet die lokale Platte als schneller Zwischenspeicher und
+der Bucket als vollständiges Archiv:
+
+- **S3**: behält die neuesten `s3_retention` Sicherungen je Instanz (Standard 365).
+- **Lokal**: behält die neuesten `retention` Sicherungen (Standard 30), löscht eine
+  Datei aber **nie**, solange ihr S3-Upload noch aussteht.
+
+Ist S3 nicht erreichbar, wachsen die lokalen Kopien also über die Aufbewahrung
+hinaus, statt die einzige Kopie zu verlieren. Fehlgeschlagene S3-Uploads werden beim
+nächsten Upload derselben Instanz oder über die Schaltfläche in der Oberfläche
+nachgeholt.
+
+S3-Fehler lassen einen Client-Upload nie fehlschlagen: Die lokale Kopie liegt bereits
+vor. Protokolliert werden sie in `storage/logs/s3.log`.
+
+## Speicherung
+
+Ausschließlich flache Dateien, kein Datenbankserver. Alle Schreibvorgänge laufen
+über `manageWriteJsonFile()`: erst in eine `.tmp`-Datei, dann `rename()`. Damit
+kann ein abgebrochener Request keinen halb geschriebenen Index hinterlassen.
+
+Bekannte Grenze: Gleichzeitige Uploads derselben Instanz können sich beim
+Schreiben von `index.json` überschneiden. Bei einer Handvoll Instanzen mit
+nächtlichen Backups ist das praktisch ausgeschlossen; bei vielen gleichzeitigen
+Uploads wäre eine Sperre nötig.
+
+## Protokolle
+
+| Datei | Inhalt |
+|---|---|
+| `storage/logs/access.log` | JSONL: Anmeldungen, Releases, empfangene Backups, Downloads |
+| `storage/logs/error.log` | JSONL: fehlgeschlagene Anmeldungen, abgelehnte Uploads, interne Fehler |
+| `storage/logs/s3.log` | Klartext: S3-Diagnose mit Status, Umleitungen und Request-ID |
+
+Die JSONL-Protokolle rotieren nach `MANAGE_LOG_MAX_BYTES` und werden nach
+`MANAGE_LOG_MAX_AGE_SECONDS` entfernt.
+
+## Herkunft
+
+Der Code stammt aus dem PSA-Bestellsystem und wurde beim Herauslösen
+verallgemeinert:
+
+| Hier | Ursprung |
+|---|---|
+| `includes/s3.php` | `backup-server/s3.php` |
+| `includes/backups.php` | `backup-server/lib.php` |
+| `includes/releases.php` | `update-server/manage.php`, `update-server/manifest.php` |
+| `includes/auth.php` | die Anmeldeteile beider Vorgänger-Oberflächen |
+| `includes/storage.php`, `log.php`, `ratelimit.php` | `includes/functions.php` |
+| `client-package/manage-client/lib/updater.php` | `admin/updater.php` |
+| `client-package/manage-client/lib/backup.php`, `zip.php`, `remote.php` | `includes/backup.php` |
+
+Das PSA-Bestellsystem wurde dabei nicht verändert und läuft unverändert gegen seine
+bisherigen Server weiter. Siehe [MIGRATION_PSA](MIGRATION_PSA.md).
+
+## Weiter
+
+- [SERVER_SETUP](SERVER_SETUP.md) – Installation
+- [INSTANCE_MANAGEMENT](INSTANCE_MANAGEMENT.md) – Instanzen und Tokens
+- [RELEASING](RELEASING.md) – Releases veröffentlichen

+ 130 - 0
docs/CONFIG_REFERENCE.md

@@ -0,0 +1,130 @@
+# Konfigurationsreferenz (Server)
+
+## Überblick
+
+Alle Konstanten stehen in `config.php`, kopiert aus `config.sample.php`. Jede hat
+einen Standardwert in `includes/bootstrap.php`; eine minimale `config.php` braucht
+nur `MANAGE_PUBLIC_URL` und `MANAGE_ADMIN_PASSWORD_HASH`.
+
+Die Konstanten des **Clients** stehen in
+[../client-package/docs/03_CONFIG_REFERENCE.md](../client-package/docs/03_CONFIG_REFERENCE.md).
+
+## Produkt
+
+| Konstante | Standard | Bedeutung |
+|---|---|---|
+| `MANAGE_PRODUCT_NAME` | `"Managed Application"` | Anzeigename in der Oberfläche |
+| `MANAGE_PACKAGE_PREFIX` | `"release"` | Dateinamenspräfix gespeicherter Pakete: `<prefix>-vX.Y.Z.zip` |
+
+## Öffentliche URL
+
+| Konstante | Standard | Bedeutung |
+|---|---|---|
+| `MANAGE_PUBLIC_URL` | `""` | Absolute Basis-URL dieser Installation, ohne Schrägstrich am Ende |
+
+Aus diesem Wert wird die Download-URL gebildet, die Clients im Manifest erhalten.
+Er wird bewusst nicht aus dem `Host`-Header abgeleitet: Ein gefälschter Header
+könnte einen Client sonst auf einen fremden Server umlenken.
+
+Ist der Wert leer, meldet die Übersicht eine Warnung und `manifest.php` antwortet
+mit einem Fehler.
+
+## Anmeldung
+
+| Konstante | Standard | Bedeutung |
+|---|---|---|
+| `MANAGE_ADMIN_PASSWORD_HASH` | – | bcrypt-Hash, mit `password_verify()` geprüft |
+| `MANAGE_ADMIN_PASSWORD` | – | Klartext-Alternative, mit `hash_equals()` geprüft |
+
+Der Hash hat Vorrang. Er wird ignoriert, solange er noch den Platzhalter aus
+`config.sample.php` enthält – so scheitert eine unfertige Konfiguration sichtbar,
+statt eine offene Anmeldung zu erlauben.
+
+```bash
+php -r 'echo password_hash("ein-langes-passwort", PASSWORD_DEFAULT), PHP_EOL;'
+```
+
+In einfache Anführungszeichen setzen, ein bcrypt-Hash enthält `$`.
+
+## Speicher
+
+| Konstante | Standard | Bedeutung |
+|---|---|---|
+| `MANAGE_STORAGE_DIR` | `__DIR__ . "/storage/"` | Wurzel für Instanzen, Releases, Backups, Protokolle |
+
+Alle weiteren Pfade leiten sich davon ab und müssen nicht einzeln gesetzt werden:
+`storage/instances.json`, `storage/settings.json`, `storage/releases/manifest.json`,
+`storage/releases/packages/`, `storage/backups/`, `storage/logs/`.
+
+Das Verzeichnis darf nicht über das Web erreichbar sein.
+
+## Backups
+
+| Konstante | Standard | Bedeutung |
+|---|---|---|
+| `MANAGE_BACKUP_RETENTION` | `30` | Lokale Backups pro Instanz. Minimum 1 |
+| `MANAGE_BACKUP_MAX_UPLOAD_BYTES` | `0` | Zusätzliches Größenlimit; `0` deaktiviert |
+
+Der in der Oberfläche gespeicherte Wert (`storage/settings.json`) hat Vorrang vor
+`MANAGE_BACKUP_RETENTION`, sobald er einmal gespeichert wurde.
+
+`MANAGE_BACKUP_MAX_UPLOAD_BYTES` liegt **über** den PHP-Grenzen: `upload_max_filesize`
+und `post_max_size` greifen ohnehin und sind meist niedriger.
+
+## S3-Archiv
+
+| Konstante | Standard | Bedeutung |
+|---|---|---|
+| `MANAGE_S3_ENABLED` | `false` | Archivierung einschalten |
+| `MANAGE_S3_ENDPOINT` | `""` | z. B. `https://fsn1.your-objectstorage.com` |
+| `MANAGE_S3_REGION` | `""` | Region für die Signatur |
+| `MANAGE_S3_BUCKET` | `""` | Bucket-Name |
+| `MANAGE_S3_PREFIX` | `""` | Schlüsselpräfix im Bucket, darf leer sein |
+| `MANAGE_S3_ACCESS_KEY` | `""` | Access Key |
+| `MANAGE_S3_SECRET_KEY` | `""` | Secret Key |
+| `MANAGE_S3_PATH_STYLE` | `false` | `false` = virtual-hosted, `true` = path-style |
+| `MANAGE_S3_TIMEOUT` | `120` | Sekunden je HTTP-Anfrage |
+| `MANAGE_S3_RETENTION` | `365` | S3-Backups pro Instanz |
+
+Das Archiv gilt nur als aktiv, wenn `MANAGE_S3_ENABLED` gesetzt **und** Endpunkt,
+Region, Bucket, Access Key und Secret Key gefüllt sind. Eine halbe Konfiguration
+bleibt wirkungslos statt bei jedem Upload zu scheitern.
+
+Verhalten und Fehlersuche: [SERVER_SETUP](SERVER_SETUP.md).
+
+## Ratenbegrenzung
+
+| Konstante | Standard | Bedeutung |
+|---|---|---|
+| `MANAGE_LOGIN_RATE_LIMIT_MAX` | `10` | Fehlversuche an der Oberfläche je Zeitfenster und IP |
+| `MANAGE_LOGIN_RATE_LIMIT_WINDOW` | `900` | Zeitfenster in Sekunden |
+| `MANAGE_API_RATE_LIMIT_MAX` | `240` | Fehlgeschlagene API-Authentifizierungen je Fenster und IP |
+| `MANAGE_API_RATE_LIMIT_WINDOW` | `300` | Zeitfenster in Sekunden |
+
+Der Zustand liegt in `storage/ratelimit/`. Eine erfolgreiche Authentifizierung
+setzt den Zähler der IP zurück. Ist der Zustand nicht schreibbar, lässt die
+Begrenzung bewusst durch, statt alle auszusperren.
+
+## Protokolle
+
+| Konstante | Standard | Bedeutung |
+|---|---|---|
+| `MANAGE_LOG_MAX_BYTES` | `1048576` | Rotation ab dieser Größe |
+| `MANAGE_LOG_KEEP_FILES` | `5` | Anzahl rotierter Dateien |
+| `MANAGE_LOG_MAX_AGE_SECONDS` | `2592000` | Rotierte Dateien danach löschen (30 Tage) |
+
+Gilt für `access.log` und `error.log`. `s3.log` wächst unbegrenzt und wird bei
+Bedarf von Hand gekürzt.
+
+## Beispiel für eine minimale config.php
+
+```php
+<?php
+
+define("MANAGE_PRODUCT_NAME", "PSA Orderform");
+define("MANAGE_PACKAGE_PREFIX", "psa-orderform");
+define("MANAGE_PUBLIC_URL", "https://manage.example.org");
+define("MANAGE_ADMIN_PASSWORD_HASH", '$2y$12$…');
+```
+
+Alles Weitere übernimmt die Standardwerte.

+ 107 - 0
docs/INSTANCE_MANAGEMENT.md

@@ -0,0 +1,107 @@
+# Instanzen verwalten
+
+## Überblick
+
+Eine Instanz ist eine Installation des betreuten Produkts – etwa "Produktiv",
+"Test" oder die Installation eines bestimmten Kunden. Jede Instanz hat eine
+Kennung und ein geheimes Token.
+
+Alles dazu unter **Instanzen** in der Oberfläche.
+
+## Instanz anlegen
+
+1. **Instanzen** öffnen, Kennung eintragen, optional Bezeichnung und Notiz.
+2. Anlegen. Direkt danach wird das Token angezeigt – **einmalig**, zusammen mit
+   einem fertigen Konfigurationsblock zum Kopieren.
+3. Den Block in die `manage-client/config.php` der Instanz eintragen.
+
+Kennungen dürfen Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten,
+müssen mit einem Buchstaben oder einer Zahl beginnen und höchstens 120 Zeichen lang
+sein. Sie erscheinen im Dateipfad der Backups; sprechende Namen wie `psa-prod` und
+`psa-test` zahlen sich aus.
+
+## Das Token
+
+- 32 zufällige Bytes, als 64 Hex-Zeichen dargestellt.
+- Der Server speichert nur den SHA-256-Hash. Es gibt keinen Weg, ein Token später
+  wieder anzuzeigen.
+- Geht es verloren, wird ein neues erzeugt – das alte wird dabei sofort ungültig.
+
+Mit dem Token kann eine Instanz Releases herunterladen und Backups hochladen. Sie
+kann **nicht** Backups herunterladen und **nicht** Releases verändern; beides
+erfordert die Anmeldung an der Oberfläche.
+
+### Token erneuern
+
+**Instanzen → Token erneuern**. Das alte Token verliert sofort seine Gültigkeit;
+die Instanz meldet danach `Authentifizierung fehlgeschlagen`, bis das neue Token
+eingetragen ist. Kurze Ausfälle von Cron-Jobs sind also einzuplanen.
+
+Anlässe: Verdacht auf Kompromittierung, Personalwechsel, Übergabe eines Projekts.
+
+## Deaktivieren statt löschen
+
+**Deaktivieren** lässt die Instanz bestehen, weist aber jede API-Anfrage mit `403`
+ab. Der richtige Weg, wenn eine Installation vorübergehend stillgelegt wird oder
+etwas unklar ist – das Token bleibt gültig und die Instanz ist mit einem Klick
+wieder betriebsbereit.
+
+**Entfernen** löscht den Registereintrag. Die gespeicherten Backups bleiben
+erhalten und unter **Backups** sichtbar und herunterladbar; sie werden dort als
+"nicht mehr registriert" gekennzeichnet. Neue Uploads sind nicht mehr möglich.
+
+## Statusanzeige
+
+Die Übersicht zeigt für jede Instanz:
+
+| Feld | Herkunft |
+|---|---|
+| Status | `aktiv`, `inaktiv` (länger als 7 Tage nicht gesehen) oder `deaktiviert` |
+| Version | letzte Meldung der Instanz |
+| Update | Vergleich dieser Version mit dem aktuellen Release |
+| Zuletzt gesehen | jede authentifizierte Anfrage aktualisiert diesen Wert |
+| Letztes Backup | Zeitpunkt des letzten empfangenen Backups |
+| Offene Migrationen | aus dem Heartbeat der Instanz |
+
+`inaktiv` bei einer laufenden Installation bedeutet meist, dass der Cron-Job für
+den Heartbeat nicht läuft. Ohne Cron meldet sich eine Instanz nur, wenn jemand die
+Oberfläche im Projekt benutzt.
+
+Offene Migrationen sind das wichtigste Warnsignal: Sie bedeuten, dass ein Update
+zwar ausgerollt wurde, ein Teil des Post-Update-Schritts aber fehlgeschlagen ist.
+
+## Client-Paket übergeben
+
+Der Ordner `client-package/` enthält den Client **und** dessen vollständige
+Dokumentation. Zum Weitergeben:
+
+```bash
+./scripts/build-client-package.sh --server-url https://manage.example.org
+```
+
+Das Ergebnis liegt unter `build/manage-client-<datum>.zip`. Mit `--server-url` ist
+die Server-Adresse in der mitgelieferten `config.sample.php` bereits eingetragen;
+die empfangende Seite ergänzt nur noch Kennung und Token.
+
+Das Skript entfernt vor dem Packen jede `config.php` und alle Protokolldateien,
+damit kein Token aus einer Testinstallation mitgeliefert wird.
+
+## Mehrere Umgebungen
+
+Übliches Vorgehen für ein Produkt mit Test- und Produktivsystem:
+
+| Instanz | Zweck |
+|---|---|
+| `produkt-test` | bekommt neue Releases zuerst |
+| `produkt-prod` | folgt nach erfolgreichem Test |
+
+Beide holen sich dasselbe `latest`. Wer ein Release nur auf dem Testsystem haben
+will, veröffentlicht es und setzt vorübergehend das ältere wieder als aktuell –
+oder rollt auf dem Testsystem mit `update --force` gezielt aus. Getrennte Kanäle
+pro Instanz gibt es bewusst nicht; dafür wird ein zweiter Manage-Server ausgerollt.
+
+## Weiter
+
+- [SERVER_SETUP](SERVER_SETUP.md) – Installation
+- [RELEASING](RELEASING.md) – Releases veröffentlichen
+- [../client-package/docs/08_PROTOCOL.md](../client-package/docs/08_PROTOCOL.md) – Authentifizierung im Detail

+ 108 - 0
docs/MIGRATION_PSA.md

@@ -0,0 +1,108 @@
+# Ablösung des PSA-Bestellsystems
+
+## Überblick
+
+**Dieses Dokument beschreibt einen Umbau, der noch nicht durchgeführt wurde.**
+
+Das PSA-Bestellsystem (`/var/www/html/psa`) wurde beim Herauslösen von `manage`
+nicht verändert. Es läuft unverändert weiter gegen seine eigenen Verzeichnisse
+`update-server/` und `backup-server/`. Dieses Dokument hält fest, was ein späterer
+Wechsel bedeuten würde – damit die Entscheidung bewusst getroffen werden kann und
+nicht nebenbei passiert.
+
+## Warum es kein automatischer Wechsel ist
+
+`manage` spricht ein anderes Protokoll. Der bisherige Stand:
+
+| | bisher (psa) | jetzt (manage) |
+|---|---|---|
+| Manifest | öffentlich abrufbar | Token-Pflicht |
+| Paket-Download | öffentlich abrufbar | Token-Pflicht |
+| Backup-Upload | ohne Anmeldung, nur Namensliste | Token-Pflicht |
+| Instanz-Identität | nur beim Backup, als Klartext-Name | Registereintrag mit Token-Hash |
+
+Es gibt bewusst keine Kompatibilitätsschicht für das alte Protokoll. Der bestehende
+Client kann also nicht einfach auf `manage` gezeigt werden; er müsste ersetzt werden.
+
+## Was zu tun wäre
+
+### 1. Instanz anlegen
+
+Im Manage-Server eine Instanz anlegen, zum Beispiel `psa-prod`, und das einmalig
+angezeigte Token notieren.
+
+### 2. Client einbauen
+
+`client-package/manage-client/` nach `psa/manage-client/` kopieren und
+`config.php` anlegen. Die Zuordnung der bisherigen Konstanten:
+
+| bisher in `psa/config.php` | neu in `psa/manage-client/config.php` |
+|---|---|
+| `UPDATE_MANIFEST_URL` | entfällt – ersetzt durch `MANAGE_SERVER_URL` + Token |
+| `UPDATE_WORK_DIR` | `MANAGE_WORK_DIR` |
+| `UPDATE_BACKUP_DIR` | `MANAGE_UPDATE_BACKUP_DIR` |
+| `BACKUP_DIR` | `MANAGE_BACKUP_DIR` |
+| `BACKUP_LOCAL_RETENTION` | `MANAGE_BACKUP_LOCAL_RETENTION` |
+| `BACKUP_AUTO_INTERVAL_SECONDS` | `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` |
+| `BACKUP_REMOTE_TARGETS` mit `type => managed` | entfällt – eingebaut, über `MANAGE_BACKUP_UPLOAD` |
+| `BACKUP_REMOTE_TARGETS` mit `s3`/`sftp`/`custom` | `MANAGE_BACKUP_REMOTE_TARGETS`, unverändertes Format |
+| – | `MANAGE_VERSION_FILE` = `includes/version.php`, `MANAGE_VERSION_CONSTANT` = `APP_VERSION` |
+
+Die bisherigen Backup-Quellen entsprechen genau:
+
+```php
+define("MANAGE_BACKUP_SOURCES", [
+    ["as" => "data", "glob" => "data/*.json"],
+    ["as" => "data/uploads", "dir" => "data/uploads"],
+]);
+```
+
+Damit sind die erzeugten Archive inhaltlich identisch zu den bisherigen.
+
+### 3. Oberfläche ersetzen
+
+`psa/admin/updater.php` würde durch eine Seite ersetzt, die
+`manage-client/ui/panel.php` einbindet – mit der bestehenden Anmeldeprüfung von
+psa davor. Der Statusblock in `psa/admin/settings.php`
+(`settingsGetUpdaterStatus()`) würde durch
+`manage-client/ui/status-partial.php` ersetzt.
+
+Der Aufruf `backupCreateAutomaticIfDue()` in `psa/admin/index.php` würde zu
+`manageBackupCreateAutomaticIfDue()`.
+
+### 4. Altes entfernen
+
+Danach könnten entfallen:
+
+- `psa/admin/updater.php`
+- `psa/includes/backup.php`
+- `psa/update-server/`
+- `psa/backup-server/`
+- `psa/scripts/create-update-zip.sh` (ersetzt durch die angepasste Vorlage aus
+  `manage/scripts/create-release-zip.sh`)
+
+Die zugehörigen Abschnitte in `psa/docs/BACKUP_CONFIGURATION.md` und
+`psa/docs/CONFIG_REFERENCE.md` würden auf die Client-Dokumentation verweisen.
+
+### 5. Bestehende Daten
+
+- **Backups**: Die bisherigen Archive liegen auf dem alten Backup-Server. Sie
+  lassen sich nicht automatisch übernehmen; entweder werden sie dort belassen, bis
+  ihre Aufbewahrungsfrist abläuft, oder sie werden von Hand nach
+  `manage/storage/backups/psa-prod/` kopiert und in `index.json` eingetragen.
+- **Releases**: Die ZIPs aus `psa/update-server/packages/` können über die
+  Oberfläche unter **Releases** hochgeladen werden; Prüfsummen berechnet der
+  Server neu.
+
+## Empfohlene Reihenfolge
+
+1. Manage-Server aufsetzen und mit einer **Testinstanz** vollständig durchspielen.
+2. Instanz `psa-test` anlegen und den Umbau an einer Kopie von psa erproben.
+3. Erst danach die Produktivinstallation umstellen – mit frischem Backup über den
+   **alten** Weg, bevor der alte Weg abgeschaltet wird.
+4. Alte Server-Verzeichnisse einige Wochen laufen lassen, bevor sie entfernt werden.
+
+## Weiter
+
+- [ARCHITECTURE](ARCHITECTURE.md) – woher welcher Code stammt
+- [../client-package/docs/02_INTEGRATION.md](../client-package/docs/02_INTEGRATION.md) – Einbindung im Projekt

+ 96 - 0
docs/RELEASING.md

@@ -0,0 +1,96 @@
+# Releases veröffentlichen
+
+## Überblick
+
+Ein Release ist ein ZIP, dessen Wurzel der Anwendungsstamm des Projekts ist. Der
+Client rollt es über die bestehende Installation aus.
+
+Ablauf: bauen → hochladen → als aktuell setzen → Instanzen holen es ab.
+
+## 1. Paket bauen
+
+`scripts/create-release-zip.sh` ist die Vorlage. Sie wird einmal pro Projekt
+angepasst (Produktname, Versionsdatei, Ausschlussliste), ins Projekt kopiert und
+dort aufgerufen:
+
+```bash
+./scripts/create-release-zip.sh v1.3.0
+```
+
+Das Skript
+
+1. schreibt die Version in die Versionsdatei,
+2. prüft nach, dass das Schreiben tatsächlich funktioniert hat,
+3. packt alle von Git verfolgten Dateien abzüglich der Ausschlussliste,
+4. gibt Dateizahl, Größe und SHA-256 aus.
+
+Es warnt, wenn die Arbeitskopie ungesicherte Änderungen enthält: Weil die
+Dateiliste aus `git ls-files` stammt, landen nicht eingecheckte Änderungen sonst
+still nicht im Paket.
+
+Ausführlich – Aufbau des Pakets, Ausschlüsse, häufige Fehler:
+[../client-package/docs/06_UPDATE_PACKAGING.md](../client-package/docs/06_UPDATE_PACKAGING.md).
+
+## 2. Hochladen
+
+**Releases** öffnen, Version im Format `vX.Y.Z` eintragen, ZIP auswählen,
+hochladen.
+
+Der Server
+
+- prüft die Endung und die ZIP-Signatur der Datei,
+- speichert sie als `<MANAGE_PACKAGE_PREFIX>-<version>.zip`,
+- berechnet SHA-256 und Größe **selbst** und trägt sie ins Manifest ein,
+- setzt das Release als aktuell.
+
+Die angezeigte Prüfsumme sollte mit der des Build-Skripts übereinstimmen. Tut sie
+das nicht, wurde eine andere Datei hochgeladen.
+
+Schlägt der Upload ohne erkennbaren Grund fehl, ist meist das PHP-Upload-Limit
+kleiner als das Paket. Die geltenden Werte stehen auf der Releases-Seite und unter
+**Einstellungen → Diagnose**.
+
+## 3. Aktuelles Release wählen
+
+Ein Upload setzt das neue Release automatisch als aktuell. Über **Als aktuell
+setzen** kann jederzeit ein anderes gewählt werden – das ist auch der Weg, um nach
+einem missglückten Release wieder auf die vorherige Fassung zu zeigen.
+
+Wichtig: Das ändert nur, was Instanzen künftig herunterladen. Bereits ausgerollte
+Instanzen bleiben, wo sie sind – der Client kennt keine Rücknahme. Eine Instanz auf
+die ältere Fassung zurückzubringen heißt, sie mit `update --force` erneut ausrollen
+zu lassen; Datenänderungen aus Migrationen macht das nicht rückgängig.
+
+## 4. Ausrollen
+
+Auf der Instanz:
+
+```bash
+php manage-client/bin/manage-client.php check     # Exit 2 = Update verfügbar
+php manage-client/bin/manage-client.php backup    # vorher sichern
+php manage-client/bin/manage-client.php update
+```
+
+Oder über die Oberfläche im Adminbereich des Projekts.
+
+Updates laufen nicht automatisch. Sie überschreiben Dateien im laufenden Betrieb
+und können Migrationen auslösen; das gehört unter Aufsicht.
+
+## Release löschen
+
+**Löschen** entfernt den Manifest-Eintrag und die ZIP-Datei. War es das aktuelle
+Release, hat der Server danach keines – Instanzen melden dann `Es ist kein gültiges
+Release veröffentlicht`. Vorher ein anderes als aktuell setzen.
+
+## Versionsnummern
+
+Format `vX.Y.Z`, sonst nichts. Client und Server lehnen alles andere ab. Eine
+bereits veröffentlichte Version erneut hochzuladen überschreibt das Paket – bei
+einem fehlerhaften Release ist eine neue Patch-Version die sauberere Wahl, weil
+Instanzen sonst je nach Zeitpunkt Unterschiedliches installiert haben.
+
+## Weiter
+
+- [../client-package/docs/06_UPDATE_PACKAGING.md](../client-package/docs/06_UPDATE_PACKAGING.md) – Paketaufbau
+- [../client-package/docs/07_POST_UPDATE_HOOKS.md](../client-package/docs/07_POST_UPDATE_HOOKS.md) – Migrationen im Paket
+- [INSTANCE_MANAGEMENT](INSTANCE_MANAGEMENT.md) – Instanzen

+ 181 - 0
docs/SERVER_SETUP.md

@@ -0,0 +1,181 @@
+# Server einrichten
+
+## Überblick
+
+Installation und Betrieb des Manage-Servers. Voraussetzungen: PHP 8.0 oder neuer
+und ein Webserver. Kein Datenbankserver, kein Composer, kein Build-Schritt.
+
+## Installation
+
+1. Repository in ein Verzeichnis des Webservers legen, zum Beispiel
+   `/var/www/manage`.
+
+2. Konfiguration anlegen:
+
+   ```bash
+   cp config.sample.php config.php
+   ```
+
+3. Passwort-Hash erzeugen und eintragen:
+
+   ```bash
+   php -r 'echo password_hash("ein-langes-passwort", PASSWORD_DEFAULT), PHP_EOL;'
+   ```
+
+   ```php
+   define("MANAGE_ADMIN_PASSWORD_HASH", '$2y$12$…');
+   ```
+
+   Einfache Anführungszeichen verwenden – ein bcrypt-Hash enthält `$`.
+
+4. Öffentliche URL setzen. Ohne diesen Wert kann kein Client ein Paket
+   herunterladen:
+
+   ```php
+   define("MANAGE_PUBLIC_URL", "https://manage.example.org");
+   ```
+
+   Absolut, ohne Schrägstrich am Ende. Liegt die Installation in einem
+   Unterverzeichnis, gehört es dazu: `https://example.org/manage`.
+
+5. Produkt benennen. `MANAGE_PACKAGE_PREFIX` bestimmt den Dateinamen der
+   gespeicherten Pakete und sollte zum Build-Skript des Projekts passen:
+
+   ```php
+   define("MANAGE_PRODUCT_NAME", "PSA Orderform");
+   define("MANAGE_PACKAGE_PREFIX", "psa-orderform");
+   ```
+
+6. Schreibrechte auf `storage/` sicherstellen. Das Verzeichnis wird bei Bedarf
+   selbst angelegt:
+
+   ```bash
+   mkdir -p storage && chown www-data:www-data storage && chmod 2775 storage
+   ```
+
+7. Oberfläche öffnen: `https://manage.example.org/admin/login.php`.
+
+Unter **Einstellungen → Diagnose** steht danach, ob alles Wesentliche stimmt:
+öffentliche URL, Schreibrechte, Passwort, Upload-Limits.
+
+## Webserver
+
+### Apache
+
+Die mitgelieferte `.htaccess` sperrt `storage/`, `includes/`, `client-package/`
+sowie `config.php` und setzt Sicherheits-Header. Sie funktioniert nur, wenn
+`AllowOverride All` für das Verzeichnis gesetzt ist.
+
+### nginx
+
+Für nginx greift keine `.htaccess`. Die Sperren müssen von Hand gesetzt werden:
+
+```nginx
+location ^~ /storage/       { deny all; return 404; }
+location ^~ /includes/      { deny all; return 404; }
+location ^~ /client-package/ { deny all; return 404; }
+location = /config.php      { deny all; return 404; }
+location ~ /\.              { deny all; return 404; }
+```
+
+Prüfen, dass die Sperren greifen:
+
+```bash
+curl -s -o /dev/null -w "%{http_code}\n" https://manage.example.org/config.php
+curl -s -o /dev/null -w "%{http_code}\n" https://manage.example.org/storage/instances.json
+```
+
+Beides muss `403` oder `404` liefern.
+
+## Upload-Limits
+
+Backups und Release-Pakete werden per HTTP hochgeladen. Beide PHP-Grenzen müssen
+groß genug sein, `post_max_size` mindestens so groß wie `upload_max_filesize`:
+
+```ini
+upload_max_filesize = 256M
+post_max_size = 256M
+max_execution_time = 300
+memory_limit = 256M
+```
+
+Die aktuellen Werte zeigt die Diagnose-Seite. Ist der Wert zu klein, meldet der
+Client eine Fehlermeldung, die die Ursache ausdrücklich benennt.
+
+`memory_limit` wird relevant, wenn das S3-Archiv aktiv ist: Ein Upload zu S3 hält
+die Datei vollständig im Speicher.
+
+## Aufbewahrung
+
+Unter **Einstellungen** einstellbar, gespeichert in `storage/settings.json`. Die
+Werte dort haben Vorrang vor den Konstanten in `config.php`.
+
+- **Lokale Backups pro Instanz** – Standard 30, Minimum 1
+- **S3-Backups pro Instanz** – Standard 365, nur bei aktivem S3-Archiv
+
+Änderungen werden sofort angewendet, nicht erst beim nächsten Upload.
+
+## S3-Archiv (optional)
+
+Ohne S3 liegen alle Backups auf der lokalen Platte. Mit S3 wird jedes empfangene
+Backup zusätzlich in ein S3-kompatibles Objektspeicher-System geschoben; lokal
+bleiben nur die neuesten Kopien.
+
+```php
+define("MANAGE_S3_ENABLED", true);
+define("MANAGE_S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
+define("MANAGE_S3_REGION", "fsn1");
+define("MANAGE_S3_BUCKET", "mein-backup-bucket");
+define("MANAGE_S3_PREFIX", "manage-backups");
+define("MANAGE_S3_ACCESS_KEY", "…");
+define("MANAGE_S3_SECRET_KEY", "…");
+```
+
+Objekte liegen unter `<prefix>/<instanz>/<dateiname>`.
+
+Adressierung: Standard ist virtual-hosted (`https://<bucket>.<endpoint>/<key>`),
+was Hetzner und die meisten Anbieter erwarten. Verlangt der Anbieter path-style,
+`MANAGE_S3_PATH_STYLE` auf `true` setzen.
+
+Verhalten:
+
+- S3-Fehler lassen einen Client-Upload nie fehlschlagen.
+- Eine lokale Kopie wird erst gelöscht, wenn sie aus der lokalen Aufbewahrung
+  gefallen **und** die S3-Kopie bestätigt ist.
+- Fehlgeschlagene Uploads werden beim nächsten Upload derselben Instanz oder über
+  "S3-Uploads jetzt nachholen" wiederholt.
+- Der Bucket kann und soll privat bleiben: Downloads laufen über die Oberfläche.
+- Beim Aktivieren auf einer bestehenden Installation einmal "S3-Uploads jetzt
+  nachholen" drücken, damit das Archiv aufgeholt wird.
+
+Fehlersuche über `storage/logs/s3.log` und den Auszug unter **Einstellungen**:
+`AccessDenied` oder eine Umleitung in der Statuskette deuten fast immer auf die
+falsche Adressierungsart hin, `SignatureDoesNotMatch` auf falsche Region oder
+falschen Secret Key. Umleitungen werden bewusst nicht verfolgt, damit eine
+Fehlkonfiguration sichtbar wird.
+
+## Sicherung des Servers
+
+Der Manage-Server hält Release-Pakete und die Backups aller Instanzen – er ist
+selbst sicherungswürdig. Zu sichern sind:
+
+- `storage/` – Instanzen, Manifest, Pakete, Backups, Einstellungen
+- `config.php` – Zugangsdaten
+
+Bei aktivem S3-Archiv liegen die Backups zusätzlich im Bucket; `storage/instances.json`
+und `storage/releases/` aber nicht.
+
+## Betrieb
+
+- Die Anmeldung ist pro IP begrenzt (Standard 10 Versuche je 15 Minuten).
+- Ratenbegrenzung gilt auch für fehlgeschlagene API-Authentifizierungen.
+- Protokolle liegen unter `storage/logs/` und rotieren automatisch.
+- Ein Passwortwechsel erfolgt in `config.php`; angemeldete Sitzungen bleiben bis
+  zum Ablauf bestehen. Sollen sie sofort enden, `session.save_path` leeren oder
+  `session_name` in `includes/auth.php` ändern.
+
+## Weiter
+
+- [INSTANCE_MANAGEMENT](INSTANCE_MANAGEMENT.md) – Instanzen anlegen und Tokens vergeben
+- [RELEASING](RELEASING.md) – Releases bauen und veröffentlichen
+- [CONFIG_REFERENCE](CONFIG_REFERENCE.md) – alle Serverkonstanten

+ 200 - 0
docs/assets/docs.css

@@ -0,0 +1,200 @@
+:root {
+    color-scheme: light;
+    --docs-bg: #f5f6f8;
+    --docs-surface: #fff;
+    --docs-text: #1a1a1a;
+    --docs-muted: #5c6370;
+    --docs-accent: #003366;
+    --docs-border: #d8dde6;
+    --docs-code-bg: #eef1f5;
+    font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+    line-height: 1.6;
+}
+
+*,
+*::before,
+*::after {
+    box-sizing: border-box;
+}
+
+body {
+    margin: 0;
+    background: var(--docs-bg);
+    color: var(--docs-text);
+}
+
+.docs-header {
+    background: var(--docs-accent);
+    color: #fff;
+}
+
+.docs-header-inner {
+    max-width: 72rem;
+    margin: 0 auto;
+    padding: 0.75rem 1.25rem;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 1rem;
+}
+
+.docs-brand,
+.docs-back {
+    color: inherit;
+    text-decoration: none;
+}
+
+.docs-back {
+    font-size: 0.9rem;
+    opacity: 0.9;
+}
+
+.docs-back:hover,
+.docs-brand:hover {
+    text-decoration: underline;
+}
+
+.docs-layout {
+    max-width: 72rem;
+    margin: 0 auto;
+    padding: 1.25rem;
+    display: grid;
+    grid-template-columns: minmax(12rem, 16rem) 1fr;
+    gap: 1.5rem;
+    align-items: start;
+}
+
+@media (max-width: 768px) {
+    .docs-layout {
+        grid-template-columns: 1fr;
+    }
+}
+
+.docs-nav {
+    background: var(--docs-surface);
+    border: 1px solid var(--docs-border);
+    border-radius: 0.5rem;
+    padding: 1rem;
+    position: sticky;
+    top: 1rem;
+}
+
+.docs-nav-title {
+    margin: 0 0 0.5rem;
+    font-size: 0.75rem;
+    text-transform: uppercase;
+    letter-spacing: 0.04em;
+    color: var(--docs-muted);
+}
+
+.docs-nav ul {
+    margin: 0;
+    padding: 0;
+    list-style: none;
+}
+
+.docs-nav li + li {
+    margin-top: 0.25rem;
+}
+
+.docs-nav a {
+    display: block;
+    padding: 0.35rem 0.5rem;
+    border-radius: 0.25rem;
+    color: var(--docs-accent);
+    text-decoration: none;
+    font-size: 0.9rem;
+}
+
+.docs-nav a:hover {
+    background: var(--docs-code-bg);
+}
+
+.docs-nav a[aria-current="page"] {
+    background: var(--docs-accent);
+    color: #fff;
+}
+
+.docs-main {
+    background: var(--docs-surface);
+    border: 1px solid var(--docs-border);
+    border-radius: 0.5rem;
+    padding: 1.5rem 2rem;
+    min-width: 0;
+}
+
+.docs-index-list {
+    padding-left: 1.25rem;
+}
+
+.docs-index-list a {
+    color: var(--docs-accent);
+}
+
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3 {
+    line-height: 1.25;
+    margin-top: 1.5em;
+    margin-bottom: 0.5em;
+}
+
+.markdown-body h1:first-child {
+    margin-top: 0;
+}
+
+.markdown-body p,
+.markdown-body ul,
+.markdown-body ol,
+.markdown-body pre,
+.markdown-body table {
+    margin: 0.75em 0;
+}
+
+.markdown-body a {
+    color: var(--docs-accent);
+}
+
+.markdown-body code {
+    font-family: ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
+    font-size: 0.9em;
+    background: var(--docs-code-bg);
+    padding: 0.1em 0.35em;
+    border-radius: 0.2em;
+}
+
+.markdown-body pre {
+    background: var(--docs-code-bg);
+    padding: 1rem;
+    overflow-x: auto;
+    border-radius: 0.35rem;
+}
+
+.markdown-body pre code {
+    padding: 0;
+    background: none;
+}
+
+.markdown-body table {
+    border-collapse: collapse;
+    width: 100%;
+    font-size: 0.95rem;
+}
+
+.markdown-body th,
+.markdown-body td {
+    border: 1px solid var(--docs-border);
+    padding: 0.4rem 0.6rem;
+    text-align: left;
+}
+
+.markdown-body th {
+    background: var(--docs-code-bg);
+}
+
+.markdown-body blockquote {
+    margin: 1em 0;
+    padding-left: 1em;
+    border-left: 4px solid var(--docs-border);
+    color: var(--docs-muted);
+}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 11 - 0
docs/assets/marked.min.js


+ 151 - 0
docs/index.php

@@ -0,0 +1,151 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * Documentation viewer (Markdown -> HTML via the vendored marked.js).
+ * Adapted from the PSA order system (docs/index.php). Self-contained: no build
+ * step and no external requests, so it also works from an unpacked ZIP.
+ */
+
+$docsDir = __DIR__;
+$docMap = [];
+
+foreach (glob($docsDir . "/*.md") ?: [] as $path) {
+    $base = basename($path, ".md");
+    if (preg_match('/^[A-Z0-9_]+$/', $base) !== 1) {
+        continue;
+    }
+    $docMap[$base] = $path;
+}
+
+ksort($docMap);
+
+function docTitle(string $key): string
+{
+    // Drop a leading sort prefix like "01_" for display only.
+    $key = preg_replace('/^\d+_/', "", $key) ?? $key;
+
+    return ucwords(strtolower(str_replace("_", " ", $key)));
+}
+
+function docEscape(string $value): string
+{
+    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
+}
+
+$docsTitle = basename(dirname($docsDir)) === "client-package"
+    ? "Manage Client"
+    : "Manage Server";
+
+$requested = isset($_GET["doc"]) ? (string) $_GET["doc"] : "";
+$activeDoc = null;
+$markdown = null;
+$pageTitle = "Dokumentation";
+
+if ($requested !== "") {
+    if (!isset($docMap[$requested])) {
+        http_response_code(404);
+        $pageTitle = "Nicht gefunden";
+    } else {
+        $activeDoc = $requested;
+        $markdown = file_get_contents($docMap[$requested]);
+        if ($markdown === false) {
+            http_response_code(500);
+            $pageTitle = "Fehler";
+            $markdown = null;
+        } else {
+            $pageTitle = docTitle($activeDoc);
+        }
+    }
+}
+
+// Works both when docs/ is a subdirectory and when it is served as the web
+// root (php -S localhost:8080 -t docs), where dirname() yields "/".
+$baseHref = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/") . "/";
+if ($baseHref === "") {
+    $baseHref = "/";
+}
+?>
+<!DOCTYPE html>
+<html lang="de">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><?php echo docEscape($pageTitle); ?> – <?php echo docEscape($docsTitle); ?></title>
+    <link rel="stylesheet" href="<?php echo docEscape($baseHref); ?>assets/docs.css">
+</head>
+<body>
+<header class="docs-header">
+    <div class="docs-header-inner">
+        <a class="docs-brand" href="<?php echo docEscape($baseHref); ?>index.php"><?php echo docEscape($docsTitle); ?></a>
+    </div>
+</header>
+<div class="docs-layout">
+    <nav class="docs-nav" aria-label="Dokumentation">
+        <p class="docs-nav-title">Inhalt</p>
+        <ul>
+            <?php foreach ($docMap as $key => $_path): ?>
+                <li>
+                    <a href="<?php echo docEscape($baseHref); ?>index.php?doc=<?php echo docEscape($key); ?>"
+                       <?php echo $key === $activeDoc ? 'aria-current="page"' : ""; ?>>
+                        <?php echo docEscape(docTitle($key)); ?>
+                    </a>
+                </li>
+            <?php endforeach; ?>
+        </ul>
+    </nav>
+    <main class="docs-main">
+        <?php if ($requested === ""): ?>
+            <h1>Dokumentation</h1>
+            <p>Alle Dokumente in empfohlener Lesereihenfolge.</p>
+            <ul class="docs-index-list">
+                <?php foreach ($docMap as $key => $_path): ?>
+                    <li>
+                        <a href="<?php echo docEscape($baseHref); ?>index.php?doc=<?php echo docEscape($key); ?>">
+                            <?php echo docEscape(docTitle($key)); ?>
+                        </a>
+                    </li>
+                <?php endforeach; ?>
+            </ul>
+        <?php elseif ($markdown === null): ?>
+            <h1><?php echo docEscape($pageTitle); ?></h1>
+            <p>Das angeforderte Dokument ist nicht verfügbar.</p>
+            <p><a href="<?php echo docEscape($baseHref); ?>index.php">Zur Übersicht</a></p>
+        <?php else: ?>
+            <article id="doc-content" class="markdown-body"></article>
+            <script type="application/json" id="doc-source"><?php
+                echo json_encode(
+                    $markdown,
+                    JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE,
+                );
+            ?></script>
+        <?php endif; ?>
+    </main>
+</div>
+<?php if ($markdown !== null): ?>
+    <script src="<?php echo docEscape($baseHref); ?>assets/marked.min.js"></script>
+    <script>
+        (function () {
+            var source = document.getElementById('doc-source');
+            var target = document.getElementById('doc-content');
+            if (!source || !target || typeof marked === 'undefined') {
+                return;
+            }
+            var text = JSON.parse(source.textContent || '""');
+            target.innerHTML = marked.parse(text, { gfm: true, breaks: false });
+            target.querySelectorAll('a[href]').forEach(function (link) {
+                var href = link.getAttribute('href');
+                if (!href || /^[a-z]+:/i.test(href) || href.charAt(0) === '#') {
+                    return;
+                }
+                var name = href.split('/').pop().replace(/\.md$/i, '');
+                if (/^[A-Z0-9_]+$/i.test(name)) {
+                    link.setAttribute('href', 'index.php?doc=' + encodeURIComponent(name.toUpperCase()));
+                }
+            });
+        })();
+    </script>
+<?php endif; ?>
+</body>
+</html>

+ 128 - 0
includes/api.php

@@ -0,0 +1,128 @@
+<?php
+
+declare(strict_types=1);
+
+// Shared request handling for the token-authenticated api/v1 endpoints.
+//
+// Protocol v1: every request carries the instance id and its secret token in
+// two headers. There is no session, no cookie and no shared password.
+//
+//   X-Manage-Instance: psa-prod
+//   X-Manage-Token:    <64 hex chars>
+
+require_once __DIR__ . "/bootstrap.php";
+require_once __DIR__ . "/instances.php";
+
+function manageApiSendJson(int $status, array $payload): void
+{
+    http_response_code($status);
+    header("Content-Type: application/json; charset=utf-8");
+    header("Cache-Control: no-store");
+    header("X-Content-Type-Options: nosniff");
+
+    echo json_encode(
+        $payload,
+        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+    );
+    exit;
+}
+
+function manageApiFail(int $status, string $error, array $extra = []): void
+{
+    manageApiSendJson($status, array_merge([
+        "success" => false,
+        "error" => $error,
+    ], $extra));
+}
+
+function manageApiRequireMethod(string $method): void
+{
+    if (($_SERVER["REQUEST_METHOD"] ?? "") !== $method) {
+        header("Allow: " . $method);
+        manageApiFail(405, $method . " erforderlich.");
+    }
+}
+
+// Reads a request header regardless of SAPI. Apache with mod_php exposes
+// X-Manage-Token as HTTP_X_MANAGE_TOKEN; some setups only fill getallheaders().
+function manageApiHeader(string $name): string
+{
+    $key = "HTTP_" . strtoupper(str_replace("-", "_", $name));
+    if (isset($_SERVER[$key])) {
+        return trim((string) $_SERVER[$key]);
+    }
+
+    if (function_exists("getallheaders")) {
+        foreach (getallheaders() ?: [] as $headerName => $value) {
+            if (strcasecmp((string) $headerName, $name) === 0) {
+                return trim((string) $value);
+            }
+        }
+    }
+
+    return "";
+}
+
+/**
+ * Authenticates the calling instance or terminates the request.
+ *
+ * Failures are rate-limited per IP and answered with the same generic message,
+ * so the endpoint cannot be used to enumerate valid instance ids.
+ *
+ * @return array the instance record
+ */
+function manageApiAuthenticate(): array
+{
+    if (!manageRateLimitTryConsume(
+        "api-auth",
+        (int) MANAGE_API_RATE_LIMIT_MAX,
+        (int) MANAGE_API_RATE_LIMIT_WINDOW,
+    )) {
+        manageApiFail(429, "Zu viele Anfragen. Bitte später erneut versuchen.");
+    }
+
+    $id = manageApiHeader("X-Manage-Instance");
+    $token = manageApiHeader("X-Manage-Token");
+
+    if ($id === "" || $token === "") {
+        manageApiFail(401, "Authentifizierung erforderlich.");
+    }
+
+    try {
+        $id = manageInstanceValidateId($id);
+    } catch (Throwable $exception) {
+        manageApiFail(401, "Authentifizierung fehlgeschlagen.");
+    }
+
+    $instance = manageInstanceAuthenticate($id, $token);
+    if ($instance === null) {
+        manageLogError("API authentication failed", ["instance" => $id]);
+        manageApiFail(401, "Authentifizierung fehlgeschlagen.");
+    }
+
+    if (!$instance["enabled"]) {
+        manageApiFail(403, "Diese Instanz ist deaktiviert.");
+    }
+
+    // A valid token clears the failure budget for this IP.
+    manageRateLimitClearIp("api-auth");
+
+    return $instance;
+}
+
+// Decodes a JSON request body. Returns an empty array for an empty body so
+// optional payloads do not need a special case at every call site.
+function manageApiReadJsonBody(): array
+{
+    $raw = file_get_contents("php://input");
+    if (!is_string($raw) || trim($raw) === "") {
+        return [];
+    }
+
+    $decoded = json_decode($raw, true);
+    if (!is_array($decoded)) {
+        manageApiFail(400, "Anfrage-Body ist kein gültiges JSON.");
+    }
+
+    return $decoded;
+}

+ 123 - 0
includes/auth.php

@@ -0,0 +1,123 @@
+<?php
+
+declare(strict_types=1);
+
+// Admin session, password check and CSRF for the management UI. Merges the two
+// near-identical login halves of the PSA update-server/manage.php and
+// backup-server/manage.php into one implementation.
+
+require_once __DIR__ . "/bootstrap.php";
+
+function manageStartSession(): void
+{
+    if (session_status() !== PHP_SESSION_NONE) {
+        return;
+    }
+
+    $isHttps =
+        (!empty($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] !== "off") ||
+        (isset($_SERVER["SERVER_PORT"]) && (int) $_SERVER["SERVER_PORT"] === 443) ||
+        strtolower((string) ($_SERVER["HTTP_X_FORWARDED_PROTO"] ?? "")) === "https";
+
+    ini_set("session.use_strict_mode", "1");
+    ini_set("session.cookie_httponly", "1");
+    ini_set("session.cookie_secure", $isHttps ? "1" : "0");
+    ini_set("session.cookie_samesite", "Lax");
+    session_name("manage_session");
+    session_start();
+}
+
+function managePasswordConfigured(): bool
+{
+    if (defined("MANAGE_ADMIN_PASSWORD_HASH")) {
+        $hash = trim((string) MANAGE_ADMIN_PASSWORD_HASH);
+        if ($hash !== "" && !str_contains($hash, "replace-this")) {
+            return true;
+        }
+    }
+
+    return defined("MANAGE_ADMIN_PASSWORD") && trim((string) MANAGE_ADMIN_PASSWORD) !== "";
+}
+
+function managePasswordMatches(string $password): bool
+{
+    if (defined("MANAGE_ADMIN_PASSWORD_HASH")) {
+        $hash = trim((string) MANAGE_ADMIN_PASSWORD_HASH);
+        if ($hash !== "" && !str_contains($hash, "replace-this")) {
+            return password_verify($password, $hash);
+        }
+    }
+
+    if (defined("MANAGE_ADMIN_PASSWORD")) {
+        return hash_equals((string) MANAGE_ADMIN_PASSWORD, $password);
+    }
+
+    return false;
+}
+
+function manageIsLoggedIn(): bool
+{
+    return !empty($_SESSION["manage_logged_in"]);
+}
+
+function manageLogin(): void
+{
+    session_regenerate_id(true);
+    $_SESSION["manage_logged_in"] = true;
+    $_SESSION["manage_logged_in_at"] = time();
+}
+
+function manageLogout(): void
+{
+    unset(
+        $_SESSION["manage_logged_in"],
+        $_SESSION["manage_logged_in_at"],
+        $_SESSION["manage_csrf_token"],
+    );
+}
+
+// Redirects to the login page instead of rendering. Every admin page calls this
+// as its first statement after the includes.
+function manageRequireLogin(): void
+{
+    manageStartSession();
+    if (manageIsLoggedIn()) {
+        return;
+    }
+
+    $target = basename((string) ($_SERVER["SCRIPT_NAME"] ?? ""));
+    header("Location: login.php?next=" . rawurlencode($target));
+    exit;
+}
+
+function manageCsrfToken(): string
+{
+    if (empty($_SESSION["manage_csrf_token"])) {
+        $_SESSION["manage_csrf_token"] = bin2hex(random_bytes(32));
+    }
+
+    return (string) $_SESSION["manage_csrf_token"];
+}
+
+function manageCsrfIsValid(string $token): bool
+{
+    return !empty($_SESSION["manage_csrf_token"]) &&
+        hash_equals((string) $_SESSION["manage_csrf_token"], $token);
+}
+
+function manageCsrfField(): string
+{
+    return '<input type="hidden" name="csrf_token" value="' .
+        manageEscape(manageCsrfToken()) .
+        '">';
+}
+
+// Guard for every mutating POST in the admin UI.
+function manageRequireCsrf(): void
+{
+    if (!manageCsrfIsValid((string) ($_POST["csrf_token"] ?? ""))) {
+        throw new RuntimeException(
+            "Ungültiges Sicherheitstoken. Bitte die Seite neu laden und erneut versuchen.",
+        );
+    }
+}

+ 616 - 0
includes/backups.php

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

+ 171 - 0
includes/bootstrap.php

@@ -0,0 +1,171 @@
+<?php
+
+declare(strict_types=1);
+
+// Shared entry point for every server-side script. Loads config.php, fills in
+// defaults for all MANAGE_* constants, and pulls in the low-level helpers.
+// Deliberately does not start a session: the API endpoints are stateless and
+// only the admin pages call manageStartSession().
+
+$manageConfigFile = dirname(__DIR__) . "/config.php";
+if (is_file($manageConfigFile)) {
+    require_once $manageConfigFile;
+}
+
+if (!defined("MANAGE_PRODUCT_NAME")) {
+    define("MANAGE_PRODUCT_NAME", "Managed Application");
+}
+if (!defined("MANAGE_PACKAGE_PREFIX")) {
+    define("MANAGE_PACKAGE_PREFIX", "release");
+}
+if (!defined("MANAGE_PUBLIC_URL")) {
+    define("MANAGE_PUBLIC_URL", "");
+}
+if (!defined("MANAGE_STORAGE_DIR")) {
+    define("MANAGE_STORAGE_DIR", dirname(__DIR__) . "/storage/");
+}
+if (!defined("MANAGE_BACKUP_RETENTION")) {
+    define("MANAGE_BACKUP_RETENTION", 30);
+}
+if (!defined("MANAGE_BACKUP_MAX_UPLOAD_BYTES")) {
+    define("MANAGE_BACKUP_MAX_UPLOAD_BYTES", 0);
+}
+if (!defined("MANAGE_S3_ENABLED")) {
+    define("MANAGE_S3_ENABLED", false);
+}
+if (!defined("MANAGE_S3_ENDPOINT")) {
+    define("MANAGE_S3_ENDPOINT", "");
+}
+if (!defined("MANAGE_S3_REGION")) {
+    define("MANAGE_S3_REGION", "");
+}
+if (!defined("MANAGE_S3_BUCKET")) {
+    define("MANAGE_S3_BUCKET", "");
+}
+if (!defined("MANAGE_S3_PREFIX")) {
+    define("MANAGE_S3_PREFIX", "");
+}
+if (!defined("MANAGE_S3_ACCESS_KEY")) {
+    define("MANAGE_S3_ACCESS_KEY", "");
+}
+if (!defined("MANAGE_S3_SECRET_KEY")) {
+    define("MANAGE_S3_SECRET_KEY", "");
+}
+if (!defined("MANAGE_S3_PATH_STYLE")) {
+    define("MANAGE_S3_PATH_STYLE", false);
+}
+if (!defined("MANAGE_S3_TIMEOUT")) {
+    define("MANAGE_S3_TIMEOUT", 120);
+}
+if (!defined("MANAGE_S3_RETENTION")) {
+    define("MANAGE_S3_RETENTION", 365);
+}
+if (!defined("MANAGE_LOGIN_RATE_LIMIT_MAX")) {
+    define("MANAGE_LOGIN_RATE_LIMIT_MAX", 10);
+}
+if (!defined("MANAGE_LOGIN_RATE_LIMIT_WINDOW")) {
+    define("MANAGE_LOGIN_RATE_LIMIT_WINDOW", 900);
+}
+if (!defined("MANAGE_API_RATE_LIMIT_MAX")) {
+    define("MANAGE_API_RATE_LIMIT_MAX", 240);
+}
+if (!defined("MANAGE_API_RATE_LIMIT_WINDOW")) {
+    define("MANAGE_API_RATE_LIMIT_WINDOW", 300);
+}
+if (!defined("MANAGE_LOG_MAX_BYTES")) {
+    define("MANAGE_LOG_MAX_BYTES", 1048576);
+}
+if (!defined("MANAGE_LOG_KEEP_FILES")) {
+    define("MANAGE_LOG_KEEP_FILES", 5);
+}
+if (!defined("MANAGE_LOG_MAX_AGE_SECONDS")) {
+    define("MANAGE_LOG_MAX_AGE_SECONDS", 2592000);
+}
+
+// Derived storage paths. Kept as functions rather than constants so a config
+// may override MANAGE_STORAGE_DIR without having to restate every child path.
+function manageStorageDir(): string
+{
+    return rtrim((string) MANAGE_STORAGE_DIR, "/\\") . DIRECTORY_SEPARATOR;
+}
+
+function manageInstancesFile(): string
+{
+    return manageStorageDir() . "instances.json";
+}
+
+function manageSettingsFile(): string
+{
+    return manageStorageDir() . "settings.json";
+}
+
+function manageReleasesDir(): string
+{
+    return manageStorageDir() . "releases" . DIRECTORY_SEPARATOR;
+}
+
+function manageManifestFile(): string
+{
+    return manageReleasesDir() . "manifest.json";
+}
+
+function managePackagesDir(): string
+{
+    return manageReleasesDir() . "packages" . DIRECTORY_SEPARATOR;
+}
+
+function manageBackupsDir(): string
+{
+    return manageStorageDir() . "backups" . DIRECTORY_SEPARATOR;
+}
+
+function manageBackupIndexFile(): string
+{
+    return manageBackupsDir() . "index.json";
+}
+
+function manageLogsDir(): string
+{
+    return manageStorageDir() . "logs" . DIRECTORY_SEPARATOR;
+}
+
+function manageS3LogFile(): string
+{
+    return manageLogsDir() . "s3.log";
+}
+
+function manageProductName(): string
+{
+    $name = trim((string) MANAGE_PRODUCT_NAME);
+
+    return $name !== "" ? $name : "Managed Application";
+}
+
+function manageEscape($value): string
+{
+    return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
+}
+
+function manageIsVersion(string $version): bool
+{
+    return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
+}
+
+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";
+}
+
+require_once __DIR__ . "/storage.php";
+require_once __DIR__ . "/log.php";
+require_once __DIR__ . "/ratelimit.php";

+ 270 - 0
includes/instances.php

@@ -0,0 +1,270 @@
+<?php
+
+declare(strict_types=1);
+
+// Instance registry. Replaces the backup server's plain name allowlist and
+// gives the update server the client identity it never had. One record per
+// deployed client instance, stored in storage/instances.json.
+//
+// Tokens are stored as a SHA-256 hash only. The plaintext is returned exactly
+// once, when it is created or rotated, and can never be recovered afterwards.
+
+require_once __DIR__ . "/bootstrap.php";
+
+function manageInstanceValidateId(string $id): string
+{
+    $id = trim($id);
+    if (
+        $id === "" ||
+        strlen($id) > 120 ||
+        preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $id) !== 1
+    ) {
+        throw new RuntimeException(
+            "Ungültige Instanz-Kennung. Erlaubt sind Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich.",
+        );
+    }
+
+    return $id;
+}
+
+function manageInstanceGenerateToken(): string
+{
+    return bin2hex(random_bytes(32));
+}
+
+function manageInstanceHashToken(string $token): string
+{
+    return hash("sha256", $token);
+}
+
+// Fills in every field a caller may read, so the rest of the code never has to
+// guard against records written by an older version.
+function manageInstanceNormalize(array $record): array
+{
+    return [
+        "id" => (string) ($record["id"] ?? ""),
+        "label" => (string) ($record["label"] ?? ""),
+        "enabled" => (bool) ($record["enabled"] ?? true),
+        "token_hash" => (string) ($record["token_hash"] ?? ""),
+        "created_at" => (string) ($record["created_at"] ?? ""),
+        "token_rotated_at" => (string) ($record["token_rotated_at"] ?? ""),
+        "last_seen_at" => (string) ($record["last_seen_at"] ?? ""),
+        "last_ip" => (string) ($record["last_ip"] ?? ""),
+        "version" => (string) ($record["version"] ?? ""),
+        "php_version" => (string) ($record["php_version"] ?? ""),
+        "disk_free" => (int) ($record["disk_free"] ?? 0),
+        "pending_migrations" => (int) ($record["pending_migrations"] ?? 0),
+        "last_backup_at" => (string) ($record["last_backup_at"] ?? ""),
+        "backup_count" => (int) ($record["backup_count"] ?? 0),
+        "notes" => (string) ($record["notes"] ?? ""),
+    ];
+}
+
+function manageInstanceList(): array
+{
+    $data = manageReadJsonFile(manageInstancesFile());
+    $records = isset($data["instances"]) && is_array($data["instances"])
+        ? $data["instances"]
+        : [];
+
+    $instances = [];
+    foreach ($records as $record) {
+        if (!is_array($record)) {
+            continue;
+        }
+        $normalized = manageInstanceNormalize($record);
+        if ($normalized["id"] === "") {
+            continue;
+        }
+        $instances[] = $normalized;
+    }
+
+    usort($instances, static function (array $left, array $right): int {
+        return strcmp($left["id"], $right["id"]);
+    });
+
+    return $instances;
+}
+
+function manageInstanceWriteAll(array $instances): void
+{
+    manageWriteJsonFile(manageInstancesFile(), [
+        "instances" => array_values($instances),
+    ]);
+}
+
+function manageInstanceFind(string $id): ?array
+{
+    foreach (manageInstanceList() as $instance) {
+        if ($instance["id"] === $id) {
+            return $instance;
+        }
+    }
+
+    return null;
+}
+
+function manageInstanceExists(string $id): bool
+{
+    return manageInstanceFind($id) !== null;
+}
+
+/**
+ * @return array{instance: array, token: string} the plaintext token is shown once
+ */
+function manageInstanceCreate(string $id, string $label = "", string $notes = ""): array
+{
+    $id = manageInstanceValidateId($id);
+    if (manageInstanceExists($id)) {
+        throw new RuntimeException("Eine Instanz mit dieser Kennung existiert bereits.");
+    }
+
+    $token = manageInstanceGenerateToken();
+    $instance = manageInstanceNormalize([
+        "id" => $id,
+        "label" => trim($label),
+        "enabled" => true,
+        "token_hash" => manageInstanceHashToken($token),
+        "created_at" => date(DATE_ATOM),
+        "token_rotated_at" => date(DATE_ATOM),
+        "notes" => trim($notes),
+    ]);
+
+    $instances = manageInstanceList();
+    $instances[] = $instance;
+    manageInstanceWriteAll($instances);
+
+    manageLogAccess("Instance created", ["instance" => $id]);
+
+    return ["instance" => $instance, "token" => $token];
+}
+
+// Applies a partial update to one instance. Unknown keys are ignored, so a
+// caller can hand over a heartbeat payload directly.
+function manageInstanceUpdate(string $id, array $changes): array
+{
+    $instances = manageInstanceList();
+    $updated = null;
+
+    foreach ($instances as $position => $instance) {
+        if ($instance["id"] !== $id) {
+            continue;
+        }
+
+        foreach ($changes as $key => $value) {
+            if ($key === "id" || $key === "token_hash" || !array_key_exists($key, $instance)) {
+                continue;
+            }
+            $instance[$key] = $value;
+        }
+
+        $updated = manageInstanceNormalize($instance);
+        $instances[$position] = $updated;
+        break;
+    }
+
+    if ($updated === null) {
+        throw new RuntimeException("Instanz wurde nicht gefunden: " . $id);
+    }
+
+    manageInstanceWriteAll($instances);
+
+    return $updated;
+}
+
+function manageInstanceRotateToken(string $id): string
+{
+    $instances = manageInstanceList();
+    $token = manageInstanceGenerateToken();
+    $found = false;
+
+    foreach ($instances as $position => $instance) {
+        if ($instance["id"] !== $id) {
+            continue;
+        }
+        $instance["token_hash"] = manageInstanceHashToken($token);
+        $instance["token_rotated_at"] = date(DATE_ATOM);
+        $instances[$position] = $instance;
+        $found = true;
+        break;
+    }
+
+    if (!$found) {
+        throw new RuntimeException("Instanz wurde nicht gefunden: " . $id);
+    }
+
+    manageInstanceWriteAll($instances);
+    manageLogAccess("Instance token rotated", ["instance" => $id]);
+
+    return $token;
+}
+
+// Removes the registry entry. Stored backups are kept on purpose: a deleted
+// instance can no longer upload, but its history stays available for download.
+function manageInstanceDelete(string $id): void
+{
+    $instances = manageInstanceList();
+    $remaining = [];
+    $found = false;
+
+    foreach ($instances as $instance) {
+        if ($instance["id"] === $id) {
+            $found = true;
+            continue;
+        }
+        $remaining[] = $instance;
+    }
+
+    if (!$found) {
+        throw new RuntimeException("Instanz wurde nicht gefunden: " . $id);
+    }
+
+    manageInstanceWriteAll($remaining);
+    manageLogAccess("Instance deleted", ["instance" => $id]);
+}
+
+/**
+ * Constant-time token check.
+ *
+ * @return array|null the instance record, or null when id/token do not match
+ */
+function manageInstanceAuthenticate(string $id, string $token): ?array
+{
+    $instance = manageInstanceFind($id);
+    if ($instance === null || $instance["token_hash"] === "") {
+        return null;
+    }
+
+    if (!hash_equals($instance["token_hash"], manageInstanceHashToken($token))) {
+        return null;
+    }
+
+    return $instance;
+}
+
+// Records what a client reported. Called from every authenticated API request
+// so the dashboard stays current even without an explicit heartbeat.
+function manageInstanceTouch(string $id, array $report = []): void
+{
+    $changes = [
+        "last_seen_at" => date(DATE_ATOM),
+        "last_ip" => (string) ($_SERVER["REMOTE_ADDR"] ?? ""),
+    ];
+
+    foreach (["version", "php_version", "disk_free", "pending_migrations"] as $key) {
+        if (array_key_exists($key, $report)) {
+            $changes[$key] = $report[$key];
+        }
+    }
+
+    try {
+        manageInstanceUpdate($id, $changes);
+    } catch (Throwable $exception) {
+        // A missing instance cannot happen here (the caller authenticated
+        // first) and a failed status write must never break the request.
+        manageLogError("Instance touch failed", [
+            "instance" => $id,
+            "error" => $exception->getMessage(),
+        ]);
+    }
+}

+ 78 - 0
includes/layout.php

@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+// Shared page chrome for the admin UI. Pages set $pageTitle and optionally
+// $flashMessages / $flashErrors before including the header.
+
+require_once __DIR__ . "/bootstrap.php";
+
+function manageNavItems(): array
+{
+    return [
+        "index.php" => "Übersicht",
+        "instances.php" => "Instanzen",
+        "releases.php" => "Releases",
+        "backups.php" => "Backups",
+        "settings.php" => "Einstellungen",
+    ];
+}
+
+function manageRenderHeader(string $pageTitle, array $messages = [], array $errors = []): void
+{
+    $current = basename((string) ($_SERVER["SCRIPT_NAME"] ?? ""));
+    ?>
+<!DOCTYPE html>
+<html lang="de">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><?php echo manageEscape($pageTitle); ?> – Manage</title>
+    <link rel="stylesheet" href="../assets/css/style.css">
+</head>
+<body>
+<header class="topbar">
+    <div class="topbar-inner">
+        <div class="brand">
+            <span class="brand-name">Manage</span>
+            <span class="brand-product"><?php echo manageEscape(manageProductName()); ?></span>
+        </div>
+        <nav class="topnav" aria-label="Hauptnavigation">
+            <?php foreach (manageNavItems() as $file => $label): ?>
+                <a href="<?php echo manageEscape($file); ?>"
+                   <?php echo $file === $current ? 'aria-current="page"' : ""; ?>>
+                    <?php echo manageEscape($label); ?>
+                </a>
+            <?php endforeach; ?>
+        </nav>
+        <form method="POST" action="logout.php" class="topbar-logout">
+            <?php echo manageCsrfField(); ?>
+            <button type="submit" class="btn btn-quiet">Abmelden</button>
+        </form>
+    </div>
+</header>
+<main class="container">
+    <h1><?php echo manageEscape($pageTitle); ?></h1>
+    <?php foreach ($messages as $message): ?>
+        <p class="alert alert-success"><?php echo manageEscape($message); ?></p>
+    <?php endforeach; ?>
+    <?php foreach ($errors as $error): ?>
+        <p class="alert alert-error"><?php echo manageEscape($error); ?></p>
+    <?php endforeach; ?>
+    <?php
+}
+
+function manageRenderFooter(): void
+{
+    ?>
+</main>
+<footer class="footer">
+    <div class="container">
+        <p>Manage – Update- und Backup-Server für <?php echo manageEscape(manageProductName()); ?>.</p>
+        <p><a href="../docs/index.php">Dokumentation</a></p>
+    </div>
+</footer>
+</body>
+</html>
+    <?php
+}

+ 137 - 0
includes/log.php

@@ -0,0 +1,137 @@
+<?php
+
+declare(strict_types=1);
+
+// JSONL logging with size-based rotation. Ported from the PSA order system
+// (includes/functions.php logError/logAccess/rotateLogFileIfNeeded).
+
+function manageErrorLogFile(): string
+{
+    return manageLogsDir() . "error.log";
+}
+
+function manageAccessLogFile(): string
+{
+    return manageLogsDir() . "access.log";
+}
+
+function manageRotateLogFileIfNeeded(string $file): void
+{
+    $maxBytes = (int) MANAGE_LOG_MAX_BYTES;
+    if ($maxBytes < 1 || !is_file($file)) {
+        return;
+    }
+
+    $size = filesize($file);
+    if ($size === false || $size < $maxBytes) {
+        manageDeleteExpiredLogFiles($file);
+        return;
+    }
+
+    $keepFiles = max(1, (int) MANAGE_LOG_KEEP_FILES);
+    for ($i = $keepFiles; $i >= 1; $i--) {
+        $source = $i === 1 ? $file : $file . "." . ($i - 1);
+        $target = $file . "." . $i;
+        if (!is_file($source)) {
+            continue;
+        }
+        if ($i === $keepFiles && is_file($target)) {
+            @unlink($target);
+        }
+        @rename($source, $target);
+    }
+
+    manageDeleteExpiredLogFiles($file);
+}
+
+function manageDeleteExpiredLogFiles(string $file): void
+{
+    $maxAge = (int) MANAGE_LOG_MAX_AGE_SECONDS;
+    if ($maxAge < 1) {
+        return;
+    }
+
+    foreach (glob($file . ".*") ?: [] as $rotated) {
+        $mtime = @filemtime($rotated);
+        if ($mtime !== false && time() - $mtime > $maxAge) {
+            @unlink($rotated);
+        }
+    }
+}
+
+function manageWriteLogEntry(string $file, array $entry): void
+{
+    try {
+        manageEnsureDirectory(dirname($file));
+    } catch (Throwable $exception) {
+        return;
+    }
+
+    manageRotateLogFileIfNeeded($file);
+
+    $line = json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+    if ($line === false) {
+        return;
+    }
+
+    @file_put_contents($file, $line . PHP_EOL, FILE_APPEND | LOCK_EX);
+}
+
+function manageLogError(string $message, array $context = [], string $level = "ERROR"): void
+{
+    manageWriteLogEntry(manageErrorLogFile(), [
+        "timestamp" => date("Y-m-d H:i:s.u"),
+        "level" => $level,
+        "message" => $message,
+        "context" => $context,
+        "ip" => $_SERVER["REMOTE_ADDR"] ?? "unknown",
+        "user_agent" => substr((string) ($_SERVER["HTTP_USER_AGENT"] ?? "unknown"), 0, 300),
+        "request_uri" => $_SERVER["REQUEST_URI"] ?? "unknown",
+    ]);
+}
+
+function manageLogAccess(string $message, array $context = []): void
+{
+    manageWriteLogEntry(manageAccessLogFile(), [
+        "timestamp" => date("Y-m-d H:i:s.u"),
+        "message" => $message,
+        "context" => $context,
+        "ip" => $_SERVER["REMOTE_ADDR"] ?? "unknown",
+        "request_method" => $_SERVER["REQUEST_METHOD"] ?? "unknown",
+        "request_uri" => $_SERVER["REQUEST_URI"] ?? "unknown",
+    ]);
+}
+
+// Plain-text append-only log for S3 diagnostics. Kept separate from the JSONL
+// logs so the raw provider error stays readable in the management UI.
+function manageLogS3(string $message, array $context = []): void
+{
+    try {
+        manageEnsureDirectory(dirname(manageS3LogFile()));
+    } catch (Throwable $exception) {
+        return;
+    }
+
+    $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(manageS3LogFile(), $line . PHP_EOL, FILE_APPEND | LOCK_EX);
+}
+
+function manageLogS3Tail(int $lines): array
+{
+    $file = manageS3LogFile();
+    if (!is_file($file) || $lines < 1) {
+        return [];
+    }
+
+    $content = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
+    if (!is_array($content)) {
+        return [];
+    }
+
+    return array_slice($content, -$lines);
+}

+ 174 - 0
includes/ratelimit.php

@@ -0,0 +1,174 @@
+<?php
+
+declare(strict_types=1);
+
+// File-based per-IP rate limiting for hosting without Redis. Ported from the
+// PSA order system (includes/functions.php rateLimitEvaluate). State lives
+// under storage/ratelimit/ and is therefore not web-readable.
+
+function manageRateLimitClientIp(): string
+{
+    $ip = trim((string) ($_SERVER["REMOTE_ADDR"] ?? ""));
+
+    return $ip !== "" ? $ip : "unknown";
+}
+
+function manageRateLimitStatePath(string $name): string
+{
+    $name = preg_replace("/[^a-z0-9_-]/", "", strtolower($name)) ?? "";
+    if ($name === "") {
+        $name = "default";
+    }
+
+    return manageStorageDir() . "ratelimit" . DIRECTORY_SEPARATOR . $name . ".json";
+}
+
+/**
+ * @return bool true while under the limit; increments the counter when $consume
+ */
+function manageRateLimitEvaluate(
+    string $name,
+    int $maxAttempts,
+    int $windowSeconds,
+    bool $consume,
+): bool {
+    if ($maxAttempts < 1 || $windowSeconds < 1) {
+        return true;
+    }
+
+    $path = manageRateLimitStatePath($name);
+    $dir = dirname($path);
+    if (!is_dir($dir) && !@mkdir($dir, 02775, true) && !is_dir($dir)) {
+        // Without usable state the limiter must not lock anybody out.
+        return true;
+    }
+    @chmod($dir, 02775);
+
+    $handle = fopen($path, "c+");
+    if ($handle === false) {
+        return true;
+    }
+
+    if (!flock($handle, LOCK_EX)) {
+        fclose($handle);
+        return true;
+    }
+
+    rewind($handle);
+    $raw = stream_get_contents($handle);
+    $data = [];
+    if (is_string($raw) && trim($raw) !== "") {
+        $decoded = json_decode($raw, true);
+        if (is_array($decoded)) {
+            $data = $decoded;
+        }
+    }
+
+    $now = time();
+    $ip = manageRateLimitClientIp();
+
+    foreach ($data as $key => $entry) {
+        if (!is_array($entry)) {
+            unset($data[$key]);
+            continue;
+        }
+        if ($now - (int) ($entry["w"] ?? 0) > $windowSeconds * 2) {
+            unset($data[$key]);
+        }
+    }
+
+    $entry = isset($data[$ip]) && is_array($data[$ip]) ? $data[$ip] : null;
+    $windowStart = (int) ($entry["w"] ?? $now);
+    $count = (int) ($entry["c"] ?? 0);
+
+    if ($entry === null || $now - $windowStart > $windowSeconds) {
+        $windowStart = $now;
+        $count = 0;
+    }
+
+    if ($count >= $maxAttempts) {
+        flock($handle, LOCK_UN);
+        fclose($handle);
+        return false;
+    }
+
+    if ($consume) {
+        $data[$ip] = ["w" => $windowStart, "c" => $count + 1];
+        $payload = json_encode($data, JSON_UNESCAPED_UNICODE);
+        if ($payload !== false) {
+            ftruncate($handle, 0);
+            rewind($handle);
+            fwrite($handle, $payload);
+            fflush($handle);
+            @chmod($path, 0660);
+        }
+    }
+
+    flock($handle, LOCK_UN);
+    fclose($handle);
+
+    return true;
+}
+
+function manageRateLimitTryConsume(string $name, int $maxAttempts, int $windowSeconds): bool
+{
+    return manageRateLimitEvaluate($name, $maxAttempts, $windowSeconds, true);
+}
+
+function manageRateLimitClearIp(string $name): void
+{
+    $path = manageRateLimitStatePath($name);
+    if (!is_file($path)) {
+        return;
+    }
+
+    $handle = fopen($path, "c+");
+    if ($handle === false) {
+        return;
+    }
+    if (!flock($handle, LOCK_EX)) {
+        fclose($handle);
+        return;
+    }
+
+    rewind($handle);
+    $raw = stream_get_contents($handle);
+    $data = is_string($raw) && trim($raw) !== "" ? json_decode($raw, true) : [];
+    if (is_array($data)) {
+        unset($data[manageRateLimitClientIp()]);
+        $payload = json_encode($data, JSON_UNESCAPED_UNICODE);
+        if ($payload !== false) {
+            ftruncate($handle, 0);
+            rewind($handle);
+            fwrite($handle, $payload);
+            fflush($handle);
+        }
+    }
+
+    flock($handle, LOCK_UN);
+    fclose($handle);
+}
+
+function manageLoginIsRateLimited(): bool
+{
+    return !manageRateLimitEvaluate(
+        "admin-login",
+        (int) MANAGE_LOGIN_RATE_LIMIT_MAX,
+        (int) MANAGE_LOGIN_RATE_LIMIT_WINDOW,
+        false,
+    );
+}
+
+function manageLoginRecordFailure(): void
+{
+    manageRateLimitTryConsume(
+        "admin-login",
+        (int) MANAGE_LOGIN_RATE_LIMIT_MAX,
+        (int) MANAGE_LOGIN_RATE_LIMIT_WINDOW,
+    );
+}
+
+function manageLoginClearRateLimit(): void
+{
+    manageRateLimitClearIp("admin-login");
+}

+ 279 - 0
includes/releases.php

@@ -0,0 +1,279 @@
+<?php
+
+declare(strict_types=1);
+
+// Release storage and manifest handling. Ported from the PSA order system
+// (update-server/manage.php and update-server/manifest.php).
+//
+// Two deliberate changes against the original:
+//  - the package filename prefix is configurable (MANAGE_PACKAGE_PREFIX)
+//  - the package download URL is built from MANAGE_PUBLIC_URL instead of the
+//    client-controlled Host header
+
+require_once __DIR__ . "/bootstrap.php";
+
+function manageReleaseReadManifest(): array
+{
+    $file = manageManifestFile();
+    if (!is_file($file)) {
+        return ["latest" => "", "releases" => []];
+    }
+
+    $decoded = manageReadJsonFile($file);
+
+    return [
+        "latest" => trim((string) ($decoded["latest"] ?? "")),
+        "releases" => isset($decoded["releases"]) && is_array($decoded["releases"])
+            ? $decoded["releases"]
+            : [],
+    ];
+}
+
+function manageReleaseWriteManifest(array $manifest): void
+{
+    manageEnsureDirectory(manageReleasesDir());
+    manageWriteJsonFile(manageManifestFile(), [
+        "latest" => (string) ($manifest["latest"] ?? ""),
+        "releases" => isset($manifest["releases"]) && is_array($manifest["releases"])
+            ? $manifest["releases"]
+            : [],
+    ]);
+}
+
+function manageReleasePackageFileName(string $version): string
+{
+    $prefix = trim((string) MANAGE_PACKAGE_PREFIX);
+    if ($prefix === "") {
+        $prefix = "release";
+    }
+
+    return $prefix . "-" . $version . ".zip";
+}
+
+function manageReleasePackagePath(string $packageRelativePath): ?string
+{
+    $packageRelativePath = trim($packageRelativePath);
+    if (
+        $packageRelativePath === "" ||
+        str_contains($packageRelativePath, "\0") ||
+        str_starts_with($packageRelativePath, "/")
+    ) {
+        return null;
+    }
+
+    $packagePath = realpath(manageReleasesDir() . $packageRelativePath);
+    $packagesDir = realpath(managePackagesDir());
+    if (
+        $packagePath === false ||
+        $packagesDir === false ||
+        !str_starts_with($packagePath, rtrim($packagesDir, "/\\") . DIRECTORY_SEPARATOR) ||
+        !is_file($packagePath)
+    ) {
+        return null;
+    }
+
+    return $packagePath;
+}
+
+// Absolute download URL handed to clients. Derived from the configured public
+// URL so a forged Host header cannot redirect a client to another server.
+function manageReleasePackageUrl(string $version): string
+{
+    $base = rtrim(trim((string) MANAGE_PUBLIC_URL), "/");
+    if ($base === "") {
+        throw new RuntimeException(
+            "MANAGE_PUBLIC_URL ist nicht konfiguriert. Ohne diesen Wert kann keine Paket-URL erzeugt werden.",
+        );
+    }
+
+    return $base . "/api/v1/package.php?version=" . rawurlencode($version);
+}
+
+function manageReleaseUploadedFileIsZip(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;
+    }
+
+    return manageFileIsZip($tmpName);
+}
+
+// Stores an uploaded package and publishes it as the new latest release.
+// The checksum and size are always computed server-side; the publisher never
+// supplies them.
+function manageReleasePublishUpload(string $version, array $file): array
+{
+    $version = trim($version);
+    if (!manageIsVersion($version)) {
+        throw new RuntimeException("Version muss dem Format vX.Y.Z entsprechen.");
+    }
+
+    if (($file["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+        throw new RuntimeException(
+            "Upload fehlgeschlagen (Fehlercode " . (string) ($file["error"] ?? "unbekannt") . ").",
+        );
+    }
+
+    if (!manageReleaseUploadedFileIsZip($file)) {
+        throw new RuntimeException("Die hochgeladene Datei muss ein ZIP-Paket sein.");
+    }
+
+    manageEnsureDirectory(managePackagesDir());
+
+    $fileName = manageReleasePackageFileName($version);
+    $targetPath = managePackagesDir() . $fileName;
+    if (!move_uploaded_file((string) $file["tmp_name"], $targetPath)) {
+        throw new RuntimeException("Das Paket konnte nicht gespeichert werden.");
+    }
+
+    @chmod($targetPath, 0664);
+
+    $sha256 = strtolower(hash_file("sha256", $targetPath) ?: "");
+    $size = filesize($targetPath);
+    if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1 || $size === false || $size <= 0) {
+        @unlink($targetPath);
+        throw new RuntimeException("Das gespeicherte Paket konnte nicht verifiziert werden.");
+    }
+
+    $manifest = manageReleaseReadManifest();
+    $manifest["latest"] = $version;
+    $manifest["releases"][$version] = [
+        "version" => $version,
+        "package" => "packages/" . $fileName,
+        "sha256" => $sha256,
+        "size" => $size,
+        "published_at" => date(DATE_ATOM),
+    ];
+
+    uksort($manifest["releases"], static function (string $left, string $right): int {
+        return version_compare(ltrim($left, "vV"), ltrim($right, "vV"));
+    });
+    manageReleaseWriteManifest($manifest);
+
+    manageLogAccess("Release published", [
+        "version" => $version,
+        "size" => $size,
+        "sha256" => $sha256,
+    ]);
+
+    return $manifest["releases"][$version];
+}
+
+function manageReleaseSetLatest(string $version): void
+{
+    $version = trim($version);
+    if (!manageIsVersion($version)) {
+        throw new RuntimeException("Ungültige Release-Version.");
+    }
+
+    $manifest = manageReleaseReadManifest();
+    if (!isset($manifest["releases"][$version])) {
+        throw new RuntimeException("Dieses Release ist nicht im Manifest vorhanden.");
+    }
+
+    $manifest["latest"] = $version;
+    manageReleaseWriteManifest($manifest);
+
+    manageLogAccess("Latest release changed", ["version" => $version]);
+}
+
+function manageReleaseDelete(string $version): void
+{
+    $version = trim($version);
+    if (!manageIsVersion($version)) {
+        throw new RuntimeException("Ungültige Release-Version.");
+    }
+
+    $manifest = manageReleaseReadManifest();
+    if (!isset($manifest["releases"][$version])) {
+        throw new RuntimeException("Dieses Release ist nicht im Manifest vorhanden.");
+    }
+
+    $package = trim((string) ($manifest["releases"][$version]["package"] ?? ""));
+    unset($manifest["releases"][$version]);
+    if ($manifest["latest"] === $version) {
+        $manifest["latest"] = "";
+    }
+
+    manageReleaseWriteManifest($manifest);
+
+    $packagePath = manageReleasePackagePath($package);
+    if ($packagePath !== null) {
+        @unlink($packagePath);
+    }
+
+    manageLogAccess("Release deleted", ["version" => $version]);
+}
+
+/**
+ * Resolves one release into the payload a client receives. Every field is
+ * re-validated here, because the manifest is the only thing standing between a
+ * client and the code it will execute.
+ *
+ * @return array{version: string, package_url: string, sha256: string, size: int, published_at: string}
+ */
+function manageReleaseResolve(string $version): array
+{
+    $manifest = manageReleaseReadManifest();
+    $releases = $manifest["releases"];
+
+    if (!isset($releases[$version]) || !is_array($releases[$version])) {
+        throw new RuntimeException("Release ist nicht konfiguriert.");
+    }
+
+    $release = $releases[$version];
+    $releaseVersion = trim((string) ($release["version"] ?? $version));
+    $sha256 = strtolower(trim((string) ($release["sha256"] ?? "")));
+    $package = trim((string) ($release["package"] ?? ""));
+    $size = isset($release["size"]) ? (int) $release["size"] : 0;
+    $publishedAt = trim((string) ($release["published_at"] ?? ""));
+
+    if ($releaseVersion !== $version || !manageIsVersion($releaseVersion)) {
+        throw new RuntimeException("Release-Version ist ungültig.");
+    }
+    if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) {
+        throw new RuntimeException("Release-Prüfsumme ist ungültig.");
+    }
+
+    $packagePath = manageReleasePackagePath($package);
+    if ($packagePath === null) {
+        throw new RuntimeException("Release-Paket fehlt.");
+    }
+    if (strtolower(pathinfo($packagePath, PATHINFO_EXTENSION)) !== "zip") {
+        throw new RuntimeException("Release-Paket ist keine ZIP-Datei.");
+    }
+
+    if ($size <= 0) {
+        $size = (int) (filesize($packagePath) ?: 0);
+    }
+
+    return [
+        "version" => $releaseVersion,
+        "package_path" => $packagePath,
+        "package_url" => manageReleasePackageUrl($releaseVersion),
+        "sha256" => $sha256,
+        "size" => $size,
+        "published_at" => $publishedAt,
+    ];
+}
+
+function manageReleaseLatestVersion(): string
+{
+    $latest = manageReleaseReadManifest()["latest"];
+
+    return manageIsVersion($latest) ? $latest : "";
+}
+
+// Releases newest first, for the admin UI.
+function manageReleaseListDescending(): array
+{
+    $releases = manageReleaseReadManifest()["releases"];
+    uksort($releases, static function (string $left, string $right): int {
+        return version_compare(ltrim($right, "vV"), ltrim($left, "vV"));
+    });
+
+    return $releases;
+}

+ 346 - 0
includes/s3.php

@@ -0,0 +1,346 @@
+<?php
+
+declare(strict_types=1);
+
+// Dependency-free client for S3-compatible object storage (AWS Signature V4).
+// Ported from the PSA order system (backup-server/s3.php) with the constant
+// prefix changed to MANAGE_S3_*. No SDK and no cURL are required; plain PHP
+// HTTPS streams are enough.
+
+require_once __DIR__ . "/bootstrap.php";
+
+function manageS3Config(): array
+{
+    return [
+        "endpoint" => rtrim(trim((string) MANAGE_S3_ENDPOINT), "/"),
+        "region" => trim((string) MANAGE_S3_REGION),
+        "bucket" => trim((string) MANAGE_S3_BUCKET),
+        "prefix" => trim((string) MANAGE_S3_PREFIX, "/"),
+        "access_key" => trim((string) MANAGE_S3_ACCESS_KEY),
+        "secret_key" => (string) MANAGE_S3_SECRET_KEY,
+        "timeout" => max(1, (int) MANAGE_S3_TIMEOUT),
+        "path_style" => (bool) MANAGE_S3_PATH_STYLE,
+    ];
+}
+
+function manageS3Enabled(): bool
+{
+    if (MANAGE_S3_ENABLED !== true) {
+        return false;
+    }
+
+    $config = manageS3Config();
+
+    return $config["endpoint"] !== "" &&
+        $config["region"] !== "" &&
+        $config["bucket"] !== "" &&
+        $config["access_key"] !== "" &&
+        $config["secret_key"] !== "";
+}
+
+function manageS3ObjectKey(string $instance, string $filename): string
+{
+    $config = manageS3Config();
+    $key = $instance . "/" . $filename;
+
+    return $config["prefix"] !== "" ? $config["prefix"] . "/" . $key : $key;
+}
+
+function manageS3EmptyPayloadHash(): string
+{
+    return hash("sha256", "");
+}
+
+function manageS3HttpStatusFromHeaders(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 manageS3ResponseHeaders($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 manageS3SignRequest(string $method, string $key, string $payloadHash, array $extraHeaders = []): array
+{
+    $config = manageS3Config();
+
+    $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-Endpunkt ist ungültig.");
+    }
+
+    $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 and most)
+        $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 manageS3ErrorDetail(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.
+function manageS3HeaderDiagnostic(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 manageS3PutFile(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-Datei konnte für den S3-Upload nicht gelesen werden.");
+    }
+
+    $request = manageS3SignRequest("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 = manageS3ResponseHeaders($http_response_header ?? null);
+    $status = manageS3HttpStatusFromHeaders($headers);
+
+    if ($response === false || $status < 200 || $status >= 300) {
+        throw new RuntimeException(
+            "S3-Upload fehlgeschlagen" . manageS3ErrorDetail($status, $response) . manageS3HeaderDiagnostic($headers),
+        );
+    }
+}
+
+function manageS3DeleteObject(string $key): void
+{
+    $request = manageS3SignRequest("DELETE", $key, manageS3EmptyPayloadHash());
+
+    $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 = manageS3ResponseHeaders($http_response_header ?? null);
+    $status = manageS3HttpStatusFromHeaders($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-Löschung fehlgeschlagen" . manageS3ErrorDetail($status, $response) . manageS3HeaderDiagnostic($headers),
+        );
+    }
+}
+
+function manageS3SendObjectToOutput(string $key, string $downloadName, int $fallbackSize): void
+{
+    $request = manageS3SignRequest("GET", $key, manageS3EmptyPayloadHash());
+
+    $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 fehlgeschlagen (Verbindungsfehler).");
+    }
+
+    $meta = stream_get_meta_data($handle);
+    $headers = isset($meta["wrapper_data"]) && is_array($meta["wrapper_data"])
+        ? $meta["wrapper_data"]
+        : [];
+    $status = manageS3HttpStatusFromHeaders($headers);
+    if ($status < 200 || $status >= 300) {
+        $body = stream_get_contents($handle, 2048);
+        fclose($handle);
+        throw new RuntimeException(
+            "S3-Download fehlgeschlagen" . manageS3ErrorDetail($status, $body) . manageS3HeaderDiagnostic($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;
+}

+ 107 - 0
includes/storage.php

@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+// Flat-file storage primitives. Ported from the PSA order system
+// (includes/functions.php readJsonFile/writeJsonFile): atomic writes via a
+// temp file plus rename, so a crashed request can never leave a half-written
+// index behind.
+
+function manageEnsureDirectory(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 manageReadJsonFile(string $file): array
+{
+    if (!is_file($file)) {
+        return [];
+    }
+
+    $content = file_get_contents($file);
+    if ($content === false || trim($content) === "") {
+        return [];
+    }
+
+    $decoded = json_decode($content, true);
+    if (!is_array($decoded)) {
+        throw new RuntimeException("JSON-Datei ist beschädigt: " . basename($file));
+    }
+
+    return $decoded;
+}
+
+function manageWriteJsonFile(string $file, array $data): void
+{
+    manageEnsureDirectory(dirname($file));
+
+    if (is_file($file) && !is_writable($file)) {
+        @chmod($file, 0664);
+    }
+
+    $json = json_encode(
+        $data,
+        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+    );
+    if ($json === false) {
+        throw new RuntimeException("JSON konnte nicht kodiert werden.");
+    }
+
+    $tmpFile = $file . ".tmp";
+    if (file_put_contents($tmpFile, $json . PHP_EOL, LOCK_EX) === false) {
+        throw new RuntimeException("JSON konnte nicht geschrieben werden.");
+    }
+
+    @chmod($tmpFile, 0664);
+
+    // rename() is atomic on the same filesystem.
+    if (!rename($tmpFile, $file)) {
+        @unlink($tmpFile);
+        throw new RuntimeException("JSON konnte nicht gespeichert werden.");
+    }
+
+    @chmod($file, 0664);
+}
+
+function manageRemoveDirectory(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);
+}
+
+// Reads the first bytes of a file and reports whether they look like a ZIP
+// local file / empty archive / spanned archive header.
+function manageFileIsZip(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";
+}

+ 6 - 0
index.php

@@ -0,0 +1,6 @@
+<?php
+
+declare(strict_types=1);
+
+header("Location: admin/index.php");
+exit;

+ 114 - 0
scripts/build-client-package.sh

@@ -0,0 +1,114 @@
+#!/usr/bin/env bash
+#
+# Packs client-package/ into a single ZIP for handover.
+#
+# The result contains the client code AND its documentation, so a project can be
+# integrated without access to this repository.
+#
+#     ./scripts/build-client-package.sh
+#     ./scripts/build-client-package.sh --server-url https://manage.example.org
+#
+# With --server-url the placeholder in the shipped config.sample.php is replaced,
+# so the recipient only has to fill in instance and token.
+
+set -euo pipefail
+
+SERVER_URL=""
+VERSION="$(date +%Y%m%d)"
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        --server-url)
+            SERVER_URL="${2:-}"
+            shift 2
+            ;;
+        --version)
+            VERSION="${2:-}"
+            shift 2
+            ;;
+        -h|--help)
+            cat <<USAGE
+Usage: $(basename "$0") [--server-url URL] [--version LABEL]
+
+Builds build/manage-client-<version>.zip from client-package/.
+
+  --server-url URL   Prefill MANAGE_SERVER_URL in the shipped config.sample.php
+  --version LABEL    Label for the file name (default: today's date)
+USAGE
+            exit 0
+            ;;
+        *)
+            echo "Error: unknown option: $1" >&2
+            exit 1
+            ;;
+    esac
+done
+
+if ! command -v zip >/dev/null 2>&1; then
+    echo "Error: zip is required." >&2
+    exit 1
+fi
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SOURCE_DIR="${REPO_ROOT}/client-package"
+BUILD_DIR="${REPO_ROOT}/build"
+
+if [[ ! -d "$SOURCE_DIR" ]]; then
+    echo "Error: client-package/ not found at ${SOURCE_DIR}" >&2
+    exit 1
+fi
+
+STAGE="$(mktemp -d)"
+trap 'rm -rf "$STAGE"' EXIT
+
+cp -r "$SOURCE_DIR" "${STAGE}/manage-client-package"
+
+# A config.php from local testing must never be handed out: it holds a token.
+# Scoped to manage-client/ on purpose - examples/ ships config.php files that are
+# documentation and must survive.
+rm -f "${STAGE}/manage-client-package/manage-client/config.php"
+find "${STAGE}/manage-client-package" -name "*.log" -delete
+find "${STAGE}/manage-client-package" -name ".DS_Store" -delete
+
+if [[ -n "$SERVER_URL" ]]; then
+    SERVER_URL="${SERVER_URL%/}"
+    SAMPLE="${STAGE}/manage-client-package/manage-client/config.sample.php"
+    sed -i.bak -E \
+        "s|(define\\(\"MANAGE_SERVER_URL\", \")[^\"]*(\"\\);)|\\1${SERVER_URL}\\2|" \
+        "$SAMPLE"
+    rm -f "${SAMPLE}.bak"
+    echo "MANAGE_SERVER_URL prefilled: ${SERVER_URL}"
+fi
+
+# Stamp the build so a recipient can tell versions apart.
+cat > "${STAGE}/manage-client-package/BUILD.txt" <<INFO
+Manage client package
+Built: $(date -Iseconds)
+Label: ${VERSION}
+Server: ${SERVER_URL:-not prefilled}
+
+Start with README.md, then docs/01_QUICKSTART.md.
+Browse the docs with: php -S localhost:8080 -t docs
+INFO
+
+mkdir -p "$BUILD_DIR"
+ARCHIVE="${BUILD_DIR}/manage-client-${VERSION}.zip"
+rm -f "$ARCHIVE"
+
+(cd "$STAGE" && zip -qr "$ARCHIVE" "manage-client-package")
+
+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
+
+cat <<SUMMARY
+
+Package:  ${ARCHIVE}
+Size:     $(wc -c < "$ARCHIVE" | tr -d '[:space:]') bytes
+SHA-256:  ${SHA}
+
+Hand this file to whoever integrates the project. It is self-contained:
+code, documentation and examples.
+SUMMARY

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

@@ -0,0 +1,190 @@
+#!/usr/bin/env bash
+#
+# Builds a release package for a managed project.
+#
+# Generalized from the PSA order system (scripts/create-update-zip.sh): the
+# product name, the version file and the exclude list are variables at the top
+# instead of hardcoded paths.
+#
+# Copy this script into 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

Vissa filer visades inte eftersom för många filer har ändrats