Sfoglia il codice sorgente

implementing manage-client

Medowar 1 mese fa
parent
commit
167ee0bab5

+ 3 - 1
.gitignore

@@ -1,3 +1,5 @@
 config.php
 .codex
-data/reservations.php
+data/reservations.php
+data/manage/
+build/

+ 4 - 0
.htaccess

@@ -16,6 +16,10 @@ Options -Indexes
 
     # Deny direct access to writable data directory (centralized, no data/.htaccess needed).
     RewriteRule ^data(?:/|$) - [F,L]
+
+    # Internals of the update/backup client: build tooling and migration scripts
+    # are only ever run by PHP or from a shell, never fetched over HTTP.
+    RewriteRule ^(scripts|migrations)(?:/|$) - [F,L]
 </IfModule>
 
 <IfModule mod_authz_core.c>

+ 0 - 292
admin/admins.php

@@ -1,292 +0,0 @@
-<?php
-require_once __DIR__ . '/../config.php';
-require_once __DIR__ . '/../includes/functions.php';
-
-// Check admin login
-if (!isset($_SESSION['admin_logged_in']) || !$_SESSION['admin_logged_in']) {
-    header('Location: login.php');
-    exit;
-}
-
-$pageTitle = 'Admins verwalten';
-$message = '';
-$messageType = '';
-
-function isValidAdminPasswordInput($password) {
-    return is_string($password) && strlen($password) >= 8;
-}
-
-$adminAccounts = getAdminAccounts();
-
-function isValidAdminEmailInput($email) {
-    return isValidAdminEmail($email);
-}
-
-if ($_SERVER['REQUEST_METHOD'] === 'POST') {
-    if (isset($_POST['add_admin'])) {
-        $username = normalizeAdminUsername($_POST['username'] ?? '');
-        $description = normalizeAdminDescription($_POST['description'] ?? '');
-        $email = normalizeAdminEmail($_POST['email'] ?? '');
-        $password = $_POST['password'] ?? '';
-        $passwordConfirm = $_POST['password_confirm'] ?? '';
-
-        if (!isValidAdminUsername($username)) {
-            $message = 'Ungültiger Benutzername. Erlaubt: 3-50 Zeichen (Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich).';
-            $messageType = 'error';
-        } elseif (isset($adminAccounts[$username])) {
-            $message = 'Dieser Benutzername existiert bereits.';
-            $messageType = 'error';
-        } elseif (!isValidAdminDescription($description)) {
-            $message = 'Beschreibung ist erforderlich (max. 120 Zeichen).';
-            $messageType = 'error';
-        } elseif (!isValidAdminEmailInput($email)) {
-            $message = 'Gültige E-Mail ist erforderlich.';
-            $messageType = 'error';
-        } elseif (!isValidAdminPasswordInput($password)) {
-            $message = 'Passwort muss mindestens 8 Zeichen lang sein.';
-            $messageType = 'error';
-        } elseif ($password !== $passwordConfirm) {
-            $message = 'Passwort und Bestätigung stimmen nicht überein.';
-            $messageType = 'error';
-        } else {
-            $adminAccounts[$username] = [
-                'password_hash' => password_hash($password, PASSWORD_BCRYPT),
-                'description' => $description,
-                'email' => $email
-            ];
-            saveAdminAccounts($adminAccounts);
-            $message = 'Admin wurde erfolgreich angelegt.';
-            $messageType = 'success';
-        }
-    }
-
-    if (isset($_POST['update_description'])) {
-        $targetUsername = normalizeAdminUsername($_POST['target_username'] ?? '');
-        $description = normalizeAdminDescription($_POST['description'] ?? '');
-        $email = normalizeAdminEmail($_POST['email'] ?? '');
-
-        if (!isset($adminAccounts[$targetUsername])) {
-            $message = 'Admin nicht gefunden.';
-            $messageType = 'error';
-        } elseif (!isValidAdminDescription($description)) {
-            $message = 'Beschreibung ist erforderlich (max. 120 Zeichen).';
-            $messageType = 'error';
-        } elseif (!isValidAdminEmailInput($email)) {
-            $message = 'Gültige E-Mail ist erforderlich.';
-            $messageType = 'error';
-        } else {
-            $adminAccounts[$targetUsername]['description'] = $description;
-            $adminAccounts[$targetUsername]['email'] = $email;
-            saveAdminAccounts($adminAccounts);
-            $message = 'Beschreibung und E-Mail wurden aktualisiert.';
-            $messageType = 'success';
-        }
-    }
-
-    if (isset($_POST['change_password'])) {
-        $targetUsername = normalizeAdminUsername($_POST['target_username'] ?? '');
-        $newPassword = $_POST['new_password'] ?? '';
-        $newPasswordConfirm = $_POST['new_password_confirm'] ?? '';
-
-        if (!isset($adminAccounts[$targetUsername])) {
-            $message = 'Admin nicht gefunden.';
-            $messageType = 'error';
-        } elseif (!isValidAdminPasswordInput($newPassword)) {
-            $message = 'Passwort muss mindestens 8 Zeichen lang sein.';
-            $messageType = 'error';
-        } elseif ($newPassword !== $newPasswordConfirm) {
-            $message = 'Passwort und Bestätigung stimmen nicht überein.';
-            $messageType = 'error';
-        } else {
-            $adminAccounts[$targetUsername]['password_hash'] = password_hash($newPassword, PASSWORD_BCRYPT);
-            saveAdminAccounts($adminAccounts);
-            $message = 'Passwort wurde aktualisiert.';
-            $messageType = 'success';
-        }
-    }
-
-    if (isset($_POST['delete_admin'])) {
-        $targetUsername = normalizeAdminUsername($_POST['target_username'] ?? '');
-
-        if (!isset($adminAccounts[$targetUsername])) {
-            $message = 'Admin nicht gefunden.';
-            $messageType = 'error';
-        } else {
-            unset($adminAccounts[$targetUsername]);
-            saveAdminAccounts($adminAccounts);
-
-            if (isset($_SESSION['admin_username']) && $_SESSION['admin_username'] === $targetUsername) {
-                $_SESSION['admin_logged_in'] = false;
-                unset($_SESSION['admin_username']);
-                session_destroy();
-                header('Location: login.php');
-                exit;
-            }
-
-            $message = 'Admin wurde gelöscht.';
-            $messageType = 'success';
-        }
-    }
-
-    $adminAccounts = getAdminAccounts();
-}
-
-$currentAdmin = isset($_SESSION['admin_username']) ? normalizeAdminUsername($_SESSION['admin_username']) : '';
-$changeUsername = normalizeAdminUsername($_GET['change'] ?? '');
-$selectedChangeUser = null;
-$editDescriptionUsername = normalizeAdminUsername($_GET['edit_description'] ?? '');
-$selectedDescriptionUser = null;
-
-if ($changeUsername !== '') {
-    if (!isset($adminAccounts[$changeUsername])) {
-        if ($message === '') {
-            $message = 'Ausgewählter Admin wurde nicht gefunden.';
-            $messageType = 'error';
-        }
-    } else {
-        $selectedChangeUser = $changeUsername;
-    }
-}
-
-if ($editDescriptionUsername !== '') {
-    if (!isset($adminAccounts[$editDescriptionUsername])) {
-        if ($message === '') {
-            $message = 'Ausgewählter Admin wurde nicht gefunden.';
-            $messageType = 'error';
-        }
-    } else {
-        $selectedDescriptionUser = $editDescriptionUsername;
-    }
-}
-
-ksort($adminAccounts);
-
-$bodyClass = 'admin-page';
-include __DIR__ . '/../includes/header.php';
-?>
-
-<div class="admin-header">
-    <h2>Admins verwalten</h2>
-    <div>
-        <a href="index.php" class="btn btn-secondary">Zurück zum Dashboard</a>
-    </div>
-</div>
-
-<?php if ($message !== ''): ?>
-    <div class="alert alert-<?php echo $messageType; ?>">
-        <?php echo htmlspecialchars($message); ?>
-    </div>
-<?php endif; ?>
-
-<div class="panel">
-    <p><strong>Eingeloggt als:</strong> <?php echo htmlspecialchars($currentAdmin !== '' ? $currentAdmin : 'Unbekannt'); ?></p>
-</div>
-
-<div class="panel">
-    <h3>Neuen Admin anlegen</h3>
-    <form method="POST">
-        <div class="form-group">
-            <label for="username">Benutzername *</label>
-            <input type="text" id="username" name="username" required maxlength="50" pattern="[A-Za-z0-9][A-Za-z0-9._-]{2,49}" placeholder="z.B. max.mustermann">
-        </div>
-        <div class="form-group">
-                <label for="description">Beschreibung *</label>
-                <input type="text" id="description" name="description" required maxlength="120" placeholder="z.B. Kassierer, Shop-Team">
-            </div>
-            <div class="form-group">
-                <label for="email">E-Mail *</label>
-                <input type="email" id="email" name="email" required maxlength="190" placeholder="z.B. max.mustermann@example.org">
-            </div>
-            <div class="form-group">
-                <label for="password">Passwort (mind. 8 Zeichen) *</label>
-                <input type="password" id="password" name="password" required minlength="8">
-        </div>
-        <div class="form-group">
-            <label for="password_confirm">Passwort bestätigen *</label>
-            <input type="password" id="password_confirm" name="password_confirm" required minlength="8">
-        </div>
-        <button type="submit" name="add_admin" class="btn">Admin anlegen</button>
-    </form>
-</div>
-
-<div class="panel">
-    <h3>Admin-Liste</h3>
-    <div class="table-responsive">
-        <table class="responsive-table">
-            <thead>
-                <tr>
-                    <th>Benutzername</th>
-                    <th>Beschreibung</th>
-                    <th>E-Mail</th>
-                    <th>Aktionen</th>
-                </tr>
-            </thead>
-            <tbody>
-            <?php foreach ($adminAccounts as $username => $account): ?>
-                <tr>
-                    <td data-label="Benutzername">
-                        <strong><?php echo htmlspecialchars($username); ?></strong>
-                        <?php if ($username === $currentAdmin): ?>
-                            <span class="status status-open" style="margin-left: 0.5rem;">Du</span>
-                        <?php endif; ?>
-                    </td>
-                    <td data-label="Beschreibung">
-                        <?php echo htmlspecialchars($account['description']); ?>
-                    </td>
-                    <td data-label="E-Mail">
-                        <?php echo htmlspecialchars($account['email']); ?>
-                    </td>
-                    <td data-label="Aktionen">
-                        <a href="admins.php?edit_description=<?php echo urlencode($username); ?>" class="btn btn-small btn-secondary">Profil ändern</a>
-                        <a href="admins.php?change=<?php echo urlencode($username); ?>" class="btn btn-small btn-secondary">Passwort ändern</a>
-                        <form method="POST" style="display: inline;" onsubmit="return confirm('Admin wirklich löschen?');">
-                            <input type="hidden" name="target_username" value="<?php echo htmlspecialchars($username); ?>">
-                            <button type="submit" name="delete_admin" class="btn btn-small">Löschen</button>
-                        </form>
-                    </td>
-                </tr>
-            <?php endforeach; ?>
-            </tbody>
-        </table>
-    </div>
-</div>
-
-<?php if ($selectedDescriptionUser !== null): ?>
-    <div class="panel">
-        <h3>Profil ändern: <?php echo htmlspecialchars($selectedDescriptionUser); ?></h3>
-        <form method="POST">
-            <input type="hidden" name="target_username" value="<?php echo htmlspecialchars($selectedDescriptionUser); ?>">
-            <div class="form-group">
-                <label for="description_edit">Beschreibung *</label>
-                <input type="text" id="description_edit" name="description" maxlength="120" required value="<?php echo htmlspecialchars($adminAccounts[$selectedDescriptionUser]['description']); ?>">
-            </div>
-            <div class="form-group">
-                <label for="email_edit">E-Mail *</label>
-                <input type="email" id="email_edit" name="email" maxlength="190" required value="<?php echo htmlspecialchars($adminAccounts[$selectedDescriptionUser]['email']); ?>">
-            </div>
-            <button type="submit" name="update_description" class="btn">Profil speichern</button>
-            <a href="admins.php" class="btn btn-secondary">Abbrechen</a>
-        </form>
-    </div>
-<?php endif; ?>
-
-<?php if ($selectedChangeUser !== null): ?>
-    <div class="panel">
-        <h3>Passwort ändern: <?php echo htmlspecialchars($selectedChangeUser); ?></h3>
-        <form method="POST">
-            <input type="hidden" name="target_username" value="<?php echo htmlspecialchars($selectedChangeUser); ?>">
-            <div class="form-group">
-                <label for="new_password">Neues Passwort (mind. 8 Zeichen) *</label>
-                <input type="password" id="new_password" name="new_password" required minlength="8">
-            </div>
-            <div class="form-group">
-                <label for="new_password_confirm">Neues Passwort bestätigen *</label>
-                <input type="password" id="new_password_confirm" name="new_password_confirm" required minlength="8">
-            </div>
-            <button type="submit" name="change_password" class="btn">Passwort speichern</button>
-            <a href="admins.php" class="btn btn-secondary">Abbrechen</a>
-        </form>
-    </div>
-<?php endif; ?>
-
-<?php include __DIR__ . '/../includes/footer.php'; ?>

+ 32 - 1
admin/index.php

@@ -1,6 +1,7 @@
 <?php
 require_once __DIR__ . '/../config.php';
 require_once __DIR__ . '/../includes/functions.php';
+require_once __DIR__ . '/../includes/manage.php';
 
 // Check admin login
 if (!isset($_SESSION['admin_logged_in']) || !$_SESSION['admin_logged_in']) {
@@ -18,6 +19,30 @@ $reservations = getReservations();
 expireOldReservations();
 $reservations = getReservations(); // Refresh after expiry
 
+// This host has no cron, so the weekly backup is triggered here - the dashboard
+// is the admin page loaded most reliably, and manageBackupCreateAutomaticIfDue()
+// returns immediately unless MANAGE_BACKUP_AUTO_INTERVAL_SECONDS has elapsed.
+// A failing backup must never take the dashboard down with it.
+$backupNotice = null;
+try {
+    $automaticBackup = manageBackupCreateAutomaticIfDue();
+    if ($automaticBackup !== null) {
+        $backupNotice = [
+            'type' => 'success',
+            'text' => 'Automatisches Backup erstellt: ' . $automaticBackup['filename'] .
+                ' (' . manageFormatBytes((int) $automaticBackup['size']) . ').'
+        ];
+    }
+} catch (Throwable $exception) {
+    manageClientLog('ERROR', 'Automatic backup from dashboard failed', [
+        'error' => $exception->getMessage(),
+    ]);
+    $backupNotice = [
+        'type' => 'warning',
+        'text' => 'Das automatische Backup ist fehlgeschlagen: ' . $exception->getMessage()
+    ];
+}
+
 $regularReservations = array_filter($reservations, function($r) {
     return (!isset($r['type']) || $r['type'] !== 'backorder') && !isReservationHidden($r);
 });
@@ -48,6 +73,12 @@ $bodyClass = 'admin-page';
 include __DIR__ . '/../includes/header.php';
 ?>
 
+<?php if ($backupNotice !== null): ?>
+    <div class="alert alert-<?php echo htmlspecialchars($backupNotice['type']); ?>">
+        <?php echo htmlspecialchars($backupNotice['text']); ?>
+    </div>
+<?php endif; ?>
+
 <div class="admin-header">
     <h2>Admin Dashboard</h2>
     <div class="admin-dashboard-actions">
@@ -59,7 +90,7 @@ include __DIR__ . '/../includes/header.php';
                 <a href="products.php">Produkte verwalten</a>
                 <a href="categories.php">Kategorien verwalten</a>
                 <a href="faq.php">FAQ bearbeiten</a>
-                <a href="admins.php">Admins verwalten</a>
+                <a href="settings.php">Einstellungen</a>
                 <a href="login.php?logout=1">Abmelden</a>
             </div>
         </details>

+ 577 - 0
admin/settings.php

@@ -0,0 +1,577 @@
+<?php
+require_once __DIR__ . '/../config.php';
+require_once __DIR__ . '/../includes/functions.php';
+require_once __DIR__ . '/../includes/manage.php';
+
+// Check admin login
+if (!isset($_SESSION['admin_logged_in']) || !$_SESSION['admin_logged_in']) {
+    header('Location: login.php');
+    exit;
+}
+
+$pageTitle = 'Einstellungen';
+
+// This page rolls out updates and hands out backup archives, so every form on
+// it carries a CSRF token - including the admin forms, which are otherwise
+// identical to what admins.php did before.
+function settingsCsrfToken() {
+    if (empty($_SESSION['settings_csrf_token'])) {
+        $_SESSION['settings_csrf_token'] = bin2hex(random_bytes(32));
+    }
+
+    return $_SESSION['settings_csrf_token'];
+}
+
+function settingsCsrfValid($token) {
+    return !empty($_SESSION['settings_csrf_token']) &&
+        is_string($token) &&
+        hash_equals($_SESSION['settings_csrf_token'], $token);
+}
+
+function isValidAdminPasswordInput($password) {
+    return is_string($password) && strlen($password) >= 8;
+}
+
+// Every message on this page, rendered as .alert blocks in source order.
+// A single action can produce several: an update reports deployment, migrations
+// and hook failure separately.
+$notices = [];
+
+$adminAccounts = getAdminAccounts();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    if (!settingsCsrfValid($_POST['csrf_token'] ?? '')) {
+        $notices[] = ['type' => 'error', 'text' => 'Ungültiges Sicherheitstoken. Bitte die Seite neu laden.'];
+    } elseif (isset($_POST['create_backup'])) {
+        try {
+            $record = manageBackupCreate('manual');
+            $notices[] = ['type' => 'success', 'text' => sprintf(
+                'Backup erstellt: %s (%d Dateien, %s).',
+                $record['filename'],
+                $record['file_count'],
+                manageFormatBytes((int) $record['size'])
+            )];
+
+            // A failed upload is a warning, never an error: the local archive
+            // is complete and valid either way.
+            foreach ($record['remote_uploads'] as $upload) {
+                if (empty($upload['success'])) {
+                    $notices[] = ['type' => 'warning', 'text' => 'Upload an ' . $upload['target'] .
+                        ' fehlgeschlagen: ' . ($upload['error'] ?? 'unbekannter Fehler')];
+                }
+            }
+            manageHeartbeatSendQuietly();
+        } catch (Throwable $exception) {
+            $notices[] = ['type' => 'error', 'text' => 'Backup fehlgeschlagen: ' . $exception->getMessage()];
+        }
+    } elseif (isset($_POST['download_backup'])) {
+        try {
+            $path = manageBackupPath($_POST['filename'] ?? '');
+            $handle = fopen($path, 'rb');
+            $size = filesize($path);
+            if ($handle === false || $size === false) {
+                throw new RuntimeException('Das Backup konnte nicht geöffnet werden.');
+            }
+
+            header('Content-Type: application/zip');
+            header('Content-Disposition: attachment; filename="' . addcslashes(basename($path), '"\\') . '"');
+            header('Content-Length: ' . $size);
+            header('Cache-Control: private, no-store');
+            header('X-Content-Type-Options: nosniff');
+            fpassthru($handle);
+            fclose($handle);
+            exit;
+        } catch (Throwable $exception) {
+            $notices[] = ['type' => 'error', 'text' => 'Download fehlgeschlagen: ' . $exception->getMessage()];
+        }
+    } elseif (isset($_POST['apply_update'])) {
+        try {
+            $result = manageUpdateApply(['force' => !empty($_POST['force'])]);
+            $notices[] = ['type' => 'success', 'text' => sprintf(
+                'Update ausgerollt: %s → %s. %d Dateien kopiert, %d gesichert, %d übersprungen.',
+                $result['from_version'] !== '' ? $result['from_version'] : 'unbekannt',
+                $result['to_version'],
+                $result['copied'],
+                $result['backed_up'],
+                $result['skipped']
+            )];
+            $notices[] = ['type' => 'info', 'text' => 'Die überschriebenen Dateien liegen unter ' .
+                $result['backup_dir'] . ' - nur für eine manuelle Wiederherstellung, es gibt kein Rollback.'];
+
+            // The files are live at this point. A failing post-update step is
+            // therefore reported on its own, not as "update failed".
+            $hook = $result['hook'];
+            if (is_array($hook)) {
+                $applied = $hook['migrations']['applied'] ?? [];
+                if ($applied !== []) {
+                    $notices[] = ['type' => 'success', 'text' => 'Migrationen ausgeführt: ' . implode(', ', $applied)];
+                }
+                if (empty($hook['success'])) {
+                    if (!empty($hook['failed_migration'])) {
+                        $notices[] = ['type' => 'error', 'text' => 'Die Dateien wurden ausgerollt, aber die Migration "' .
+                            $hook['failed_migration'] . '" ist fehlgeschlagen: ' . ($hook['error'] ?? '')];
+                        $notices[] = ['type' => 'error', 'text' => 'Die restlichen Migrationen wurden nicht ausgeführt. ' .
+                            'Nach Behebung der Ursache unten "Migrationen ausführen" verwenden.'];
+                    } else {
+                        $notices[] = ['type' => 'error', 'text' => 'Die Dateien wurden ausgerollt, aber der ' .
+                            'Post-Update-Hook ist fehlgeschlagen: ' . ($hook['error'] ?? '')];
+                    }
+                }
+            }
+            manageHeartbeatSendQuietly();
+        } catch (Throwable $exception) {
+            $notices[] = ['type' => 'error', 'text' => 'Update fehlgeschlagen: ' . $exception->getMessage()];
+        }
+    } elseif (isset($_POST['run_migrations'])) {
+        $report = manageUpdateRunMigrations();
+        if ($report['applied'] !== []) {
+            $notices[] = ['type' => 'success', 'text' => 'Migrationen ausgeführt: ' . implode(', ', $report['applied'])];
+        }
+        if (!$report['success']) {
+            $notices[] = ['type' => 'error', 'text' => 'Migration "' . $report['failed'] . '" ist fehlgeschlagen: ' .
+                $report['error']];
+        } elseif ($report['applied'] === []) {
+            $notices[] = ['type' => 'info', 'text' => 'Es gibt keine offenen Migrationen.'];
+        }
+    } elseif (isset($_POST['send_heartbeat'])) {
+        try {
+            $result = manageHeartbeatSend();
+            $notices[] = ['type' => 'success', 'text' => 'Status an den Manage-Server gemeldet. Aktuelles Release: ' .
+                ($result['latest'] !== '' ? $result['latest'] : 'keines') . '.'];
+        } catch (Throwable $exception) {
+            $notices[] = ['type' => 'error', 'text' => 'Statusmeldung fehlgeschlagen: ' . $exception->getMessage()];
+        }
+    } elseif (isset($_POST['add_admin'])) {
+        $username = normalizeAdminUsername($_POST['username'] ?? '');
+        $description = normalizeAdminDescription($_POST['description'] ?? '');
+        $email = normalizeAdminEmail($_POST['email'] ?? '');
+        $password = $_POST['password'] ?? '';
+        $passwordConfirm = $_POST['password_confirm'] ?? '';
+
+        if (!isValidAdminUsername($username)) {
+            $notices[] = ['type' => 'error', 'text' => 'Ungültiger Benutzername. Erlaubt: 3-50 Zeichen (Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich).'];
+        } elseif (isset($adminAccounts[$username])) {
+            $notices[] = ['type' => 'error', 'text' => 'Dieser Benutzername existiert bereits.'];
+        } elseif (!isValidAdminDescription($description)) {
+            $notices[] = ['type' => 'error', 'text' => 'Beschreibung ist erforderlich (max. 120 Zeichen).'];
+        } elseif (!isValidAdminEmail($email)) {
+            $notices[] = ['type' => 'error', 'text' => 'Gültige E-Mail ist erforderlich.'];
+        } elseif (!isValidAdminPasswordInput($password)) {
+            $notices[] = ['type' => 'error', 'text' => 'Passwort muss mindestens 8 Zeichen lang sein.'];
+        } elseif ($password !== $passwordConfirm) {
+            $notices[] = ['type' => 'error', 'text' => 'Passwort und Bestätigung stimmen nicht überein.'];
+        } else {
+            $adminAccounts[$username] = [
+                'password_hash' => password_hash($password, PASSWORD_BCRYPT),
+                'description' => $description,
+                'email' => $email
+            ];
+            saveAdminAccounts($adminAccounts);
+            $notices[] = ['type' => 'success', 'text' => 'Admin wurde erfolgreich angelegt.'];
+        }
+    } elseif (isset($_POST['update_description'])) {
+        $targetUsername = normalizeAdminUsername($_POST['target_username'] ?? '');
+        $description = normalizeAdminDescription($_POST['description'] ?? '');
+        $email = normalizeAdminEmail($_POST['email'] ?? '');
+
+        if (!isset($adminAccounts[$targetUsername])) {
+            $notices[] = ['type' => 'error', 'text' => 'Admin nicht gefunden.'];
+        } elseif (!isValidAdminDescription($description)) {
+            $notices[] = ['type' => 'error', 'text' => 'Beschreibung ist erforderlich (max. 120 Zeichen).'];
+        } elseif (!isValidAdminEmail($email)) {
+            $notices[] = ['type' => 'error', 'text' => 'Gültige E-Mail ist erforderlich.'];
+        } else {
+            $adminAccounts[$targetUsername]['description'] = $description;
+            $adminAccounts[$targetUsername]['email'] = $email;
+            saveAdminAccounts($adminAccounts);
+            $notices[] = ['type' => 'success', 'text' => 'Beschreibung und E-Mail wurden aktualisiert.'];
+        }
+    } elseif (isset($_POST['change_password'])) {
+        $targetUsername = normalizeAdminUsername($_POST['target_username'] ?? '');
+        $newPassword = $_POST['new_password'] ?? '';
+        $newPasswordConfirm = $_POST['new_password_confirm'] ?? '';
+
+        if (!isset($adminAccounts[$targetUsername])) {
+            $notices[] = ['type' => 'error', 'text' => 'Admin nicht gefunden.'];
+        } elseif (!isValidAdminPasswordInput($newPassword)) {
+            $notices[] = ['type' => 'error', 'text' => 'Passwort muss mindestens 8 Zeichen lang sein.'];
+        } elseif ($newPassword !== $newPasswordConfirm) {
+            $notices[] = ['type' => 'error', 'text' => 'Passwort und Bestätigung stimmen nicht überein.'];
+        } else {
+            $adminAccounts[$targetUsername]['password_hash'] = password_hash($newPassword, PASSWORD_BCRYPT);
+            saveAdminAccounts($adminAccounts);
+            $notices[] = ['type' => 'success', 'text' => 'Passwort wurde aktualisiert.'];
+        }
+    } elseif (isset($_POST['delete_admin'])) {
+        $targetUsername = normalizeAdminUsername($_POST['target_username'] ?? '');
+
+        if (!isset($adminAccounts[$targetUsername])) {
+            $notices[] = ['type' => 'error', 'text' => 'Admin nicht gefunden.'];
+        } else {
+            unset($adminAccounts[$targetUsername]);
+            saveAdminAccounts($adminAccounts);
+
+            if (isset($_SESSION['admin_username']) && $_SESSION['admin_username'] === $targetUsername) {
+                $_SESSION['admin_logged_in'] = false;
+                unset($_SESSION['admin_username']);
+                session_destroy();
+                header('Location: login.php');
+                exit;
+            }
+
+            $notices[] = ['type' => 'success', 'text' => 'Admin wurde gelöscht.'];
+        }
+    }
+
+    $adminAccounts = getAdminAccounts();
+}
+
+// Collected after the actions, so the page shows the state they produced.
+// Never throws: remote failures come back inside the array.
+$status = manageClientStatus();
+
+// No cron on this host, so the report to the Manage server rides along with
+// this page load - at most once an hour.
+manageHeartbeatSendIfDue();
+
+$updateAvailable = $status['update'] !== null && !empty($status['update']['available']);
+$latestVersion = $status['update']['latest'] ?? '';
+
+$currentAdmin = isset($_SESSION['admin_username']) ? normalizeAdminUsername($_SESSION['admin_username']) : '';
+$changeUsername = normalizeAdminUsername($_GET['change'] ?? '');
+$selectedChangeUser = null;
+$editDescriptionUsername = normalizeAdminUsername($_GET['edit_description'] ?? '');
+$selectedDescriptionUser = null;
+
+if ($changeUsername !== '') {
+    if (!isset($adminAccounts[$changeUsername])) {
+        $notices[] = ['type' => 'error', 'text' => 'Ausgewählter Admin wurde nicht gefunden.'];
+    } else {
+        $selectedChangeUser = $changeUsername;
+    }
+}
+
+if ($editDescriptionUsername !== '') {
+    if (!isset($adminAccounts[$editDescriptionUsername])) {
+        $notices[] = ['type' => 'error', 'text' => 'Ausgewählter Admin wurde nicht gefunden.'];
+    } else {
+        $selectedDescriptionUser = $editDescriptionUsername;
+    }
+}
+
+ksort($adminAccounts);
+
+$csrfToken = settingsCsrfToken();
+
+$bodyClass = 'admin-page';
+include __DIR__ . '/../includes/header.php';
+?>
+
+<div class="admin-header">
+    <h2>Einstellungen</h2>
+    <div>
+        <a href="index.php" class="btn btn-secondary">Zurück zum Dashboard</a>
+    </div>
+</div>
+
+<?php foreach ($notices as $notice): ?>
+    <div class="alert alert-<?php echo htmlspecialchars($notice['type']); ?>">
+        <?php echo htmlspecialchars($notice['text']); ?>
+    </div>
+<?php endforeach; ?>
+
+<?php if (!$status['configured']): ?>
+    <div class="alert alert-warning">
+        Der Manage-Client ist nicht konfiguriert. Ohne <code>MANAGE_SERVER_URL</code>, <code>MANAGE_INSTANCE</code>
+        und <code>MANAGE_TOKEN</code> in <code>config.php</code> funktionieren Update-Prüfung und Backup-Upload nicht.
+        Lokale Backups lassen sich trotzdem erstellen.
+    </div>
+<?php endif; ?>
+
+<div class="admin-stats">
+    <div class="stat-card">
+        <h3>Installierte Version</h3>
+        <div class="stat-value"><?php echo htmlspecialchars($status['version'] !== '' ? $status['version'] : 'unbekannt'); ?></div>
+    </div>
+
+    <div class="stat-card">
+        <h3>Aktuelles Release</h3>
+        <div class="stat-value"><?php echo htmlspecialchars($latestVersion !== '' ? $latestVersion : '–'); ?></div>
+    </div>
+
+    <div class="stat-card">
+        <h3>Lokale Backups</h3>
+        <div class="stat-value"><?php echo count($status['backups']); ?></div>
+    </div>
+
+    <div class="stat-card">
+        <h3>Letztes Backup</h3>
+        <div class="stat-value" style="font-size: 1.2rem;">
+            <?php echo $status['last_backup_at'] !== null ? htmlspecialchars(formatDate($status['last_backup_at'])) : 'nie'; ?>
+        </div>
+    </div>
+</div>
+
+<div class="panel">
+    <h3>System</h3>
+
+    <?php if ($status['update_error'] !== null): ?>
+        <div class="alert alert-warning">
+            Die Update-Prüfung ist fehlgeschlagen: <?php echo htmlspecialchars($status['update_error']); ?>
+        </div>
+    <?php elseif ($updateAvailable): ?>
+        <div class="alert alert-warning">
+            Version <?php echo htmlspecialchars($latestVersion); ?> steht bereit. Vor dem Ausrollen sollte ein
+            aktuelles Backup vorliegen – ein Update lässt sich nicht zurücknehmen.
+        </div>
+    <?php elseif ($status['configured']): ?>
+        <div class="alert alert-success">Der Shop ist auf dem aktuellen Stand.</div>
+    <?php endif; ?>
+
+    <div class="table-responsive">
+        <table class="responsive-table">
+            <tbody>
+                <tr>
+                    <td data-label="Instanz"><strong>Instanz</strong></td>
+                    <td><?php echo htmlspecialchars($status['instance'] !== '' ? $status['instance'] : '–'); ?></td>
+                </tr>
+                <tr>
+                    <td data-label="Manage-Server"><strong>Manage-Server</strong></td>
+                    <td><?php echo htmlspecialchars($status['server_url'] !== '' ? $status['server_url'] : '–'); ?></td>
+                </tr>
+                <tr>
+                    <td data-label="PHP-Version"><strong>PHP-Version</strong></td>
+                    <td><?php echo htmlspecialchars($status['php_version']); ?></td>
+                </tr>
+                <tr>
+                    <td data-label="Offene Migrationen"><strong>Offene Migrationen</strong></td>
+                    <td><?php echo count($status['pending_migrations']); ?></td>
+                </tr>
+            </tbody>
+        </table>
+    </div>
+
+    <form method="POST" style="margin-top: 1rem;" onsubmit="return confirm('Update jetzt ausrollen? Dateien werden überschrieben und es gibt kein Rollback.');">
+        <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>">
+        <div class="form-group">
+            <label>
+                <input type="checkbox" name="force" value="1">
+                Erneut ausrollen, auch wenn keine neuere Version vorliegt
+            </label>
+        </div>
+        <button type="submit" name="apply_update" class="btn">Update ausrollen</button>
+        <button type="submit" name="send_heartbeat" class="btn btn-secondary">Status melden</button>
+    </form>
+</div>
+
+<div class="panel">
+    <h3>Backup</h3>
+    <p>
+        Gesichert werden die Daten unter <code>data/</code> und die Produktbilder unter
+        <code>assets/images/</code>. Lokal bleiben die letzten
+        <?php echo (int) MANAGE_BACKUP_LOCAL_RETENTION; ?> Archive erhalten, jedes wird zusätzlich an den
+        Manage-Server übertragen.
+        <?php if ((int) MANAGE_BACKUP_AUTO_INTERVAL_SECONDS > 0): ?>
+            Zusätzlich erstellt das Dashboard automatisch alle
+            <?php echo (int) round(MANAGE_BACKUP_AUTO_INTERVAL_SECONDS / 86400); ?> Tage ein Backup.
+        <?php endif; ?>
+    </p>
+
+    <form method="POST">
+        <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>">
+        <button type="submit" name="create_backup" class="btn">Backup jetzt erstellen</button>
+    </form>
+
+    <?php if ($status['backups'] === []): ?>
+        <p style="margin-top: 1rem;">Es wurde noch kein Backup erstellt.</p>
+    <?php else: ?>
+        <div class="table-responsive" style="margin-top: 1rem;">
+            <table class="responsive-table">
+                <thead>
+                    <tr>
+                        <th>Datei</th>
+                        <th>Erstellt</th>
+                        <th>Auslöser</th>
+                        <th>Dateien</th>
+                        <th>Größe</th>
+                        <th>Upload</th>
+                        <th>Aktionen</th>
+                    </tr>
+                </thead>
+                <tbody>
+                <?php foreach ($status['backups'] as $backup): ?>
+                    <tr>
+                        <td data-label="Datei"><strong><?php echo htmlspecialchars($backup['filename']); ?></strong></td>
+                        <td data-label="Erstellt"><?php echo htmlspecialchars(formatDate($backup['created_at'])); ?></td>
+                        <td data-label="Auslöser"><?php echo htmlspecialchars($backup['trigger'] ?? ''); ?></td>
+                        <td data-label="Dateien"><?php echo (int) ($backup['file_count'] ?? 0); ?></td>
+                        <td data-label="Größe"><?php echo htmlspecialchars(manageFormatBytes((int) ($backup['size'] ?? 0))); ?></td>
+                        <td data-label="Upload">
+                            <?php
+                            $uploads = is_array($backup['remote_uploads'] ?? null) ? $backup['remote_uploads'] : [];
+                            if ($uploads === []) {
+                                echo '<span class="status status-expired">nicht übertragen</span>';
+                            } else {
+                                foreach ($uploads as $upload) {
+                                    if (!empty($upload['success'])) {
+                                        echo '<span class="status status-picked">' . htmlspecialchars($upload['target']) . ': OK</span>';
+                                    } else {
+                                        echo '<span class="status status-expired" title="' .
+                                            htmlspecialchars($upload['error'] ?? '') . '">' .
+                                            htmlspecialchars($upload['target']) . ': Fehler</span>';
+                                    }
+                                }
+                            }
+                            ?>
+                        </td>
+                        <td data-label="Aktionen">
+                            <form method="POST" style="display: inline;">
+                                <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>">
+                                <input type="hidden" name="filename" value="<?php echo htmlspecialchars($backup['filename']); ?>">
+                                <button type="submit" name="download_backup" class="btn btn-small btn-secondary">Herunterladen</button>
+                            </form>
+                        </td>
+                    </tr>
+                <?php endforeach; ?>
+                </tbody>
+            </table>
+        </div>
+    <?php endif; ?>
+</div>
+
+<?php if ($status['pending_migrations'] !== []): ?>
+    <div class="panel">
+        <h3>Offene Migrationen</h3>
+        <p>Diese Migrationen wurden noch nicht ausgeführt:</p>
+        <ul>
+            <?php foreach ($status['pending_migrations'] as $migration): ?>
+                <li><code><?php echo htmlspecialchars($migration['id']); ?></code></li>
+            <?php endforeach; ?>
+        </ul>
+        <form method="POST">
+            <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>">
+            <button type="submit" name="run_migrations" class="btn">Migrationen ausführen</button>
+        </form>
+    </div>
+<?php endif; ?>
+
+<?php foreach ($status['errors'] as $error): ?>
+    <div class="alert alert-warning"><?php echo htmlspecialchars($error); ?></div>
+<?php endforeach; ?>
+
+<div class="panel">
+    <p><strong>Eingeloggt als:</strong> <?php echo htmlspecialchars($currentAdmin !== '' ? $currentAdmin : 'Unbekannt'); ?></p>
+</div>
+
+<div class="panel">
+    <h3>Neuen Admin anlegen</h3>
+    <form method="POST">
+        <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>">
+        <div class="form-group">
+            <label for="username">Benutzername *</label>
+            <input type="text" id="username" name="username" required maxlength="50" pattern="[A-Za-z0-9][A-Za-z0-9._-]{2,49}" placeholder="z.B. max.mustermann">
+        </div>
+        <div class="form-group">
+            <label for="description">Beschreibung *</label>
+            <input type="text" id="description" name="description" required maxlength="120" placeholder="z.B. Kassierer, Shop-Team">
+        </div>
+        <div class="form-group">
+            <label for="email">E-Mail *</label>
+            <input type="email" id="email" name="email" required maxlength="190" placeholder="z.B. max.mustermann@example.org">
+        </div>
+        <div class="form-group">
+            <label for="password">Passwort (mind. 8 Zeichen) *</label>
+            <input type="password" id="password" name="password" required minlength="8">
+        </div>
+        <div class="form-group">
+            <label for="password_confirm">Passwort bestätigen *</label>
+            <input type="password" id="password_confirm" name="password_confirm" required minlength="8">
+        </div>
+        <button type="submit" name="add_admin" class="btn">Admin anlegen</button>
+    </form>
+</div>
+
+<div class="panel">
+    <h3>Admin-Liste</h3>
+    <div class="table-responsive">
+        <table class="responsive-table">
+            <thead>
+                <tr>
+                    <th>Benutzername</th>
+                    <th>Beschreibung</th>
+                    <th>E-Mail</th>
+                    <th>Aktionen</th>
+                </tr>
+            </thead>
+            <tbody>
+            <?php foreach ($adminAccounts as $username => $account): ?>
+                <tr>
+                    <td data-label="Benutzername">
+                        <strong><?php echo htmlspecialchars($username); ?></strong>
+                        <?php if ($username === $currentAdmin): ?>
+                            <span class="status status-open" style="margin-left: 0.5rem;">Du</span>
+                        <?php endif; ?>
+                    </td>
+                    <td data-label="Beschreibung">
+                        <?php echo htmlspecialchars($account['description']); ?>
+                    </td>
+                    <td data-label="E-Mail">
+                        <?php echo htmlspecialchars($account['email']); ?>
+                    </td>
+                    <td data-label="Aktionen">
+                        <a href="settings.php?edit_description=<?php echo urlencode($username); ?>" class="btn btn-small btn-secondary">Profil ändern</a>
+                        <a href="settings.php?change=<?php echo urlencode($username); ?>" class="btn btn-small btn-secondary">Passwort ändern</a>
+                        <form method="POST" style="display: inline;" onsubmit="return confirm('Admin wirklich löschen?');">
+                            <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>">
+                            <input type="hidden" name="target_username" value="<?php echo htmlspecialchars($username); ?>">
+                            <button type="submit" name="delete_admin" class="btn btn-small">Löschen</button>
+                        </form>
+                    </td>
+                </tr>
+            <?php endforeach; ?>
+            </tbody>
+        </table>
+    </div>
+</div>
+
+<?php if ($selectedDescriptionUser !== null): ?>
+    <div class="panel">
+        <h3>Profil ändern: <?php echo htmlspecialchars($selectedDescriptionUser); ?></h3>
+        <form method="POST">
+            <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>">
+            <input type="hidden" name="target_username" value="<?php echo htmlspecialchars($selectedDescriptionUser); ?>">
+            <div class="form-group">
+                <label for="description_edit">Beschreibung *</label>
+                <input type="text" id="description_edit" name="description" maxlength="120" required value="<?php echo htmlspecialchars($adminAccounts[$selectedDescriptionUser]['description']); ?>">
+            </div>
+            <div class="form-group">
+                <label for="email_edit">E-Mail *</label>
+                <input type="email" id="email_edit" name="email" maxlength="190" required value="<?php echo htmlspecialchars($adminAccounts[$selectedDescriptionUser]['email']); ?>">
+            </div>
+            <button type="submit" name="update_description" class="btn">Profil speichern</button>
+            <a href="settings.php" class="btn btn-secondary">Abbrechen</a>
+        </form>
+    </div>
+<?php endif; ?>
+
+<?php if ($selectedChangeUser !== null): ?>
+    <div class="panel">
+        <h3>Passwort ändern: <?php echo htmlspecialchars($selectedChangeUser); ?></h3>
+        <form method="POST">
+            <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>">
+            <input type="hidden" name="target_username" value="<?php echo htmlspecialchars($selectedChangeUser); ?>">
+            <div class="form-group">
+                <label for="new_password">Neues Passwort (mind. 8 Zeichen) *</label>
+                <input type="password" id="new_password" name="new_password" required minlength="8">
+            </div>
+            <div class="form-group">
+                <label for="new_password_confirm">Neues Passwort bestätigen *</label>
+                <input type="password" id="new_password_confirm" name="new_password_confirm" required minlength="8">
+            </div>
+            <button type="submit" name="change_password" class="btn">Passwort speichern</button>
+            <a href="settings.php" class="btn btn-secondary">Abbrechen</a>
+        </form>
+    </div>
+<?php endif; ?>
+
+<?php include __DIR__ . '/../includes/footer.php'; ?>

+ 72 - 0
config.sample.php

@@ -60,6 +60,78 @@ define('ADMINS_FILE', DATA_DIR . 'admins.json');
 define('CATEGORIES_FILE', DATA_DIR . 'categories.json');
 define('FAQ_FILE', DATA_DIR . 'faq.json');
 
+// Backup and update client (Manage server)
+// -----------------------------------------------------------------------------
+// Connection. Instance and token come from the Manage server when the instance
+// is created there; the token is displayed exactly once. Leaving any of the
+// three empty disables backup upload, update check and heartbeat - the settings
+// page then says so instead of failing.
+define('MANAGE_SERVER_URL', 'https://manage.example.org'); // no trailing slash, no /api
+define('MANAGE_INSTANCE', '');
+define('MANAGE_TOKEN', '');
+
+// Seconds per HTTP request. Package download and backup upload use the long one.
+define('MANAGE_HTTP_TIMEOUT', 15);
+define('MANAGE_HTTP_TIMEOUT_LONG', 300);
+
+// Where the installed version lives. Never written by the client - it changes
+// when a release is rolled out over the installation.
+define('MANAGE_APP_ROOT', __DIR__);
+define('MANAGE_VERSION_FILE', __DIR__ . '/includes/version.php');
+define('MANAGE_VERSION_CONSTANT', 'APP_VERSION');
+
+// Working directories. Must be writable by PHP and must not be web-readable;
+// the root .htaccess denies all of data/.
+define('MANAGE_DIR', DATA_DIR . 'manage/');
+define('MANAGE_BACKUP_DIR', MANAGE_DIR . 'backups/');   // local archives
+define('MANAGE_WORK_DIR', MANAGE_DIR . 'work/');        // update staging, cleared after each run
+define('MANAGE_UPDATE_BACKUP_DIR', MANAGE_DIR . 'updates/'); // files an update overwrote
+define('MANAGE_LOG_FILE', MANAGE_DIR . 'manage-client.log');
+define('MANAGE_HEARTBEAT_STATE', MANAGE_DIR . 'heartbeat.json');
+define('MANAGE_MIGRATIONS_STATE', MANAGE_DIR . 'migrations.json');
+define('MANAGE_MIGRATIONS_DIR', __DIR__ . '/migrations');
+
+// What goes into a backup archive, relative to MANAGE_APP_ROOT.
+//   'glob' => shell glob, non-recursive   'dir' => recursive   'file' => single file
+//   'as'   => path prefix inside the ZIP
+// assets/images holds product images uploaded through the admin UI; they exist
+// nowhere else, so a data-only backup would not survive a restore.
+// config.php is deliberately absent: backups are downloadable by anyone with a
+// Manage server login, and this file holds secrets.
+define('MANAGE_BACKUP_SOURCES', [
+    ['as' => 'data', 'glob' => 'data/*.json'],
+    ['as' => 'assets/images', 'dir' => 'assets/images'],
+]);
+
+// Local archives kept on the server (minimum 1). Retention on the Manage server
+// is configured there and is usually much higher.
+define('MANAGE_BACKUP_LOCAL_RETENTION', 4);
+
+// This host has no cron, so an automatic backup is triggered by the admin
+// dashboard once this many seconds have passed since the last one. 0 disables
+// it and leaves only the button on the settings page.
+define('MANAGE_BACKUP_AUTO_INTERVAL_SECONDS', 604800); // 7 days
+
+// Compress archive entries (needs zlib) and upload every new backup.
+define('MANAGE_BACKUP_COMPRESS', true);
+define('MANAGE_BACKUP_UPLOAD', true);
+
+// Paths an update must never overwrite. A trailing slash marks a directory.
+// assets/images is deliberately NOT protected: the updater only touches paths
+// contained in the release package, so uploaded images survive anyway, while a
+// release can still ship its own images.
+define('MANAGE_UPDATE_PROTECTED_PATHS', ['config.php', 'data/', '.git/']);
+
+// A release package must contain at least one of these, otherwise it is
+// rejected before a single file is copied.
+define('MANAGE_UPDATE_SANITY_PATHS', ['admin/index.php', 'includes/functions.php']);
+
+// Callback after a successful deployment; see includes/after-update.php.
+define('MANAGE_UPDATE_POST_HOOK', [
+    'file' => __DIR__ . '/includes/after-update.php',
+    'callback' => 'shopAfterUpdate',
+]);
+
 // Session settings
 if (session_status() === PHP_SESSION_NONE) {
     session_start();

+ 4 - 2
docs/ADMIN_SYSTEM.md

@@ -6,7 +6,7 @@ Das Admin-System nutzt einen klassischen Session-Login für den Bereich unter `a
 
 - Login-Seite: `admin/login.php`
 - Admin-Dashboard: `admin/index.php`
-- Admin-Verwaltung: `admin/admins.php`
+- Admin-Verwaltung: `admin/settings.php` (zusammen mit Backup und Updates, siehe `docs/BACKUP_UPDATE.md`)
 - Backend-Helfer: `includes/functions.php`
 - Persistenz: `data/admins.json`
 
@@ -44,11 +44,13 @@ Hinweise:
 
 Diese Regeln werden serverseitig geprüft.
 
-## Bedienung in `admin/admins.php`
+## Bedienung in `admin/settings.php`
 
 - Tabelle zeigt Benutzername + Beschreibung + E-Mail.
 - Bearbeitung von Beschreibung + E-Mail erfolgt über den Button `Profil ändern` und ein separates Formular.
 - Passwortänderung erfolgt über den Button `Passwort ändern` und ein separates Formular.
+- Alle Formulare der Seite sind CSRF-geschützt: ein Token pro Session in
+  `$_SESSION['settings_csrf_token']`, das jedes POST mitschicken muss.
 
 ## Wichtige Betriebsdetails
 

+ 172 - 0
docs/BACKUP_UPDATE.md

@@ -0,0 +1,172 @@
+# Backup and Update
+
+The shop talks to a central **Manage server** (`MANAGE_SERVER_URL`) for three
+things: it uploads backup archives there, it fetches release packages from
+there, and it reports its status there. Everything is triggered from
+**Admin → Einstellungen** (`admin/settings.php`); this host has no cron.
+
+## Files
+
+| File | Content |
+|---|---|
+| `includes/manage.php` | entry point: config defaults, HTTP transport, logging, version, `manageClientStatus()` |
+| `includes/manage-zip.php` | pure-PHP ZIP writer used for backups |
+| `includes/manage-backup.php` | collecting sources, writing archives, retention, upload |
+| `includes/manage-update.php` | manifest, download, verification, deployment, migrations |
+| `includes/manage-heartbeat.php` | status report to the Manage server |
+| `includes/version.php` | the installed version, `APP_VERSION` |
+| `includes/after-update.php` | `shopAfterUpdate()`, the post-update callback |
+| `migrations/` | migration scripts shipped inside a release, see `migrations/README.md` |
+| `scripts/create-release-zip.sh` | builds a release package |
+| `admin/settings.php` | the UI for all of it, plus admin account management |
+
+Runtime state lives under `data/manage/` and is gitignored:
+`backups/` (local archives + `backup-index.json`), `updates/` (files the last
+update overwrote), `work/` (update staging, cleared after each run),
+`manage-client.log` (JSONL), `migrations.json`, `heartbeat.json`.
+
+`config.php` carries every `MANAGE_*` constant. See `docs/CONFIG_REFERENCE.md`;
+defaults live in `includes/manage.php`, so `config.php` only has to set what
+differs.
+
+## Backup
+
+An archive contains `data/*.json` and `assets/images/` — the operational data
+and the product images uploaded through the admin UI, which exist nowhere else.
+`config.php` is deliberately **not** in it: archives can be downloaded by anyone
+with a Manage server login, and that file holds the legacy `ADMIN_USERS`
+hashes, the order-history cookie secret and the instance token.
+
+Three ways in:
+
+- **Button** on the settings page — trigger `manual`.
+- **Automatically** from `admin/index.php` once
+  `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` (7 days) has passed since the last
+  automatic run — trigger `automatic`. The call returns immediately when nothing
+  is due, so the dashboard is only slow on the rare load that actually backs up.
+- **Directly**, `manageBackupCreate('manual')` after
+  `require_once includes/manage.php`.
+
+Each archive is written to `data/manage/backups/`, then uploaded. The last
+`MANAGE_BACKUP_LOCAL_RETENTION` (4) archives stay on disk; retention on the
+Manage server is configured there and is usually much higher.
+
+**A failed upload does not invalidate the archive.** The error is stored in the
+index record, shown in the *Upload* column and logged; the local ZIP is complete
+either way. A failure that does throw means the archive never came into being.
+
+Concurrent runs are prevented by a lock file — a second one fails immediately
+with *Es läuft bereits ein Backup.*
+
+### There is no restore
+
+Backups are created and transferred, never played back. Restoring means
+downloading the ZIP from the settings page or the Manage server and unpacking it
+over `data/` and `assets/images/` by hand. This is deliberate: an automated
+restore button that runs while the shop is live is a footgun.
+
+## Update
+
+The settings page shows the installed version against the current release. The
+*Update ausrollen* button then:
+
+1. fetches and validates the manifest,
+2. downloads the package and checks **size and SHA-256** against it, deleting the
+   file on mismatch,
+3. extracts it, rejecting any entry with `..`, an absolute path, a drive letter
+   or a null byte,
+4. checks the package contains at least one `MANAGE_UPDATE_SANITY_PATHS` entry,
+   so an unrelated ZIP cannot be rolled over the shop,
+5. copies every file over the shop root, **copying each overwritten file aside**
+   into `data/manage/updates/` first, skipping `MANAGE_UPDATE_PROTECTED_PATHS`,
+6. runs pending migrations, then `shopAfterUpdate()`.
+
+Take a backup first. The button does not do it for you — that stays a visible,
+deliberate act.
+
+### Limits, all deliberate
+
+- **No rollback.** The aside copies in `data/manage/updates/` are for manual
+  recovery, and only the most recent run is kept.
+- **Deleted files are not removed.** Deployment is an overlay; a file no longer
+  in the new release stays behind. Removing it belongs in a migration.
+- **No maintenance mode.** The shop stays reachable while files are copied. Roll
+  out during a quiet period.
+- **Files can be live while the post-update step failed.** The settings page
+  reports the two separately. A failed migration leaves the rest pending; fix the
+  cause and press *Migrationen ausführen*.
+
+### Protected paths
+
+`MANAGE_UPDATE_PROTECTED_PATHS` is `config.php`, `data/`, `.git/`. Note that
+`assets/images/` is **not** protected, and does not need to be: the updater only
+touches paths contained in the package, so uploaded images survive on their own,
+while a release can still ship or update its own images.
+
+## Cutting a release
+
+```bash
+./scripts/create-release-zip.sh v1.1.0
+```
+
+The script writes the version into `includes/version.php`, packs every
+**git-tracked** file minus `config.php`, `data/`, `build/`, `scripts/` and
+`.codex/`, and prints size and SHA-256. Commit the version bump, then upload the
+ZIP in the Manage server under *Releases*.
+
+Two things to get right:
+
+- `PRODUCT` at the top of the script must match `MANAGE_PACKAGE_PREFIX` on the
+  Manage server.
+- The version format is strictly `vX.Y.Z`. Forgetting to bump it means the
+  instance keeps reporting the old version and keeps being offered the same
+  update.
+
+Only committed content is packaged — the script warns on a dirty tree rather
+than refusing, because building from one is occasionally deliberate.
+
+## Heartbeat
+
+Reports version, PHP version, free disk space, pending migrations and the time
+of the last backup. Without cron it rides along with page loads: at most once an
+hour on the settings page (`manageHeartbeatSendIfDue()`), and immediately after a
+backup or update, when those values have just changed. *Status melden* forces one.
+
+It never breaks a page render — `manageHeartbeatSendQuietly()` swallows every
+error into the log.
+
+## Security notes
+
+- The token is the only secret between shop and server. It lives in `config.php`,
+  which is gitignored and excluded from release packages. Rotate it on the Manage
+  server if it leaks.
+- `data/manage/` must not be web-readable. The root `.htaccess` denies all of
+  `data/`; verify with
+  `curl -o /dev/null -w "%{http_code}\n" https://<host>/shop/data/manage/backups/`
+  — anything but `403`/`404` is a problem. On nginx this needs a `location` rule
+  instead.
+- Backup downloads validate the filename against `backup-\d{8}-\d{6}(-\d+)?\.zip`,
+  so the form cannot be made to serve another path.
+- Every form on the settings page carries a CSRF token, and the page requires an
+  admin session before anything else runs.
+- Transport is HTTPS with PHP's normal certificate verification, and redirects
+  are not followed — a redirected request fails rather than sending the token
+  somewhere else.
+- The checksum comes from the same server as the package. Whoever controls the
+  Manage server can publish a package **and** its checksum; there is no signature
+  layer. Secure the Manage server accordingly.
+
+## Troubleshooting
+
+| Message | Cause |
+|---|---|
+| *Der Manage-Client ist nicht konfiguriert.* | `MANAGE_SERVER_URL`, `MANAGE_INSTANCE` or `MANAGE_TOKEN` empty in `config.php` |
+| *Es ist kein gültiges Release veröffentlicht.* | no release published on the Manage server yet |
+| *Authentifizierung fehlgeschlagen.* | wrong or rotated token; instance id typo |
+| *Die Prüfsumme des Pakets stimmt nicht überein.* | corrupted download; nothing was deployed |
+| *Das Paket sieht nicht wie ein Release dieses Shops aus* | packaged with a wrapping directory, or the wrong ZIP |
+| *Die Datei konnte nicht ausgerollt werden: …* | PHP lacks write access in the shop root; the update stopped partway |
+| *Es läuft bereits ein Backup.* | a second run while one is in progress |
+
+`data/manage/manage-client.log` holds one JSON object per line for every backup,
+update, migration and failure.

+ 37 - 0
docs/CONFIG_REFERENCE.md

@@ -38,9 +38,46 @@ It is **not** used for customer login, admin login, password reset, or contact f
 | `ADMINS_FILE` | Admin account JSON path | Admin account read/write helpers in `includes/functions.php` |
 | `FAQ_FILE` | FAQ content JSON path (`content` markdown text) | FAQ read/write and markdown rendering helpers in `includes/functions.php`, pages `faq.php` + `admin/faq.php` |
 
+## Backup and update client (`MANAGE_*`)
+
+These drive the update and backup client. Defaults for all of them live in
+`includes/manage.php` behind `if (!defined(...))`, so `config.php` only has to
+set what differs. Full behaviour: `docs/BACKUP_UPDATE.md`.
+
+| Constant | What it controls | Where it is used |
+|---|---|---|
+| `MANAGE_SERVER_URL` | Base URL of the Manage server, no trailing slash and no `/api` | `manageClientEndpoint()` in `includes/manage.php` |
+| `MANAGE_INSTANCE` | Instance id as created on the Manage server | auth header in `manageClientAuthHeaders()` |
+| `MANAGE_TOKEN` | The instance's secret token, shown once on creation. Empty disables every remote call | auth header in `manageClientAuthHeaders()` |
+| `MANAGE_HTTP_TIMEOUT` | Seconds for manifest and heartbeat | `manageClientRequest()` |
+| `MANAGE_HTTP_TIMEOUT_LONG` | Seconds for package download and backup upload | `manageUpdateDownloadPackage()`, `manageBackupUpload()` |
+| `MANAGE_APP_ROOT` | Root of the shop: update target and base of every relative backup path | `manageClientAppRoot()` |
+| `MANAGE_VERSION_FILE` | File holding the installed version | `manageClientVersion()` |
+| `MANAGE_VERSION_CONSTANT` | Constant name inside that file (`APP_VERSION`), or `null` for a plain text file | `manageClientVersion()` |
+| `MANAGE_DIR` | Base of all client runtime state, `data/manage/` | composes the paths below |
+| `MANAGE_BACKUP_DIR` | Local backup archives + `backup-index.json` | `includes/manage-backup.php` |
+| `MANAGE_WORK_DIR` | Update staging, cleared after every run | `manageUpdateApply()` |
+| `MANAGE_UPDATE_BACKUP_DIR` | Aside copies of files an update overwrote; only the last run is kept | `manageUpdateCopyWithBackup()` |
+| `MANAGE_LOG_FILE` | JSONL client log, rotated once past 2 MB | `manageClientLog()` |
+| `MANAGE_HEARTBEAT_STATE` | Timestamp of the last heartbeat, backs the one-per-hour throttle | `manageHeartbeatSendIfDue()` |
+| `MANAGE_MIGRATIONS_DIR` | Directory with migration scripts; `null` disables them | `includes/manage-update.php` |
+| `MANAGE_MIGRATIONS_STATE` | Which migrations have already run | `manageMigrationsReadState()` |
+| `MANAGE_BACKUP_SOURCES` | What goes into an archive: `glob` / `dir` / `file` entries with an optional `as` prefix | `manageBackupCollectSources()` |
+| `MANAGE_BACKUP_LOCAL_RETENTION` | Local archives kept (minimum 1). Independent of retention on the Manage server | `manageBackupApplyRetention()` |
+| `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` | Interval for the dashboard-triggered automatic backup; `0` disables it | `manageBackupIsAutomaticDue()`, called from `admin/index.php` |
+| `MANAGE_BACKUP_COMPRESS` | Deflate archive entries (needs zlib) | `manageZipCompressionAvailable()` |
+| `MANAGE_BACKUP_UPLOAD` | Upload every new backup to the Manage server | `manageBackupCreate()` |
+| `MANAGE_UPDATE_PROTECTED_PATHS` | Paths an update never overwrites, relative to `MANAGE_APP_ROOT`. A trailing slash marks a directory | `manageUpdateShouldSkipPath()` |
+| `MANAGE_UPDATE_SANITY_PATHS` | A package must contain at least one of these or it is rejected before anything is copied | `manageUpdateExtractPackage()` |
+| `MANAGE_UPDATE_POST_HOOK` | `['file' => ..., 'callback' => ...]` run after a successful deployment; `null` disables it | `manageUpdateRunPostHookCallback()`, points at `shopAfterUpdate()` in `includes/after-update.php` |
+
 ## Important notes
 
 - `ORDER_HISTORY_COOKIE_SECRET` should be a long random value; changing it invalidates old browser history cookies.
 - `SITE_URL` should match the real subpath where the app is served. If wrong, links/assets/cookies may break.
 - In this codebase, admin login data is read from `data/admins.json` (`ADMINS_FILE`).  
   The `ADMIN_USERS` block is currently a legacy reference in comments/sample config, not active runtime auth.
+- `MANAGE_TOKEN` is a secret. It lives only in `config.php`, which is gitignored and
+  excluded from release packages. If it leaks, rotate it on the Manage server.
+- `MANAGE_BACKUP_SOURCES` must not include `config.php`: backup archives are
+  downloadable by anyone with a Manage server login.

+ 48 - 0
includes/after-update.php

@@ -0,0 +1,48 @@
+<?php
+// Post-update callback, wired through MANAGE_UPDATE_POST_HOOK in config.php.
+//
+// Runs after the release files are in place and after all pending migrations
+// have succeeded. Migrations handle data changes; this handles the two things
+// that have to happen on every single deploy regardless of what changed.
+
+declare(strict_types=1);
+
+/**
+ * @param array $context app_root, instance, from_version, to_version,
+ *                       backup_dir, run_id, migrations
+ *
+ * @return array{success: bool, error?: string}
+ */
+function shopAfterUpdate(array $context): array
+{
+    $notes = [];
+
+    // 1. The release package excludes data/, so a fresh install or a release
+    //    that adds a new working directory needs them recreated here.
+    foreach ([MANAGE_DIR, MANAGE_BACKUP_DIR, MANAGE_WORK_DIR, MANAGE_UPDATE_BACKUP_DIR] as $dir) {
+        try {
+            manageEnsureDir((string) $dir);
+        } catch (Throwable $exception) {
+            return [
+                'success' => false,
+                'error' => 'Arbeitsverzeichnis konnte nicht angelegt werden: ' . $exception->getMessage(),
+            ];
+        }
+    }
+
+    // 2. OPcache caches compiled bytecode keyed by path and mtime. The deploy
+    //    just replaced PHP files underneath a running process, so without this
+    //    the shop can keep serving the previous release until the cache expires
+    //    on its own.
+    if (function_exists('opcache_reset') && ini_get('opcache.enable')) {
+        $notes[] = opcache_reset() ? 'opcache_reset' : 'opcache_reset_failed';
+    }
+
+    manageClientLog('INFO', 'shopAfterUpdate finished', [
+        'from_version' => (string) ($context['from_version'] ?? ''),
+        'to_version' => (string) ($context['to_version'] ?? ''),
+        'notes' => $notes,
+    ]);
+
+    return ['success' => true];
+}

+ 503 - 0
includes/manage-backup.php

@@ -0,0 +1,503 @@
+<?php
+// Backups: collecting sources, writing the archive, local retention and the
+// upload to the Manage server.
+//
+// The source list is not hardcoded — it comes from MANAGE_BACKUP_SOURCES in
+// config.php, which for this shop is data/*.json plus assets/images/.
+//
+// There is deliberately no restore: archives are created and transferred, never
+// played back. Restoring is manual work, see docs/BACKUP_UPDATE.md.
+
+declare(strict_types=1);
+
+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'  => 'assets/images'    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, function ($left, $right) {
+        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, function ($left, $right) {
+        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));
+}
+
+// Absolute path of a local archive. Validates the filename strictly, so the
+// download form on the settings page cannot be made to serve another path.
+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('Das 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
+ */
+function manageBackupUpload(string $archivePath, array $meta = []): array
+{
+    manageClientRequireConfigured();
+
+    if (!is_file($archivePath)) {
+        throw new RuntimeException('Die 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('Die 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' => manageResponseExcerpt($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' => manageResponseExcerpt($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, and the function returns normally. If it does
+ * throw, the archive itself never came into being.
+ *
+ * @param string $trigger manual | automatic | update
+ */
+function manageBackupCreate(string $trigger = 'manual'): array
+{
+    $dir = manageBackupDir();
+    manageEnsureDir($dir);
+
+    $lockHandle = fopen(manageBackupLockFile(), 'c+');
+    if ($lockHandle === false) {
+        throw new RuntimeException('Die Backup-Sperrdatei konnte nicht geöffnet werden.');
+    }
+
+    // Two admins pressing the button at the same time must not interleave.
+    if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
+        fclose($lockHandle);
+        throw new RuntimeException('Es läuft bereits ein Backup.');
+    }
+
+    try {
+        $baseName = 'backup-' . date('Ymd-His');
+        $filename = $baseName . '.zip';
+        $counter = 2;
+        while (file_exists($dir . $filename)) {
+            $filename = $baseName . '-' . $counter . '.zip';
+            $counter++;
+        }
+
+        $tmpFile = $dir . '.' . $filename . '.tmp';
+        $archivePath = $dir . $filename;
+        $createdAt = date(DATE_ATOM);
+
+        $files = manageBackupCollectSources();
+        $zipStats = manageZipWrite($tmpFile, $files);
+
+        if (!rename($tmpFile, $archivePath)) {
+            @unlink($tmpFile);
+            throw new RuntimeException('Das 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,
+                ]);
+            }
+        }
+
+        $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(),
+            '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 {
+        flock($lockHandle, LOCK_UN);
+        fclose($lockHandle);
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Automatic scheduling — this host has no cron
+// ---------------------------------------------------------------------------
+
+function manageBackupLastAutomaticAt(): int
+{
+    foreach (manageBackupList() as $record) {
+        if ((string) ($record['trigger'] ?? '') !== 'automatic') {
+            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 once the interval has elapsed, otherwise returns
+ * null immediately. Called from admin/index.php, which is the rarely loaded
+ * page this is meant for — a backup takes a few seconds.
+ */
+function manageBackupCreateAutomaticIfDue(): ?array
+{
+    if (!manageBackupIsAutomaticDue()) {
+        return null;
+    }
+
+    return manageBackupCreate('automatic');
+}

+ 120 - 0
includes/manage-heartbeat.php

@@ -0,0 +1,120 @@
+<?php
+// Status report to the Manage server.
+//
+// There is no cron on this host, so the heartbeat rides along with admin page
+// loads: throttled on admin/settings.php via manageHeartbeatSendIfDue(), and
+// unthrottled right after a backup or an update, when the reported values have
+// just changed.
+//
+// The response doubles as a cheap update check, which is why the server
+// dashboard stays current without polling the shop.
+
+declare(strict_types=1);
+
+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 its 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;
+    $free = @disk_free_space(manageClientAppRoot());
+    if (is_float($free) || is_int($free)) {
+        $diskFree = (int) $free;
+    }
+
+    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
+    );
+
+    manageWriteJson((string) MANAGE_HEARTBEAT_STATE, [
+        'sent_at' => date(DATE_ATOM),
+        'latest' => (string) ($decoded['latest'] ?? ''),
+        'update_available' => !empty($decoded['update_available']),
+    ]);
+
+    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 page render, where an
+ * unreachable server must not surface as an error.
+ */
+function manageHeartbeatSendQuietly(): ?array
+{
+    if (!manageClientConfigured()) {
+        return null;
+    }
+
+    try {
+        return manageHeartbeatSend();
+    } catch (Throwable $exception) {
+        manageClientLog('WARNING', 'Heartbeat failed', ['error' => $exception->getMessage()]);
+
+        return null;
+    }
+}
+
+function manageHeartbeatLastSentAt(): int
+{
+    $timestamp = strtotime((string) (manageReadJson((string) MANAGE_HEARTBEAT_STATE)['sent_at'] ?? ''));
+
+    return $timestamp === false ? 0 : $timestamp;
+}
+
+/**
+ * Sends a heartbeat at most once per hour, so opening the settings page
+ * repeatedly does not mean one HTTP round trip per render. Never throws.
+ */
+function manageHeartbeatSendIfDue(int $intervalSeconds = 3600): ?array
+{
+    if (time() - manageHeartbeatLastSentAt() < max(1, $intervalSeconds)) {
+        return null;
+    }
+
+    return manageHeartbeatSendQuietly();
+}

+ 703 - 0
includes/manage-update.php

@@ -0,0 +1,703 @@
+<?php
+// Update pipeline: check, download, verify, extract, deploy, post-update step.
+//
+// Deployment is an overlay copy: every file in the release package is written
+// over the shop root, with each overwritten file copied aside first. Three
+// consequences worth knowing before pressing the button:
+//
+//   - files that disappeared between releases are NOT removed;
+//   - there is no maintenance mode, the shop stays reachable while copying;
+//   - there is no rollback, the aside copies are for manual recovery only.
+//
+// The post-update step runs migrations from migrations/ and then the callback
+// in MANAGE_UPDATE_POST_HOOK. A failure there is reported loudly rather than
+// silently, because the files are live at that point.
+
+declare(strict_types=1);
+
+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
+ * shop 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('Die Version im Manifest ist ungültig.');
+    }
+    if (!filter_var($packageUrl, FILTER_VALIDATE_URL)) {
+        throw new RuntimeException('Die Paket-URL im Manifest ist ungültig.');
+    }
+    if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) {
+        throw new RuntimeException('Die 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.
+ *
+ * If the installed version cannot be determined, available is true, so an
+ * installation with an unreadable version file is not permanently blocked.
+ *
+ * @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('Die Größe des heruntergeladenen Pakets stimmt nicht überein.');
+    }
+
+    $actualHash = strtolower(hash_file('sha256', $targetFile) ?: '');
+    if ($actualHash !== $manifest['sha256']) {
+        unlink($targetFile);
+        throw new RuntimeException('Die 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;
+            }
+        }
+    }
+
+    // Guards against rolling a completely unrelated ZIP over the shop.
+    if (!$hasAppFile) {
+        $zip->close();
+        throw new RuntimeException(
+            'Das Paket sieht nicht wie ein Release dieses Shops 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, but a directory named without the
+ * slash still protects its contents.
+ */
+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);
+        $entry = trim($entry, '/');
+        if ($entry === '') {
+            continue;
+        }
+
+        if ($relativePath === $entry || 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('Die Datei konnte nicht gesichert werden: ' . $relativePath);
+            }
+            $backedUp++;
+        }
+
+        if (!copy($item->getPathname(), $targetPath)) {
+            throw new RuntimeException('Die Datei konnte nicht ausgerollt werden: ' . $relativePath);
+        }
+
+        @chmod($targetPath, fileperms($item->getPathname()) & 0777);
+        $copied++;
+    }
+
+    return ['copied' => $copied, 'backed_up' => $backedUp, 'skipped' => $skipped];
+}
+
+// Keeps only the aside copies 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('Ein altes Sicherungsverzeichnis 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
+ *
+ * Throws if deployment fails. If only the post-update step fails, the function
+ * returns normally with hook['success'] false — the files are live by then, and
+ * the caller has to tell the two cases apart.
+ */
+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 "erneut ausrollen" kann dasselbe Paket noch einmal ' .
+            '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'],
+        '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;
+    }
+
+    // APP_VERSION is already loaded in this process from the old code, so
+    // to_version comes from the manifest rather than from a re-read.
+    $report['hook'] = manageUpdateRunPostHook([
+        'from_version' => $check['current'],
+        'to_version' => $manifest['version'],
+        'backup_dir' => $backupDir,
+        'run_id' => $runId,
+    ]);
+
+    return $report;
+}
+
+// ---------------------------------------------------------------------------
+// Migrations
+// ---------------------------------------------------------------------------
+
+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 manageMigrationsReadState(): array
+{
+    $state = manageReadJson((string) MANAGE_MIGRATIONS_STATE);
+    $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((string) MANAGE_MIGRATIONS_STATE, $state);
+}
+
+/**
+ * All migration files in the installation, 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, function ($left, $right) {
+        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
+{
+    return array_merge([
+        'app_root' => manageClientAppRoot(),
+        'instance' => (string) MANAGE_INSTANCE,
+        'from_version' => '',
+        'to_version' => manageClientVersion(),
+        'backup_dir' => '',
+        'run_id' => '',
+    ], $extra);
+}
+
+/**
+ * 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(
+        'Die 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 {
+            // 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('Die Hook-Datei wurde nicht gefunden: ' . $file);
+            }
+            require_once $file;
+        }
+
+        $callback = $hook['callback'];
+        if (!is_callable($callback)) {
+            throw new RuntimeException(
+                'Der 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(
+                'Der 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 data shape. 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,
+    ];
+}

+ 329 - 0
includes/manage-zip.php

@@ -0,0 +1,329 @@
+<?php
+// Pure-PHP ZIP writer for backups. Builds the archive with pack() rather than
+// requiring ext-zip, exec or a temp copy of the whole archive in memory, and
+// reports file count, source size and SHA-256 in the same pass — the backup
+// index and the upload metadata both need those numbers.
+//
+// Deflate compression (method 8) is optional: storing everything uncompressed
+// is fine for the product JPEGs, wasteful for the JSON data files.
+//
+// Limits (no Zip64): 4 GB per entry, 4 GB per archive, 65535 entries.
+
+declare(strict_types=1);
+
+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) ?: '',
+    ];
+}

+ 564 - 0
includes/manage.php

@@ -0,0 +1,564 @@
+<?php
+// Manage client core: configuration defaults, filesystem helpers, the
+// authenticated HTTP transport and version file handling.
+//
+// This is the only file the shop needs to require; it pulls in the rest of the
+// client. config.php is expected to be loaded already (every entry point of the
+// shop does that first), so the MANAGE_* constants set there win over the
+// defaults below.
+//
+//     require_once __DIR__ . '/../includes/manage.php';
+//     $status = manageClientStatus();
+//
+// Talks to the Manage server at MANAGE_SERVER_URL: update manifest, release
+// package download, backup upload and heartbeat. See docs/BACKUP_UPDATE.md.
+
+declare(strict_types=1);
+
+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__));
+}
+if (!defined('MANAGE_VERSION_FILE')) {
+    define('MANAGE_VERSION_FILE', MANAGE_APP_ROOT . '/includes/version.php');
+}
+if (!defined('MANAGE_VERSION_CONSTANT')) {
+    define('MANAGE_VERSION_CONSTANT', 'APP_VERSION');
+}
+if (!defined('MANAGE_DIR')) {
+    define('MANAGE_DIR', MANAGE_APP_ROOT . '/data/manage/');
+}
+if (!defined('MANAGE_WORK_DIR')) {
+    define('MANAGE_WORK_DIR', MANAGE_DIR . 'work/');
+}
+if (!defined('MANAGE_UPDATE_BACKUP_DIR')) {
+    define('MANAGE_UPDATE_BACKUP_DIR', MANAGE_DIR . 'updates/');
+}
+if (!defined('MANAGE_BACKUP_DIR')) {
+    define('MANAGE_BACKUP_DIR', MANAGE_DIR . 'backups/');
+}
+if (!defined('MANAGE_LOG_FILE')) {
+    define('MANAGE_LOG_FILE', MANAGE_DIR . 'manage-client.log');
+}
+if (!defined('MANAGE_HEARTBEAT_STATE')) {
+    define('MANAGE_HEARTBEAT_STATE', MANAGE_DIR . 'heartbeat.json');
+}
+if (!defined('MANAGE_UPDATE_PROTECTED_PATHS')) {
+    define('MANAGE_UPDATE_PROTECTED_PATHS', ['config.php', 'data/', '.git/']);
+}
+if (!defined('MANAGE_UPDATE_SANITY_PATHS')) {
+    define('MANAGE_UPDATE_SANITY_PATHS', ['admin/index.php', 'includes/functions.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_DIR . 'migrations.json');
+}
+if (!defined('MANAGE_BACKUP_SOURCES')) {
+    define('MANAGE_BACKUP_SOURCES', [['as' => 'data', 'glob' => 'data/*.json']]);
+}
+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);
+}
+
+// Raised when the backup upload fails. Carries a scrubbed debug context that is
+// safe to log: the token is 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 manageClientNormalizePath(string $path): string
+{
+    return str_replace('\\', '/', $path);
+}
+
+function manageEnsureDir(string $dir): void
+{
+    if (is_dir($dir)) {
+        return;
+    }
+
+    // Mode 0775, deliberately without the setgid bit: asking for setgid on a
+    // directory whose group the process does not belong to is refused outright
+    // with EPERM, which is exactly the situation when Apache runs as www-data
+    // under a project owned by a deploy user. Group inheritance comes from the
+    // parent directory's own setgid bit or its default ACL instead.
+    if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
+        throw new RuntimeException('Verzeichnis konnte nicht erstellt werden: ' . $dir);
+    }
+
+    // Defeats a restrictive umask, and keeps the ACL mask at rwx so that
+    // entries granting another user write access stay effective.
+    @chmod($dir, 0775);
+}
+
+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
+// ---------------------------------------------------------------------------
+// Deliberately not readJsonFile()/writeJsonFile() from functions.php: those
+// return [] on failure without distinguishing, while the client needs the
+// atomic rename below so a crash mid-write can never truncate the backup index.
+
+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; there is no log rotation in this shop.
+    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);
+    }
+}
+
+// Truncated, whitespace-collapsed excerpt of a server response, for logging a
+// failed upload without dumping a whole HTML error page into the log.
+function manageResponseExcerpt($response): string
+{
+    if (!is_string($response) || $response === '') {
+        return '';
+    }
+
+    $response = preg_replace('/\s+/', ' ', trim($response));
+
+    return is_string($response) ? substr($response, 0, 500) : '';
+}
+
+// ---------------------------------------------------------------------------
+// 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 from includes/version.php.
+ *
+ * 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 in this process.
+        if (defined($constant)) {
+            $value = (string) constant($constant);
+            if (manageIsVersionString($value)) {
+                return trim($value);
+            }
+        }
+
+        // Otherwise parse it out of the file without executing it: right after
+        // an update the file on disk is newer than the loaded constant, 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(
+        'Der Manage-Client ist nicht konfiguriert. MANAGE_SERVER_URL, MANAGE_INSTANCE und ' .
+        'MANAGE_TOKEN müssen in 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.
+ *
+ * Redirects are deliberately not followed, so credentials never travel to a
+ * different destination than the configured one.
+ *
+ * @param string      $method  GET or POST
+ * @param string      $path    endpoint below api/v1/
+ * @param string|null $body    raw request body for POST
+ * @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('Der 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('Die Antwort des Servers ist kein gültiges JSON.');
+    }
+
+    return $decoded;
+}
+
+require_once __DIR__ . '/manage-zip.php';
+require_once __DIR__ . '/manage-backup.php';
+require_once __DIR__ . '/manage-update.php';
+require_once __DIR__ . '/manage-heartbeat.php';
+
+/**
+ * Aggregate status for admin/settings.php. Never throws: every remote failure
+ * is reported inside the returned array, so the settings page still renders
+ * when the Manage 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;
+}

+ 13 - 0
includes/version.php

@@ -0,0 +1,13 @@
+<?php
+// Installed version of the shop.
+//
+// This file is the single source of truth for the version number. It is never
+// written by the update client: it changes as a side effect of a release being
+// rolled out over the installation, and is set at build time by
+// scripts/create-release-zip.sh.
+//
+// Format is strictly vX.Y.Z — both client and Manage server reject anything
+// else. manageClientVersion() reads the value with a regular expression and
+// does not execute this file a second time.
+
+define('APP_VERSION', 'v1.0.0');

+ 80 - 0
migrations/README.md

@@ -0,0 +1,80 @@
+# Migrations
+
+Migrations convert existing installations when a release changes the shape of
+something in `data/`. They ship **inside** the release package and run
+automatically as part of the post-update step, right after the files are
+deployed and before `shopAfterUpdate()`.
+
+Which migrations have already run is recorded in `data/manage/migrations.json`,
+which is never part of a release. Pending migrations are listed on
+*Admin → Einstellungen* and can be re-run from there after a failure.
+
+## When you need one
+
+Only for changes that existing data cannot survive on its own. Adding a new
+optional field with a sensible default is handled by
+`normalizeProductRecord()` and friends in `includes/functions.php` — that needs
+no migration. Renaming a field, splitting one file into two, or changing a value
+format does.
+
+## Naming
+
+```
+YYYY-MM-DD-NN-short-slug.php
+```
+
+for example `2026-08-21-01-add-category-id.php`. Files run in filename order,
+so the date prefix and the two-digit counter decide the sequence. **The filename
+without `.php` is the migration id** — renaming an applied migration makes it
+run again.
+
+## Shape
+
+Return a closure. (A file defining a global `up()` also works, but two such
+files in one run would collide.)
+
+```php
+<?php
+
+return function (array $context): void {
+    $file = $context['app_root'] . '/data/products.json';
+
+    $products = json_decode((string) file_get_contents($file), true);
+    if (!is_array($products)) {
+        throw new RuntimeException('products.json ist nicht lesbar.');
+    }
+
+    foreach ($products as &$product) {
+        if (!isset($product['category_ids'])) {
+            $product['category_ids'] = isset($product['category'])
+                ? [$product['category']]
+                : [];
+        }
+        unset($product['category']);
+    }
+    unset($product);
+
+    file_put_contents(
+        $file,
+        json_encode($products, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
+    );
+};
+```
+
+`$context` carries `app_root`, `instance`, `from_version`, `to_version`,
+`backup_dir`, `run_id` and `migration_id`.
+
+## Rules
+
+- **Throw on failure.** A thrown exception stops the run at that migration;
+  everything after it stays pending and the admin sees the error. Returning
+  quietly on a problem hides a half-migrated installation.
+- **Be idempotent where you can.** Guard with `isset()` rather than assuming the
+  old shape, so a re-run after a partial failure is harmless.
+- **Do not require the new code.** A migration runs in the process that was
+  loaded from the *previous* release. Do the work with plain file operations
+  instead of calling shop functions that may only exist in the new version.
+- **Never delete the only copy.** An automatic backup is not taken before an
+  update; the aside copies in `data/manage/updates/` cover files, not data.
+  Take a backup from the settings page before rolling out a release that
+  migrates data.

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

@@ -0,0 +1,202 @@
+#!/usr/bin/env bash
+#
+# Builds a release package of this shop for the Manage server.
+#
+# Run it from the project root:
+#
+#     ./scripts/create-release-zip.sh v1.1.0
+#
+# It writes the version into includes/version.php, packs every git-tracked file
+# minus the exclusions below, and prints the SHA-256 and size. Upload the
+# resulting ZIP in the Manage server under "Releases"; the shop then offers it
+# on Admin -> Einstellungen.
+#
+# The root of the ZIP is the shop root - no wrapping directory, otherwise the
+# update would create a subfolder instead of updating anything.
+#
+# See docs/BACKUP_UPDATE.md.
+
+set -euo pipefail
+
+# --- CONFIGURATION ----------------------------------------------------------
+
+# Package name prefix. Must match MANAGE_PACKAGE_PREFIX on the Manage server.
+PRODUCT="feuerwehr-shop"
+
+# 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.
+# config.php holds this installation's admin hashes, cookie secret and Manage
+# token; data/ holds the operational data of whoever built the package. Both are
+# additionally covered by MANAGE_UPDATE_PROTECTED_PATHS, but they must not end
+# up in a package that gets downloaded from the Manage server in the first place.
+EXCLUDES=(
+    ".gitignore"
+    "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
+
+# The file list comes from `git ls-files`, so anything uncommitted silently
+# stays out of the package. 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
+
+# Untracked files are the nastier case: a new file added by the release but
+# never `git add`ed is missing from the package with nothing else to show for it.
+UNTRACKED="$(git ls-files --others --exclude-standard)"
+if [[ -n "$UNTRACKED" ]]; then
+    echo "Warning: untracked files will NOT be in the package:" >&2
+    printf '         %s\n' $UNTRACKED >&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