2 커밋 16307e2040 ... f73b4bba5a

작성자 SHA1 메시지 날짜
  Medowar f73b4bba5a fixing and adapting stuff for manage-client 1 개월 전
  Medowar 54f924c604 implementing manage client 1 개월 전

+ 7 - 0
.gitignore

@@ -2,6 +2,13 @@
 /config/config.php
 /config/credentials.php
 
+# Manage client instance configuration (server URL, instance id, token).
+# The rest of manage-client/ IS checked in, so it ships in release packages.
+/manage-client/config.php
+
+# Release packages built by scripts/create-release-zip.sh
+/build/
+
 # Runtime data
 /data/*
 !/data/.htaccess

+ 4 - 1
.htaccess

@@ -5,9 +5,12 @@ DirectoryIndex index.php
 
 # Primary protection: block the internal directories outright. This works even
 # on hosts that ignore the per-directory .htaccess files in app/, config/, data/.
+# manage-client/ is included: it is reached by PHP include only, never over HTTP
+# — its backup and update functions are exposed through admin/maintenance.php,
+# behind the admin login.
 <IfModule mod_rewrite.c>
     RewriteEngine On
-    RewriteRule ^(app|config|data|docs)/ - [F,L]
+    RewriteRule ^(app|config|data|docs|manage-client|migrations|scripts)/ - [F,L]
 </IfModule>
 
 # Cache static assets

+ 26 - 11
README.md

@@ -25,6 +25,16 @@ No database, no framework, no build step — upload via FTP/SFTP and it runs.
   strip EXIF metadata (camera, lens, timestamps, GPS) from what it stores.
 - **Flat-file storage** — all content lives in JSON files; admin credentials
   live in a PHP config file. Password can be changed online.
+- **Backup & update** — the backoffice can back the installation up and install
+  a new release, both through a central manage server. Backups run themselves on
+  a weekly schedule that needs no cron; updates never do, and stay one deliberate
+  click. Same operations from the command line where cron does exist.
+
+## Version
+
+`v1.2.0` — semantic versioning, `vMAJOR.MINOR.PATCH`. The number lives in
+[app/version.php](app/version.php); the release script writes it and an update
+carries it along. See [docs/SETUP.md](docs/SETUP.md) §5a.
 
 ## Requirements
 
@@ -47,7 +57,7 @@ first** (Settings).
 ## Documentation
 
 - [docs/SETUP.md](docs/SETUP.md) — deployment, Hetzner bucket + CORS setup,
-  and how to update an existing installation
+  backups, and how to update an existing installation
 - [docs/ADMIN-GUIDE.md](docs/ADMIN-GUIDE.md) — using the backoffice
 - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how it works inside
 
@@ -57,15 +67,20 @@ Upload the contents of this folder straight into your document root —
 `index.php` is the home page.
 
 ```
-index.php  landing page              ← document root
+index.php       landing page                        ← document root
 showreel.php
-gallery/   client galleries (/gallery/?g=<slug>)
-admin/     backoffice
-assets/    css + js
-media/     local images (hero + showreel)
-app/       PHP library code    ┐
-config/    config + credentials├─ inside the docroot but blocked by .htaccess
-data/      flat-file storage   ┘
-docs/      documentation
-router.php local dev only (php -S)
+gallery/        client galleries (/gallery/?g=<slug>)
+admin/          backoffice
+assets/         css + js
+media/          local images (hero + showreel)
+worker.php      background archive builder
+manage-worker.php  background backup + heartbeat
+app/            PHP library code                   ┐
+config/         config + credentials               │
+data/           flat-file storage                  │ inside the docroot,
+docs/           documentation                      ├─ but blocked
+manage-client/  backup + update client             │  by .htaccess
+migrations/     one-time release scripts           │
+scripts/        release build + cron examples      ┘
+router.php      local dev only (php -S)
 ```

+ 4 - 0
admin/index.php

@@ -39,6 +39,10 @@ flash_render();
         <tr><td>Data format</td>
             <td><?= $pending === [] ? 'up to date (v' . (int)SCHEMA_VERSION . ')' : count($pending) . ' pending' ?></td>
             <td><a href="migrate.php">Migration →</a></td></tr>
+        <!-- Version from the constant, never from the manage server: the
+             dashboard must not wait on a network call to render. -->
+        <tr><td>Software version</td><td><?= e(APP_VERSION) ?></td>
+            <td><a href="maintenance.php">Update &amp; backup →</a></td></tr>
     </table>
 </div>
 <p class="help">

+ 447 - 0
admin/maintenance.php

@@ -0,0 +1,447 @@
+<?php
+/**
+ * Maintenance: backups and software updates, both driven by the manage client
+ * in manage-client/ (reference: https://manage.med0.de/client-docs/).
+ *
+ * The page holds no update or backup logic of its own — every button calls the
+ * same documented function the CLI calls, so `php manage-client/bin/manage-client.php
+ * backup` and the button below do exactly the same thing. The client ships a
+ * drop-in panel of its own; this page replaces it so the backoffice keeps one
+ * look, one login and one CSRF token.
+ *
+ * Results are rendered on the POST itself rather than after a redirect: an
+ * update reports several lines (files copied, migrations run, backup location)
+ * and a flash message holds one.
+ */
+require dirname(__DIR__) . '/app/bootstrap.php';
+auth_require();
+
+// An installation may legitimately not carry the client — it is one folder,
+// and deploying by FTP works without it. Say so instead of dying on a require.
+if (!is_file(APP_ROOT . '/manage-client/lib/client.php')) {
+    admin_header('Maintenance', 'maintenance');
+    echo '<h1>Maintenance</h1><div class="card"><p class="help" style="margin:0">'
+        . 'The backup and update client is not installed: <code>manage-client/</code> '
+        . 'is missing. See <code>docs/SETUP.md</code>, section 5a.</p></div>';
+    admin_footer();
+    exit;
+}
+require_once APP_ROOT . '/manage-client/lib/client.php';
+
+$messages = [];
+$errors   = [];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    csrf_verify();
+    // Archiving media/ and uploading it runs well past the default limit.
+    @set_time_limit(0);
+
+    $action = (string)($_POST['action'] ?? '');
+
+    try {
+        if ($action === 'download') {
+            // Validates the filename itself and throws on anything that is not
+            // a backup of this installation.
+            $path = manageBackupPath((string)($_POST['filename'] ?? ''));
+            $size = filesize($path);
+            $fh   = fopen($path, 'rb');
+            if ($size === false || $fh === false) {
+                throw new RuntimeException('Backup file could not be opened.');
+            }
+            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($fh);
+            fclose($fh);
+            exit;
+        }
+
+        if ($action === 'backup') {
+            $messages = array_merge($messages, maintenance_backup('manual'));
+            manageHeartbeatSendQuietly();
+        } elseif ($action === 'update') {
+            // Deliberately a line here rather than a feature of the client: it
+            // stays visible that the update takes a backup first, and the
+            // update stops if that backup cannot be written.
+            if (!empty($_POST['backup_first'])) {
+                $messages = array_merge($messages, maintenance_backup('update'));
+            }
+
+            $result = manageUpdateApply(['force' => !empty($_POST['force'])]);
+            $messages[] = sprintf(
+                'Update deployed: %s → %s. %d files copied, %d replaced files saved to %s.',
+                $result['from_version'] !== '' ? $result['from_version'] : 'unknown',
+                $result['to_version'],
+                $result['copied'],
+                $result['backed_up'],
+                $result['backup_dir'],
+            );
+
+            // Files are deployed even when the hook failed; that difference is
+            // the whole point of reporting it separately.
+            $hook = is_array($result['hook'] ?? null) ? $result['hook'] : [];
+            $applied = $hook['migrations']['applied'] ?? [];
+            if ($applied !== []) {
+                $messages[] = 'Migrations run: ' . implode(', ', $applied);
+            }
+            if ($hook !== [] && empty($hook['success'])) {
+                $errors[] = empty($hook['failed_migration'])
+                    ? 'The files are deployed, but the post-update step failed: '
+                        . (string)($hook['error'] ?? 'unknown')
+                    : 'The files are deployed, but migration "' . (string)$hook['failed_migration']
+                        . '" failed: ' . (string)($hook['error'] ?? 'unknown')
+                        . ' Remaining migrations were not attempted — fix the cause, then use "Run migrations" below.';
+            }
+            manageHeartbeatSendQuietly();
+        } elseif ($action === 'migrate') {
+            $report = manageUpdateRunMigrations();
+            if ($report['applied'] !== []) {
+                $messages[] = 'Migrations run: ' . implode(', ', $report['applied']);
+            }
+            if (!$report['success']) {
+                $errors[] = 'Migration "' . (string)$report['failed'] . '" failed: ' . (string)$report['error'];
+            } elseif ($report['applied'] === []) {
+                $messages[] = 'No pending migrations.';
+            }
+        } elseif ($action === 'heartbeat') {
+            $result = manageHeartbeatSend();
+            $messages[] = 'Status reported. Current release: '
+                . (($result['latest'] ?? '') !== '' ? (string)$result['latest'] : 'none') . '.';
+        }
+    } catch (Throwable $e) {
+        $errors[] = $e->getMessage();
+    }
+}
+
+/**
+ * One backup, reported as message lines. A failed upload is a warning, not a
+ * failure: the archive exists locally either way.
+ */
+function maintenance_backup(string $trigger): array
+{
+    $record = manageBackupCreate($trigger);
+    $lines = [sprintf(
+        'Backup created: %s — %d files, %s.',
+        $record['filename'],
+        $record['file_count'],
+        manageFormatBytes((int)$record['size']),
+    )];
+    foreach ($record['remote_uploads'] as $upload) {
+        if (empty($upload['success'])) {
+            $lines[] = 'Upload to ' . (string)$upload['target'] . ' failed: '
+                . (string)($upload['error'] ?? 'unknown') . ' The local copy is intact.';
+        }
+    }
+    return $lines;
+}
+
+/** An interval as something readable: 604800 -> "every 7 days". */
+function maintenance_interval_text(int $seconds): string
+{
+    if ($seconds % 86400 === 0) {
+        $days = intdiv($seconds, 86400);
+        return $days === 1 ? 'daily' : 'every ' . $days . ' days';
+    }
+    if ($seconds % 3600 === 0) {
+        $hours = intdiv($seconds, 3600);
+        return $hours === 1 ? 'hourly' : 'every ' . $hours . ' hours';
+    }
+    return 'every ' . max(1, intdiv($seconds, 60)) . ' minutes';
+}
+
+// The one place that asks the manage server whether a release is waiting: it is
+// a network round trip, so it happens when this page is opened and nowhere else.
+// Never throws — an unreachable server still renders the page.
+$status = manageClientStatus();
+
+// State of the schedule in app/manage.php.
+$autoInterval = (int)MANAGE_BACKUP_AUTO_INTERVAL_SECONDS;
+$due = manage_due();
+$lastAutoBackup = 0;
+foreach ($status['backups'] as $backup) {
+    if (in_array($backup['trigger'] ?? '', ['automatic', 'cron'], true)) {
+        $lastAutoBackup = max($lastAutoBackup, strtotime((string)($backup['created_at'] ?? '')) ?: 0);
+    }
+}
+$lastHeartbeat = (int)(json_read(manage_state_file())['heartbeat_at'] ?? 0);
+
+// An instance whose server has no release yet answers the manifest with 404.
+// That is a normal state — a new project, nothing published — and reads far
+// too much like a broken connection when it is shown as a failed check.
+$noReleaseYet = $status['update_error'] !== null
+    && str_contains($status['update_error'], 'HTTP 404');
+$update = $status['update'];
+$capabilities = manageRemoteCapabilities();
+
+admin_header('Maintenance', 'maintenance');
+flash_render();
+?>
+<h1>Maintenance</h1>
+
+<?php foreach ($messages as $line): ?>
+    <div class="flash flash-ok"><?= e($line) ?></div>
+<?php endforeach; ?>
+<?php foreach ($errors as $line): ?>
+    <div class="flash flash-error"><?= e($line) ?></div>
+<?php endforeach; ?>
+
+<?php if (!$status['configured']): ?>
+    <div class="flash flash-error">
+        Not connected to the manage server. Create an instance there, then fill in
+        <code>MANAGE_INSTANCE</code> and <code>MANAGE_TOKEN</code> in
+        <code>manage-client/config.php</code>. Backups can still be made locally.
+    </div>
+<?php endif; ?>
+
+<div class="card">
+    <table>
+        <tr>
+            <td>Installed version</td>
+            <td><?= e($status['version'] !== '' ? $status['version'] : 'unknown') ?></td>
+            <td class="help" style="margin:0">PHP <?= e($status['php_version']) ?></td>
+        </tr>
+        <tr>
+            <td>Current release</td>
+            <td>
+                <?php if ($update !== null && $update['available']): ?>
+                    <span class="tag tag-lock"><?= e($update['latest']) ?> available</span>
+                <?php elseif ($update !== null): ?>
+                    <?= e($update['latest'] !== '' ? $update['latest'] : '—') ?>
+                <?php else: ?>
+                    —
+                <?php endif; ?>
+            </td>
+            <td class="help" style="margin:0">
+                <?php if ($noReleaseYet): ?>
+                    No release has been published on the manage server yet.
+                <?php elseif ($status['update_error'] !== null): ?>
+                    Check failed: <?= e($status['update_error']) ?>
+                <?php elseif ($update !== null && $update['available']): ?>
+                    Back up first, then deploy.
+                <?php elseif ($update !== null): ?>
+                    Up to date.
+                <?php else: ?>
+                    <?= e($status['instance'] !== '' ? $status['instance'] : 'no instance configured') ?>
+                <?php endif; ?>
+            </td>
+        </tr>
+        <tr>
+            <td>Last backup</td>
+            <td><?= e($status['last_backup_at'] ?? 'never') ?></td>
+            <td class="help" style="margin:0"><?= count($status['backups']) ?> kept locally</td>
+        </tr>
+        <tr>
+            <td>Release migrations</td>
+            <td><?= count($status['pending_migrations']) ?> pending</td>
+            <td class="help" style="margin:0">
+                <?= $status['pending_migrations'] === []
+                    ? 'Nothing to run.'
+                    : e(implode(', ', array_column($status['pending_migrations'], 'id'))) ?>
+            </td>
+        </tr>
+    </table>
+</div>
+
+<h2>Backup</h2>
+<div class="card">
+    <p class="help" style="margin-top:0">
+        Archives <code>data/</code> and <code>media/</code> — galleries, showreel,
+        front page, settings — and uploads it to the manage server. Gallery photos
+        are not included: they live in the S3 bucket, which is their own backup.
+        Nor are <code>config/</code> credentials, because a backup can be
+        downloaded again from the server.
+    </p>
+    <form method="post">
+        <?= csrf_field() ?>
+        <input type="hidden" name="action" value="backup">
+        <button type="submit" style="margin:0">Back up now</button>
+    </form>
+</div>
+
+<h2>Schedule</h2>
+<div class="card">
+    <p class="help" style="margin-top:0">
+        This host is assumed to have no cron, so the backoffice drives both jobs:
+        opening any admin page past the interval starts them in the background
+        (<code>manage-worker.php</code>). Updates are never part of that, and the
+        release check runs only when this page is opened.
+    </p>
+    <table>
+        <tr>
+            <td>Automatic backup</td>
+            <td><?= $autoInterval > 0 ? e(maintenance_interval_text($autoInterval)) : 'off' ?></td>
+            <td class="help" style="margin:0">
+                <?php if ($autoInterval <= 0): ?>
+                    <code>MANAGE_BACKUP_AUTO_INTERVAL_SECONDS</code> is 0 — only cron or the button above make backups.
+                <?php elseif (in_array('backup', $due, true)): ?>
+                    Due now — starts on the next admin page load.
+                <?php else: ?>
+                    Next <?= e(date('Y-m-d H:i', $lastAutoBackup + $autoInterval)) ?>.
+                <?php endif; ?>
+            </td>
+        </tr>
+        <tr>
+            <td>Heartbeat</td>
+            <td><?= e(maintenance_interval_text(manage_heartbeat_interval())) ?></td>
+            <td class="help" style="margin:0">
+                <?php if ($lastHeartbeat === 0): ?>
+                    Not sent yet.
+                <?php else: ?>
+                    Last <?= e(date('Y-m-d H:i', $lastHeartbeat)) ?>.
+                <?php endif; ?>
+            </td>
+        </tr>
+    </table>
+    <p class="help">
+        If the host does offer cron, point it at
+        <code>manage-worker.php?key=…</code> every 15 minutes instead — the key
+        is in <code>data/worker-key.json</code>, and the jobs are the same code
+        either way. See <code>scripts/manage-client.cron</code>.
+    </p>
+</div>
+
+<h2>Update</h2>
+<div class="card">
+    <?php if ($update !== null && $update['available']): ?>
+        <p style="margin-top:0">
+            Version <strong><?= e($update['latest']) ?></strong> is ready
+            <?php if (!empty($update['manifest']['published_at'])): ?>
+                (published <?= e($update['manifest']['published_at']) ?>)
+            <?php endif; ?>.
+        </p>
+    <?php endif; ?>
+    <p class="help" style="margin-top:0">
+        Files are replaced while the site stays online, and there is no rollback:
+        the replaced files are copied to <code>data/manage/updates/</code> for
+        manual recovery. <code>config/</code>, <code>data/</code> and
+        <code>media/</code> are never touched. Deleted files are not removed —
+        an update overlays what is there.
+        <?php if ($status['pending_migrations'] === []): ?>
+            Afterwards, check <a href="migrate.php">Data migration</a>.
+        <?php endif; ?>
+    </p>
+    <form method="post" onsubmit="return confirm('Deploy the update now? Files will be overwritten.');">
+        <?= csrf_field() ?>
+        <input type="hidden" name="action" value="update">
+        <p class="help" style="margin-bottom:.4rem"><label style="display:inline;text-transform:none;letter-spacing:0">
+            <input type="checkbox" name="backup_first" value="1" checked>
+            Create a backup first
+        </label></p>
+        <p class="help" style="margin-bottom:.4rem"><label style="display:inline;text-transform:none;letter-spacing:0">
+            <input type="checkbox" name="force" value="1">
+            Deploy even if no newer version is offered
+        </label></p>
+        <button type="submit" style="margin-top:1rem"
+            <?= $update !== null && !$update['available'] ? 'class="btn-ghost"' : '' ?>>
+            Deploy update
+        </button>
+    </form>
+</div>
+
+<?php if ($status['pending_migrations'] !== []): ?>
+<h2>Pending release migrations</h2>
+<div class="card">
+    <p class="help" style="margin-top:0">
+        Shipped with a release and normally run by the update itself. These are
+        left over — usually because one failed, or because the update ran with
+        migrations skipped.
+    </p>
+    <table style="margin-bottom:1rem">
+        <?php foreach ($status['pending_migrations'] as $migration): ?>
+            <tr><td><?= e($migration['id']) ?></td></tr>
+        <?php endforeach; ?>
+    </table>
+    <form method="post">
+        <?= csrf_field() ?>
+        <input type="hidden" name="action" value="migrate">
+        <button type="submit" style="margin:0">Run migrations</button>
+    </form>
+</div>
+<?php endif; ?>
+
+<h2>Local backups</h2>
+<div class="card">
+    <?php if ($status['backups'] === []): ?>
+        <p class="help" style="margin:0">No backup has been made yet.</p>
+    <?php else: ?>
+        <table>
+            <tr>
+                <th>File</th><th>Created</th><th>Trigger</th>
+                <th>Files</th><th>Size</th><th>Upload</th><th></th>
+            </tr>
+            <?php foreach ($status['backups'] as $backup): ?>
+                <tr>
+                    <td><?= e((string)($backup['filename'] ?? '')) ?></td>
+                    <td><?= e((string)($backup['created_at'] ?? '')) ?></td>
+                    <td><?= e((string)($backup['trigger'] ?? '')) ?></td>
+                    <td><?= (int)($backup['file_count'] ?? 0) ?></td>
+                    <td><?= e(manageFormatBytes((int)($backup['size'] ?? 0))) ?></td>
+                    <td>
+                        <?php $uploads = is_array($backup['remote_uploads'] ?? null) ? $backup['remote_uploads'] : []; ?>
+                        <?php if ($uploads === []): ?>
+                            —
+                        <?php else: foreach ($uploads as $upload): ?>
+                            <span class="tag <?= empty($upload['success']) ? 'tag-expired' : 'tag-lock' ?>">
+                                <?= e((string)($upload['target'] ?? '?')) ?><?= empty($upload['success']) ? ' failed' : '' ?>
+                            </span>
+                        <?php endforeach; endif; ?>
+                    </td>
+                    <td>
+                        <form method="post">
+                            <?= csrf_field() ?>
+                            <input type="hidden" name="action" value="download">
+                            <input type="hidden" name="filename" value="<?= e((string)($backup['filename'] ?? '')) ?>">
+                            <button type="submit" class="btn-ghost" style="margin:0;padding:.4rem 1rem">Download</button>
+                        </form>
+                    </td>
+                </tr>
+            <?php endforeach; ?>
+        </table>
+    <?php endif; ?>
+    <p class="help">
+        Kept locally: <?= (int)MANAGE_BACKUP_LOCAL_RETENTION ?>. Older ones are
+        deleted here after each new backup; the manage server keeps its own,
+        longer history.
+    </p>
+</div>
+
+<?php
+$unsupported = [];
+foreach ($capabilities as $type => $capability) {
+    if ($capability['configured'] && !$capability['available']) {
+        $unsupported[] = $type;
+    }
+}
+?>
+<?php if ($unsupported !== []): ?>
+    <div class="flash flash-error">
+        Configured backup targets this server cannot use:
+        <?= e(implode(', ', $unsupported)) ?>. Those uploads will fail.
+    </div>
+<?php endif; ?>
+<?php foreach ($status['errors'] as $line): ?>
+    <div class="flash flash-error"><?= e($line) ?></div>
+<?php endforeach; ?>
+
+<div class="card">
+    <h2 style="margin-top:0">Report status</h2>
+    <p class="help" style="margin-top:0">
+        Sends version, PHP version, free disk space and the time of the last
+        backup to the manage server. Normally an hourly cron job; this is the
+        manual version of it.
+    </p>
+    <form method="post">
+        <?= csrf_field() ?>
+        <input type="hidden" name="action" value="heartbeat">
+        <button type="submit" class="btn-ghost" style="margin:0">Send heartbeat</button>
+    </form>
+</div>
+
+<p class="help">
+    Same operations from the shell:
+    <code>php manage-client/bin/manage-client.php status|check|backup|update|migrate|heartbeat</code>.
+    See <code>docs/SETUP.md</code>.
+</p>
+<?php admin_footer(); ?>

+ 61 - 0
app/after-update.php

@@ -0,0 +1,61 @@
+<?php
+/**
+ * Post-update hook for the manage client (MANAGE_UPDATE_POST_HOOK).
+ *
+ * Runs after every successful deployment, after the release migrations in
+ * migrations/ and only if those succeeded. At this point the new files are
+ * already in place — there is no rollback — so this does housekeeping that is
+ * safe to repeat and never anything that could fail a working installation.
+ *
+ * Deliberately not done here: the per-gallery data migration from
+ * app/migrate.php. It touches every gallery file and stays an operator
+ * decision, announced on the dashboard and run from admin/migrate.php.
+ */
+
+declare(strict_types=1);
+
+/**
+ * @param array $context app_root, instance, from_version, to_version,
+ *                       backup_dir, run_id, migrations
+ */
+function after_update_hook(array $context): void
+{
+    $root = (string)$context['app_root'];
+
+    // 1. Directories a fresh checkout does not have and an update never
+    //    creates by copying, because the release package carries no data.
+    foreach ([$root . '/data', $root . '/data/galleries', $root . '/media'] as $dir) {
+        if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
+            throw new RuntimeException('Could not create directory: ' . $dir);
+        }
+    }
+
+    // 2. The archive worker's lock. If the update landed while a gallery ZIP
+    //    was being built, that PHP process is gone but its lock file is not,
+    //    and every later run would refuse to start. The queue itself is left
+    //    alone — the next visitor kicks it (see app/archive.php).
+    $lock = $root . '/data/archive.lock';
+    if (is_file($lock) && time() - (int)@filemtime($lock) > 300) {
+        @unlink($lock);
+    }
+
+    // 3. Discard compiled bytecode of the files just replaced, for hosts where
+    //    OPcache would otherwise keep serving the old code until it restarts.
+    if (function_exists('opcache_reset') && ini_get('opcache.enable')) {
+        @opcache_reset();
+    }
+
+    // 4. A line per update, so "when did this installation change" is
+    //    answerable without the manage server.
+    @file_put_contents(
+        $root . '/data/update-history.log',
+        sprintf(
+            "%s  %s -> %s  (migrations: %s)\n",
+            date(DATE_ATOM),
+            $context['from_version'] !== '' ? $context['from_version'] : 'unknown',
+            $context['to_version'],
+            $context['migrations'] === [] ? 'none' : implode(', ', $context['migrations']),
+        ),
+        FILE_APPEND | LOCK_EX,
+    );
+}

+ 4 - 44
app/archive.php

@@ -633,60 +633,20 @@ function archive_worker_key(): string
     return (string)$data['key'];
 }
 
-/**
- * URL of worker.php on this installation.
- *
- * site.base_url is preferred when it has been filled in, because it does not
- * depend on the request's Host header. Otherwise the URL is derived from the
- * current request, mapping APP_ROOT against DOCUMENT_ROOT so an app installed in
- * a subdirectory still resolves.
- */
+/** URL of worker.php on this installation. See self_url() in app/bootstrap.php. */
 function archive_worker_url(): ?string
 {
-    $query = '/worker.php?key=' . rawurlencode(archive_worker_key());
-
-    $configured = rtrim((string)config('site.base_url', ''), '/');
-    if ($configured !== '' && !str_contains($configured, 'example.com')) {
-        return $configured . $query;
-    }
-
-    $host = (string)($_SERVER['HTTP_HOST'] ?? '');
-    $root = rtrim(str_replace('\\', '/', (string)($_SERVER['DOCUMENT_ROOT'] ?? '')), '/');
-    $app  = str_replace('\\', '/', APP_ROOT);
-    if ($host === '' || $root === '' || !str_starts_with($app, $root)) {
-        return null;
-    }
-    $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
-    return $scheme . '://' . $host . rtrim(substr($app, strlen($root)), '/') . $query;
+    return self_url('/worker.php?key=' . rawurlencode(archive_worker_key()));
 }
 
 /**
  * Fire a request at worker.php and hang up without waiting for it. The worker
  * sets ignore_user_abort(), so it runs its slice regardless.
- *
- * A timeout is the expected, successful outcome here — it means the request was
- * delivered and the worker is busy with it. A *completed* response is only a
- * success if it is the worker's own 204; anything else (a 404 from a wrong key
- * or a misconfigured base URL) means nothing is running, and saying so lets the
- * caller fall back to doing the work inline instead of silently stalling.
+ * The mechanics are shared with manage-worker.php: see self_dispatch().
  */
 function archive_dispatch(int $timeoutMs = 1000): bool
 {
-    $url = archive_worker_url();
-    if ($url === null || !function_exists('curl_init')) {
-        return false;
-    }
-    $ch = curl_init($url);
-    curl_setopt_array($ch, [
-        CURLOPT_RETURNTRANSFER    => true,
-        CURLOPT_NOSIGNAL          => true,
-        CURLOPT_CONNECTTIMEOUT_MS => $timeoutMs,
-        CURLOPT_TIMEOUT_MS        => $timeoutMs,
-    ]);
-    curl_exec($ch);
-    $errno = curl_errno($ch);
-    $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
-    return $errno === CURLE_OPERATION_TIMEOUTED || ($errno === 0 && $status === 204);
+    return self_dispatch(archive_worker_url(), $timeoutMs);
 }
 
 /**

+ 60 - 0
app/bootstrap.php

@@ -17,6 +17,10 @@ if (!is_file(CONFIG_DIR . '/config.php')) {
 
 $GLOBALS['config'] = require CONFIG_DIR . '/config.php';
 
+// APP_VERSION. Its own file because the release build script rewrites it and
+// the manage client reads it back — see app/version.php.
+require APP_ROOT . '/app/version.php';
+
 date_default_timezone_set(config('site.timezone', 'UTC'));
 
 require APP_ROOT . '/app/storage.php';
@@ -29,6 +33,7 @@ require APP_ROOT . '/app/archive.php';
 require APP_ROOT . '/app/migrate.php';
 require APP_ROOT . '/app/markdown.php';
 require APP_ROOT . '/app/partials.php';
+require APP_ROOT . '/app/manage.php';
 
 /**
  * Read a config value by dot path, e.g. config('s3.bucket').
@@ -96,6 +101,61 @@ function session_boot(): void
     session_start();
 }
 
+/**
+ * URL of a script in this installation, for the server to call itself.
+ *
+ * site.base_url is preferred when it has been filled in, because it does not
+ * depend on the request's Host header. Otherwise the URL is derived from the
+ * current request, mapping APP_ROOT against DOCUMENT_ROOT so an app installed
+ * in a subdirectory still resolves. Null means the URL cannot be determined —
+ * the caller then has to do the work inline instead of dispatching it.
+ */
+function self_url(string $pathAndQuery): ?string
+{
+    $configured = rtrim((string)config('site.base_url', ''), '/');
+    if ($configured !== '' && !str_contains($configured, 'example.com')) {
+        return $configured . $pathAndQuery;
+    }
+
+    $host = (string)($_SERVER['HTTP_HOST'] ?? '');
+    $root = rtrim(str_replace('\\', '/', (string)($_SERVER['DOCUMENT_ROOT'] ?? '')), '/');
+    $app  = str_replace('\\', '/', APP_ROOT);
+    if ($host === '' || $root === '' || !str_starts_with($app, $root)) {
+        return null;
+    }
+    $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
+    return $scheme . '://' . $host . rtrim(substr($app, strlen($root)), '/') . $pathAndQuery;
+}
+
+/**
+ * Fire a request at one of this installation's own background scripts and hang
+ * up without waiting for it. Those scripts set ignore_user_abort(), so they run
+ * on regardless.
+ *
+ * A timeout is the expected, successful outcome: it means the request was
+ * delivered and the script is busy with it. A *completed* response only counts
+ * as success if it is the script's own 204 — anything else (a 404 from a wrong
+ * key or a misconfigured base URL) means nothing is running, and saying so lets
+ * the caller fall back to doing the work inline instead of silently stalling.
+ */
+function self_dispatch(?string $url, int $timeoutMs = 1000): bool
+{
+    if ($url === null || !function_exists('curl_init')) {
+        return false;
+    }
+    $ch = curl_init($url);
+    curl_setopt_array($ch, [
+        CURLOPT_RETURNTRANSFER    => true,
+        CURLOPT_NOSIGNAL          => true,
+        CURLOPT_CONNECTTIMEOUT_MS => $timeoutMs,
+        CURLOPT_TIMEOUT_MS        => $timeoutMs,
+    ]);
+    curl_exec($ch);
+    $errno = curl_errno($ch);
+    $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+    return $errno === CURLE_OPERATION_TIMEOUTED || ($errno === 0 && $status === 204);
+}
+
 /** Redirect and stop. */
 function redirect(string $url): never
 {

+ 193 - 0
app/manage.php

@@ -0,0 +1,193 @@
+<?php
+/**
+ * The backup schedule, for hosting without cron.
+ *
+ * The manage client in manage-client/ can do everything from the command line,
+ * but this application's typical host offers no dependable cron — so the two
+ * recurring jobs are driven by the web instead, the same way gallery archives
+ * are (see app/archive.php):
+ *
+ *   - **backup**, when the newest one is older than
+ *     MANAGE_BACKUP_AUTO_INTERVAL_SECONDS (a week by default),
+ *   - **heartbeat**, hourly, so the manage server can tell a silent
+ *     installation from a healthy one.
+ *
+ * An admin page load past the interval calls manage_kick(), which hands the
+ * work to manage-worker.php in the background and returns immediately. The
+ * backoffice is the trigger rather than the public site because these jobs
+ * exist for the operator, and an installation nobody administers is one nobody
+ * needs a fresh backup of.
+ *
+ * Deliberately not scheduled here:
+ *
+ *   - **Updates.** Never automatic; they overwrite files under a live site and
+ *     have no rollback. Admin -> Maintenance, on purpose.
+ *   - **The update check.** It is a request to the manage server, and doing it
+ *     per page load would put a network round trip in front of the backoffice.
+ *     The Maintenance page checks when it is opened, and the heartbeat response
+ *     carries the same information as a side effect.
+ *
+ * Where real cron does exist, it calls manage-worker.php (or the CLI) on a
+ * timer and this all still holds — the jobs are the same code either way.
+ */
+
+declare(strict_types=1);
+
+/** Whether the manage client is installed and configured on this instance. */
+function manage_available(): bool
+{
+    return is_file(APP_ROOT . '/manage-client/config.php')
+        && is_file(APP_ROOT . '/manage-client/lib/client.php');
+}
+
+/** Load the client library. Safe to call repeatedly. */
+function manage_load(): void
+{
+    require_once APP_ROOT . '/manage-client/lib/client.php';
+}
+
+/**
+ * Marks the last time a kick was dispatched. Its mtime is the only thing read,
+ * so an admin page costs one stat() when nothing is due.
+ */
+function manage_tick_file(): string
+{
+    return DATA_DIR . '/manage.tick';
+}
+
+function manage_state_file(): string
+{
+    return DATA_DIR . '/manage/web-schedule.json';
+}
+
+/** Seconds between heartbeats. */
+function manage_heartbeat_interval(): int
+{
+    return (int)config('manage.heartbeat_interval', 3600);
+}
+
+/**
+ * Which jobs are due right now: 'backup', 'heartbeat', or neither.
+ *
+ * The backup side asks the client rather than deciding itself — the interval
+ * lives in manage-client/config.php, and manageBackupCreateAutomaticIfDue()
+ * measures it against the same backup index the Maintenance page shows.
+ */
+function manage_due(): array
+{
+    if (!manage_available()) {
+        return [];
+    }
+    manage_load();
+
+    $due = [];
+
+    if ((int)MANAGE_BACKUP_AUTO_INTERVAL_SECONDS > 0) {
+        $last = 0;
+        foreach (manageBackupList() as $backup) {
+            // Only backups this schedule made count towards it: a manual one
+            // from the Maintenance page should not postpone the routine.
+            if (in_array($backup['trigger'] ?? '', ['automatic', 'cron'], true)) {
+                $last = max($last, strtotime((string)($backup['created_at'] ?? '')) ?: 0);
+            }
+        }
+        if (time() - $last >= (int)MANAGE_BACKUP_AUTO_INTERVAL_SECONDS) {
+            $due[] = 'backup';
+        }
+    }
+
+    $state = json_read(manage_state_file());
+    if (time() - (int)($state['heartbeat_at'] ?? 0) >= manage_heartbeat_interval()) {
+        $due[] = 'heartbeat';
+    }
+
+    return $due;
+}
+
+/**
+ * Run whatever is due. Returns a line per job for the log; never throws, because
+ * every caller is a page that has something better to do than fail over this.
+ */
+function manage_run_due(): array
+{
+    if (!manage_available()) {
+        return [];
+    }
+    manage_load();
+
+    $done = [];
+    $due = manage_due();
+
+    if (in_array('backup', $due, true)) {
+        try {
+            // Returns null if another look at the clock says it is not due
+            // after all — two workers racing, or a backup made in between.
+            $record = manageBackupCreateAutomaticIfDue();
+            if ($record !== null) {
+                $done[] = 'backup ' . $record['filename'];
+            }
+        } catch (Throwable $e) {
+            manageClientLog('ERROR', 'Scheduled backup failed', ['error' => $e->getMessage()]);
+            $done[] = 'backup failed: ' . $e->getMessage();
+        }
+    }
+
+    if (in_array('heartbeat', $due, true)) {
+        // Records the attempt either way: an unreachable server must not turn
+        // into a heartbeat on every single page load.
+        json_update(manage_state_file(), function (array $state): array {
+            $state['heartbeat_at'] = time();
+            return $state;
+        });
+        $done[] = manageHeartbeatSendQuietly() === null ? 'heartbeat failed' : 'heartbeat';
+    }
+
+    return $done;
+}
+
+/** URL of manage-worker.php on this installation. */
+function manage_worker_url(): ?string
+{
+    return self_url('/manage-worker.php?key=' . rawurlencode(archive_worker_key()));
+}
+
+/**
+ * Called at the end of every admin page. Cheap when nothing is due: one stat()
+ * on the tick file, and the client library is not even loaded.
+ *
+ * The dispatch-then-fall-back-to-inline shape is archive_kick()'s, for the same
+ * reason — a host that blocks outbound HTTP to itself would otherwise never run
+ * these jobs at all. Inline only happens after the page has been flushed to the
+ * operator, so a weekly backup of a few hundred megabytes is never something
+ * they sit and watch.
+ */
+function manage_kick(): void
+{
+    $tick = manage_tick_file();
+    // Long enough that a click-happy session costs nothing, short enough that
+    // a backup which failed to start is retried within the same visit.
+    if (is_file($tick) && time() - (int)filemtime($tick) < 900) {
+        return;
+    }
+    if (!manage_available() || manage_due() === []) {
+        return;
+    }
+    @touch($tick);
+
+    if (!function_exists('fastcgi_finish_request')) {
+        self_dispatch(manage_worker_url(), 200);
+        return;
+    }
+
+    @fastcgi_finish_request();
+    if (self_dispatch(manage_worker_url(), 2000)) {
+        return;
+    }
+    // No self-dispatch on this host. The operator already has the page, so this
+    // process does the work itself.
+    if (session_status() === PHP_SESSION_ACTIVE) {
+        session_write_close();
+    }
+    @set_time_limit(0);
+    manage_run_due();
+}

+ 6 - 0
app/partials.php

@@ -72,6 +72,7 @@ function admin_header(string $title, string $active = ''): void
         <a href="galleries.php" class="<?= $active === 'galleries' ? 'active' : '' ?>">Galleries</a>
         <a href="contact.php" class="<?= $active === 'contact' ? 'active' : '' ?>">Contact</a>
         <a href="settings.php" class="<?= $active === 'settings' ? 'active' : '' ?>">Settings</a>
+        <a href="maintenance.php" class="<?= $active === 'maintenance' ? 'active' : '' ?>">Maintenance</a>
         <a href="../" target="_blank" rel="noopener">View site ↗</a>
         <a href="logout.php">Log out</a>
     </nav>
@@ -83,9 +84,14 @@ function admin_header(string $title, string $active = ''): void
 function admin_footer(): void
 {
     ?></main>
+<footer class="admin-foot"><?= e(APP_VERSION) ?></footer>
 </body>
 </html>
 <?php
+    // Last thing on every admin page: run the scheduled backup if one is due.
+    // Cheap when it is not, and dispatched in the background when it is, so the
+    // operator never waits on it. See app/manage.php.
+    manage_kick();
 }
 
 /**

+ 16 - 0
app/version.php

@@ -0,0 +1,16 @@
+<?php
+/**
+ * The installed software version, single source of truth.
+ *
+ * It is written here by scripts/create-release-zip.sh when a release package is
+ * built, and changes on an installation as a side effect of an update copying
+ * this file over. Nothing writes it at runtime.
+ *
+ * The manage client reads the literal below with a regular expression rather
+ * than by loading the file (see manage-client/config.php), so the value stays
+ * readable even in a request that already defined the constant.
+ */
+
+declare(strict_types=1);
+
+define('APP_VERSION', 'v1.2.0');

+ 12 - 0
assets/site.css

@@ -545,6 +545,18 @@ body.admin { background: #101012; }
 
 .admin-nav .nav-links { flex-wrap: wrap; gap: 1.2rem; }
 
+/* The installed version, on every backoffice page. Small and out of the way:
+   it is the first thing worth knowing when something looks wrong, and of no
+   interest at all the rest of the time. */
+.admin-foot {
+    max-width: 1000px;
+    margin: 0 auto;
+    padding: 0 clamp(1rem, 3vw, 2rem) 2rem;
+    color: var(--muted);
+    font-size: .75rem;
+    letter-spacing: .12em;
+}
+
 .admin-main { max-width: 1000px; margin: 0 auto; padding: 2.5rem clamp(1rem, 3vw, 2rem) 5rem; }
 
 .admin-main h1 { font-weight: 400; font-size: 1.5rem; margin-bottom: 1.6rem; }

+ 12 - 0
config/config.sample.php

@@ -77,4 +77,16 @@ return [
         // build is a few dozen links long.
         'max_chain'      => 500,
     ],
+
+    // ---- Backup & update client -------------------------------------------
+    // Connection and backup settings live in manage-client/config.php; this is
+    // only the part the application itself schedules. Backups run from the
+    // backoffice when the newest one is older than the interval configured
+    // there (a week by default) — see app/manage.php.
+    'manage' => [
+        // Seconds between status reports to the manage server. The report is
+        // one small request and is what makes an installation that has stopped
+        // running visible there, so hourly is the sensible floor.
+        'heartbeat_interval' => 3600,
+    ],
 ];

+ 61 - 3
docs/ARCHITECTURE.md

@@ -16,7 +16,9 @@ gallery/           client gallery viewer, served as /gallery/?g=<slug>
   index.php        password gate, expiry, grid + lightbox
   download.php     redirects to the presigned URL of the gallery's ZIP
 worker.php         background archive builder (self-dispatching, key-protected)
+manage-worker.php  background backup + heartbeat runner (key-protected)
 admin/             backoffice (session-protected)
+  maintenance.php  backup, update, migrations — the manage client's UI
   api.php          JSON API for the uploader (presign / register)
   archive-api.php  JSON API for building an archive on demand
   topics-api.php   JSON API for moving one image into a topic
@@ -33,11 +35,17 @@ app/               library code — blocked by .htaccess
   exif.php         metadata stripping for uploads (JPEG/PNG/WebP containers)
   zip.php          store-only ZIP64 writer
   archive.php      archive build slices, dirty queue, worker dispatch
+  manage.php       backup/heartbeat schedule for hosts without cron
+  after-update.php post-update hook, run by the manage client
+  version.php      APP_VERSION, rewritten when a release is built
   migrate.php      numbered schema migrations + the schema version constant
   csrf.php         CSRF tokens
   partials.php     shared HTML header/footer for public + admin pages
 config/            static config (S3, site) + admin credentials — blocked
 data/              flat-file content: site.json, galleries/<slug>.json — blocked
+manage-client/     update + backup client, config.php holds the instance token
+migrations/        one-time scripts shipped inside a release package
+scripts/           release build script, cron examples
 router.php         local dev only: applies the .htaccess rules under php -S
 ```
 
@@ -316,6 +324,48 @@ inline after `fastcgi_finish_request()` instead, and progress needs one page vie
 per slice. A real cron job hitting `worker.php?key=…` works too and is better
 than either (see SETUP.md).
 
+## Updates and backups (manage-client/, app/manage.php)
+
+The installation talks to a central manage server: it fetches releases from it
+and sends backups to it. The client is vendor code kept unmodified in
+`manage-client/`, so a newer version of it can be dropped in; everything
+project-specific lives outside it — `manage-client/config.php` (paths, backup
+sources, protected paths), `app/after-update.php`, `admin/maintenance.php` and
+the schedule in `app/manage.php`.
+
+**Version.** `app/version.php` defines `APP_VERSION` (`vMAJOR.MINOR.PATCH`) and
+nothing writes it at runtime: `scripts/create-release-zip.sh` writes it into the
+package, and an update changes it as a side effect of copying the file. The
+client reads the literal back with a regular expression rather than by including
+the file, so the value is correct even in the request that just deployed it.
+
+**Update.** A release is a ZIP whose root is the document root. The client
+verifies size and SHA-256 against the manifest, refuses a package that does not
+contain `app/bootstrap.php`, copies every file over the installation while
+saving each replaced one to `data/manage/updates/`, then runs the pending
+scripts in `migrations/` and finally `app/after-update.php`. `config/config.php`,
+`config/credentials.php`, `data/` and `media/` are on the protected list and are
+never written. There is no rollback and no maintenance mode, which is why the
+Maintenance page takes a backup first by default and why updates never happen on
+a timer. Deleted files are not removed — deployment is an overlay, so dropping a
+file is a migration's job.
+
+**Backup.** `data/` and `media/` — the flat-file content and the locally hosted
+images. Not gallery photos: those live in S3, which is their own copy. Not
+`config/`: a backup is uploaded to the manage server and can be downloaded from
+it, so it must not carry the S3 keys or the password hash. There is no restore
+command; the archive is a plain ZIP to unpack over `data/` and `media/`.
+
+**Schedule without cron.** `manage_kick()` runs at the end of every backoffice
+page render, the same trick as `archive_kick()`: one `stat()` on `data/manage.tick`
+when nothing is due, otherwise flush the page and fire a request at
+`manage-worker.php`, falling back to inline work where the host cannot call
+itself. It runs a backup when the newest scheduled one is older than
+`MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` (a week) and a heartbeat hourly, and never
+an update. The release check is not on the schedule either: it is a network
+round trip, so it happens only when the Maintenance page is opened, which keeps
+every other admin page independent of the manage server being reachable.
+
 ## Security model
 
 - **Admin auth**: credentials in `config/credentials.php`
@@ -327,11 +377,19 @@ than either (see SETUP.md).
 - **Gallery access**: bcrypt-hashed gallery passwords; unlock state is
   per-gallery in the session. Expiry is a pure server-side date check —
   expired and nonexistent galleries return the identical 404 page.
+- **Manage client**: `manage-client/config.php` holds an instance token that is
+  equivalent to write access to the backups on the manage server — it is
+  gitignored, kept out of release packages and out of backups, and blocked from
+  the web twice over. `manage-worker.php` is authenticated by the same
+  `data/worker-key.json` key as `worker.php` and does nothing an unauthenticated
+  caller could exploit; `admin/maintenance.php`, which can deploy code, sits
+  behind the admin session and the CSRF token like every other admin page.
 - **Web exposure**: the document root is the project folder. The root
-  `.htaccess` blocks `app/`, `config/`, `data/`, `docs/` (via `mod_rewrite`)
+  `.htaccess` blocks `app/`, `config/`, `data/`, `docs/`, `manage-client/`,
+  `migrations/`, `scripts/` (via `mod_rewrite`)
   and denies dotfiles, `*.json`, `*.md` and config templates (via
-  `FilesMatch`); each of `app/`, `config/`, `data/` also carries a deny-all
-  `.htaccess` as a fallback for hosts without `mod_rewrite`. `media/.htaccess`
+  `FilesMatch`); each of `app/`, `config/`, `data/` and `manage-client/` also
+  carries a deny-all `.htaccess` as a fallback for hosts without `mod_rewrite`. `media/.htaccess`
   serves images only and disables PHP execution. `router.php` reproduces these
   rules for the PHP built-in server during local development.
 - **Input hygiene**: slugs validated by regex before touching the filesystem;

+ 157 - 7
docs/SETUP.md

@@ -113,9 +113,12 @@ Upload the **contents of this folder** into your account's document root
 page, `index.php`, sits directly in the document root — there is no separate
 web-root subfolder to configure.
 
-The application internals (`app/`, `config/`, `data/`, `docs/`) live inside
-the document root but are blocked from the web by the root `.htaccess` (plus a
-deny-all `.htaccess` inside each of `app/`, `config/`, `data/` as a fallback).
+The application internals (`app/`, `config/`, `data/`, `docs/`,
+`manage-client/`, `migrations/`, `scripts/`) live inside the document root but
+are blocked from the web by the root `.htaccess` (plus a deny-all `.htaccess`
+inside each of `app/`, `config/`, `data/` and `manage-client/` as a fallback).
+The manage client is only ever reached through PHP includes — its update and
+backup functions are exposed at `/admin/maintenance.php`, behind the login.
 
 Verify after deploying — each of these must return **403 Forbidden**, never
 their contents:
@@ -123,6 +126,7 @@ their contents:
 - `https://your-domain.com/config/config.php`
 - `https://your-domain.com/config/credentials.php`
 - `https://your-domain.com/data/site.json`
+- `https://your-domain.com/manage-client/config.php`
 
 If they don't, your host ignores `.htaccess` — move `app/`, `config/` and
 `data/` above the document root and adjust the paths, or contact support.
@@ -134,6 +138,9 @@ The PHP process must be able to write to:
 - `data/` (and `data/galleries/`) — flat-file content
 - `media/` — hero + showreel images
 - `config/` — only for the online password change
+- `data/manage/` — backups, update working files (created automatically)
+- the whole document root, if updates are to be deployed through the manage
+  client rather than by FTP
 
 On typical shared hosting (suEXEC/FPM running as your user) this already
 works; otherwise `chmod 755` the directories (or `775`/`777` as a last resort).
@@ -154,16 +161,159 @@ works; otherwise `chmod 755` the directories (or `775`/`777` as a last resort).
    - tomorrow the gallery shows "not available" (expiry working).
 5. Delete the test gallery — the S3 objects are removed as well.
 
-## 5a. Updating an existing installation
+## 5a. Backups and updates (manage client)
 
-The application is a plain file tree, so an update is an upload plus one click.
+`manage-client/` connects the installation to a
+[manage server](https://manage.med0.de) that keeps the backups and hands out
+releases. Once it is configured, a backup is a button and so is an update.
+Everything it does is also available from the shell, and both paths call the
+same code.
 
-1. **Back up `data/`.** It is the whole database — a few hundred kilobytes.
-   Nothing below deletes anything, but there is no undo either.
+Skipping this is a supported choice: without `manage-client/config.php` the
+site runs exactly as before, and the manual route in **5b** stays open.
+
+### One-time setup
+
+1. On the manage server, create an instance and copy the token — it is shown
+   **once**.
+2. On the webhost:
+
+   ```bash
+   cp manage-client/config.sample.php manage-client/config.php
+   ```
+
+   Fill in `MANAGE_INSTANCE` and `MANAGE_TOKEN`. `MANAGE_SERVER_URL` is already
+   set. Nothing else needs changing: backup sources, protected paths, the
+   version file and the post-update hook are configured for this project.
+3. Check it:
+
+   ```bash
+   php manage-client/bin/manage-client.php status
+   ```
+
+   Or open **`/admin/` → Maintenance**, which shows the same thing.
+4. Make the first backup (the button, or `manage-client.php backup`).
+
+`manage-client/config.php` holds the token. It is gitignored, excluded from
+release packages, and never inside a backup.
+
+### What a backup contains
+
+`data/` (galleries, showreel, front page, settings) and `media/` (hero and
+showreel images) — the operational data, roughly a few hundred kilobytes plus
+whatever the local images weigh.
+
+Two deliberate omissions:
+
+- **Gallery photos.** They live in the S3 bucket, which is their own backup;
+  copying gigabytes into a ZIP nightly would achieve nothing.
+- **`config/config.php` and `config/credentials.php`.** A backup is uploaded to
+  the manage server and can be downloaded there, so it must not carry the S3
+  keys or the admin password hash. Keep one copy of those two files somewhere
+  safe by hand — they are short and they change almost never.
+
+There is **no restore command**. A backup is a ZIP: unpack it over `data/` and
+`media/`, and the installation is back.
+
+### The schedule, without cron
+
+Shared hosting rarely has dependable cron, so the backoffice drives the two
+recurring jobs itself — the same mechanism gallery archives already use:
+
+| Job | When | Trigger |
+| --- | --- | --- |
+| Backup | the newest scheduled backup is older than a week | any admin page load past that point |
+| Heartbeat | hourly | the same |
+
+An admin page load past the interval hands the work to `manage-worker.php` in
+the background and returns immediately; the operator never waits on a backup.
+If the host blocks outbound HTTP to itself — which also breaks archive
+building — the job runs inline instead, after the page has been sent. The
+current state is on **Admin → Maintenance → Schedule**.
+
+Two things are deliberately *not* on that schedule:
+
+- **Updates.** Never automatic. See below.
+- **The release check.** It is a request to the manage server, so it happens
+  when the Maintenance page is opened and nowhere else. No page of the
+  backoffice waits on the network to render.
+
+Tuning: `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` in `manage-client/config.php`
+(a week; `0` turns the backup schedule off) and `manage.heartbeat_interval` in
+`config/config.php` (an hour).
+
+If the host *does* offer cron, use it — `scripts/manage-client.cron` has lines
+for both variants: a `curl` at `manage-worker.php?key=…` every 15 minutes (the
+key is in `data/worker-key.json`), or the CLI on an explicit schedule. Both run
+the same code as the web fallback, which then finds nothing due.
+
+### Running an update
+
+**`/admin/` → Maintenance → Deploy update**, with *Create a backup first* left
+ticked. From the shell it is `manage-client.php check` and then
+`manage-client.php update`.
+
+What that does, in order: downloads the release, verifies size and SHA-256,
+copies every file over the installation (saving each replaced file to
+`data/manage/updates/`), runs any migrations from `migrations/`, then runs
+`app/after-update.php`.
+
+What it deliberately does not do:
+
+- **No rollback.** The replaced files are kept for manual recovery, and that
+  is the whole safety net. This is why the backup checkbox is ticked.
+- **No maintenance mode.** The site stays online while files are replaced.
+  Update during a quiet moment.
+- **No deletions.** A file dropped from a release stays behind on the
+  installation; removing it is a job for a migration.
+- **Never touches** `config/`, `data/`, `media/` or `.git/`.
+
+Afterwards the dashboard tells you whether the per-gallery data migration in
+**Admin → Data migration** is outstanding (see 5b, step 3) — that one is
+separate, and stays your decision.
+
+If a migration fails, the update reports it loudly and the files are still
+deployed: fix the cause, then press *Run migrations* on the Maintenance page.
+
+### Versions and building a release
+
+The installed version lives in `app/version.php` as `APP_VERSION`, and nothing
+writes it at runtime: it changes when a release package copies that file over.
+It is shown at the foot of every backoffice page, on the dashboard, and on the
+Maintenance page, and it is what the manage server compares against to decide
+whether an update is available.
+
+The format is semantic versioning with a `v` prefix — `vMAJOR.MINOR.PATCH`,
+currently **v1.2.0**. Both the client and the manage server reject anything
+else, so there are no `-beta` or `-rc` suffixes: patch for fixes, minor for
+features, major for a release that needs manual steps.
+
+For whoever maintains the software rather than the site:
+
+```bash
+./scripts/create-release-zip.sh v1.3.0
+```
+
+It writes the version into `app/version.php`, packs every git-tracked file
+except configuration and content, and prints the SHA-256. Upload the resulting
+`build/releases/foto-portfolio-v1.3.0.zip` under **Releases** on the manage
+server. Full reference: [the client documentation](https://manage.med0.de/client-docs/),
+06_UPDATE_PACKAGING — and `migrations/README.md` for changes that need a
+migration.
+
+## 5b. Updating by hand
+
+Without the manage client — or when you would rather use FTP — the application
+is a plain file tree, so an update is an upload plus one click.
+
+1. **Back up `data/` and `media/`.** They are the whole database — a few
+   hundred kilobytes plus the local images. Nothing below deletes anything, but
+   there is no undo either.
 2. Upload the new files over the old ones. Do **not** upload `config/`,
    `data/` or `media/` — those hold your configuration and content, and are
    never overwritten by an update. New config keys are always optional and read
    with defaults, so an existing `config/config.php` keeps working unchanged.
+   Leave `manage-client/config.php` in place too.
 3. Open **`/admin/` → Migration** and press *Run migration*.
 
    The dashboard shows a banner while anything is outstanding. The step is safe

+ 21 - 0
manage-client/.htaccess

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

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

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

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

@@ -0,0 +1,125 @@
+<?php
+
+// Manage client configuration for the photography portfolio.
+//
+// Copy this file to config.php in the same folder and fill in the three
+// connection values. config.php holds the instance token: it is gitignored,
+// excluded from release packages, and never part of a backup.
+//
+// Every constant has a default in lib/client.php — the values below are the
+// ones this project needs to deviate on, plus the connection block.
+// Reference: https://manage.med0.de/client-docs/ (03_CONFIG_REFERENCE)
+
+// ---------------------------------------------------------------------------
+// Connection
+// ---------------------------------------------------------------------------
+// Base URL of the manage server, no trailing slash, no /api.
+define('MANAGE_SERVER_URL', 'https://manage.med0.de');
+
+// Instance id and token from the manage server (Instances -> create instance).
+// The token is shown exactly once, right after the instance is created.
+define('MANAGE_INSTANCE', '');
+define('MANAGE_TOKEN', '');
+
+// Seconds per HTTP request. Gallery media makes backups large, so the long
+// timeout (upload, package download) is generous.
+define('MANAGE_HTTP_TIMEOUT', 15);
+define('MANAGE_HTTP_TIMEOUT_LONG', 600);
+
+// ---------------------------------------------------------------------------
+// Application layout
+// ---------------------------------------------------------------------------
+// manage-client/ sits in the document root, next to index.php.
+define('MANAGE_APP_ROOT', dirname(__DIR__));
+
+// The installed version lives in app/version.php as APP_VERSION. The file is
+// read with a regular expression, never executed, so the value stays readable
+// even in a process that already loaded the old constant.
+define('MANAGE_VERSION_FILE', MANAGE_APP_ROOT . '/app/version.php');
+define('MANAGE_VERSION_CONSTANT', 'APP_VERSION');
+
+// Working directories, all under data/ — which .htaccess already blocks and
+// .gitignore already excludes.
+define('MANAGE_WORK_DIR', MANAGE_APP_ROOT . '/data/manage/work/');
+define('MANAGE_UPDATE_BACKUP_DIR', MANAGE_APP_ROOT . '/data/manage/updates/');
+define('MANAGE_BACKUP_DIR', MANAGE_APP_ROOT . '/data/manage/backups/');
+define('MANAGE_LOG_FILE', MANAGE_APP_ROOT . '/data/manage/manage-client.log');
+
+// ---------------------------------------------------------------------------
+// Update
+// ---------------------------------------------------------------------------
+// Never overwritten by an update. config/ holds this installation's S3
+// credentials and the admin password hash; data/ and media/ are its content.
+// The sample files in config/ are deliberately not protected, so a release can
+// still ship new defaults for them.
+define('MANAGE_UPDATE_PROTECTED_PATHS', [
+    'config/config.php',
+    'config/credentials.php',
+    'data/',
+    'media/',
+    '.git/',
+    'manage-client/config.php',
+]);
+
+// A package must contain these, otherwise it is not this application and
+// nothing is copied.
+define('MANAGE_UPDATE_SANITY_PATHS', ['app/bootstrap.php']);
+
+// Runs after a successful deployment: creates missing directories, clears the
+// archive worker's stale lock, appends to data/update-history.log.
+define('MANAGE_UPDATE_POST_HOOK', [
+    'file'     => MANAGE_APP_ROOT . '/app/after-update.php',
+    'callback' => 'after_update_hook',
+]);
+
+// Migrations shipped inside the release package (see migrations/README.md).
+// Not to be confused with the per-gallery data migration in admin/migrate.php,
+// which stays an operator decision.
+define('MANAGE_MIGRATIONS_DIR', MANAGE_APP_ROOT . '/migrations');
+define('MANAGE_MIGRATIONS_STATE', MANAGE_APP_ROOT . '/data/manage/migrations.json');
+
+// ---------------------------------------------------------------------------
+// Backup
+// ---------------------------------------------------------------------------
+// Operational data only: the flat-file storage plus the locally hosted images.
+// Gallery photos live in S3 and are not part of this — the bucket is the
+// system of record for those. Application files come from the release package.
+//
+// config/credentials.php and config/config.php stay out on purpose: a backup
+// is uploaded to the manage server and can be downloaded there, so it must not
+// carry the S3 keys or the admin password hash.
+define('MANAGE_BACKUP_SOURCES', [
+    // site.json, archive-queue.json, worker-key.json, login-throttle.json
+    ['as' => 'data', 'glob' => 'data/*.json'],
+    // One JSON file per gallery: titles, topics, image lists, passwords.
+    ['as' => 'data/galleries', 'dir' => 'data/galleries'],
+    // Hero image and showreel images, uploaded through the backoffice.
+    ['as' => 'media', 'dir' => 'media'],
+    // Which release migrations have run — lives under data/manage/, which is
+    // otherwise excluded so the backup directory cannot back itself up.
+    ['as' => 'manage', 'file' => 'data/manage/migrations.json'],
+]);
+
+// No database: this project is flat-file throughout.
+define('MANAGE_BACKUP_DATABASE', null);
+
+// Local archives kept on the instance. They contain JPEGs, so they are large;
+// the manage server keeps the longer history.
+define('MANAGE_BACKUP_LOCAL_RETENTION', 3);
+
+// How old the last backup may get before the backoffice makes a new one, in
+// seconds. This is the schedule on hosting without cron: any admin page load
+// past the interval starts a backup in the background (see app/manage.php).
+// 0 disables it — the right value only when a real cron job runs the CLI.
+define('MANAGE_BACKUP_AUTO_INTERVAL_SECONDS', 604800);
+
+// JPEGs do not compress; the JSON files do, and they are the small part.
+// Leaving deflate on costs little and keeps the JSON side small.
+define('MANAGE_BACKUP_COMPRESS', true);
+
+// Upload every new backup to the manage server.
+define('MANAGE_BACKUP_UPLOAD', true);
+
+// Additional targets besides the manage server: s3, sftp, custom.
+// See https://manage.med0.de/client-docs/ (05_BACKUP_SOURCES).
+define('MANAGE_BACKUP_REMOTE_TARGETS', []);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 52 - 0
manage-worker.php

@@ -0,0 +1,52 @@
+<?php
+/**
+ * Background worker for the scheduled backup and heartbeat.
+ *
+ * Same shape and same trust model as worker.php: the caller is this server
+ * making an HTTP request to itself, so there is no admin session to check and
+ * the key in data/worker-key.json authenticates instead. A wrong or missing key
+ * is indistinguishable from the script not existing.
+ *
+ * Reached in three ways, all of which run the same code:
+ *
+ *   - an admin page load past the interval (manage_kick(), app/manage.php),
+ *   - real cron, if the host has it: curl this URL every 15 minutes,
+ *   - by hand, when testing.
+ *
+ * What it never does is update the software. That is a deliberate act, from
+ * Admin -> Maintenance.
+ */
+require __DIR__ . '/app/bootstrap.php';
+
+if (!hash_equals(archive_worker_key(), (string)($_GET['key'] ?? ''))) {
+    http_response_code(404);
+    exit;
+}
+
+// The dispatcher hung up after a fraction of a second. Without this, PHP would
+// kill this process the moment it noticed the disconnect.
+ignore_user_abort(true);
+@set_time_limit(0);
+
+// Nothing is ever read from the response — the caller is not listening.
+http_response_code(204);
+if (function_exists('fastcgi_finish_request')) {
+    @fastcgi_finish_request();
+}
+
+// One backup at a time. The client refuses concurrent runs itself, but with an
+// exception in the log; taking the lock here keeps that noise out and lets an
+// overlapping worker leave quietly.
+$lock = fopen(DATA_DIR . '/manage.lock', 'c');
+if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
+    exit;
+}
+
+$done = manage_run_due();
+
+flock($lock, LOCK_UN);
+fclose($lock);
+
+if ($done !== []) {
+    manageClientLog('INFO', 'Scheduled run finished', ['jobs' => $done]);
+}

+ 57 - 0
migrations/README.md

@@ -0,0 +1,57 @@
+# Release migrations
+
+One-time scripts that ship inside a release package and are run by the manage
+client right after it deploys the files — before `app/after-update.php`, and
+only once per installation. This is the place for a change that the code cannot
+make lazily: renaming a field every gallery file already has, deleting a file a
+release dropped, rewriting `data/site.json` into a new shape.
+
+Not the same thing as the per-gallery data migration in `app/migrate.php`, which
+the operator runs from **Admin → Data migration**. That one is idempotent
+housekeeping the application does not depend on; these run automatically as part
+of the update and must therefore be safe.
+
+## Writing one
+
+File name: `YYYY-MM-DD-NN-short-description.php`. They run in filename order,
+and the name without `.php` is the id recorded in `data/manage/migrations.json`.
+Renaming a file that already ran makes it run again.
+
+```php
+<?php
+
+return function (array $context): void {
+    $file = $context['app_root'] . '/data/site.json';
+    $data = json_decode((string)file_get_contents($file), true) ?: [];
+
+    if (!array_key_exists('new_field', $data)) {   // idempotent: check first
+        $data['new_field'] = null;
+        file_put_contents($file, json_encode($data, 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`. There is no `pdo` — this project has
+no database.
+
+Rules that matter, because a failed migration stops the run and leaves the new
+files deployed:
+
+- **Idempotent.** A migration that fails halfway is not recorded as applied and
+  runs again from the start.
+- **Not destructive.** Normalise and repair; never delete images or S3 objects.
+- **Use the same locking as the app** where you touch gallery files:
+  `json_update()` from `app/storage.php` if you bootstrap the application, so a
+  concurrent upload cannot lose its write.
+
+## Running them by hand
+
+```bash
+php manage-client/bin/manage-client.php migrate --dry-run   # what is pending
+php manage-client/bin/manage-client.php migrate             # catch up
+```
+
+Also available in the backoffice under **Maintenance**. Full reference:
+[the client documentation](https://manage.med0.de/client-docs/),
+07_POST_UPDATE_HOOKS.

+ 1 - 1
router.php

@@ -11,7 +11,7 @@
  */
 $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '/';
 
-$blocked = preg_match('#^/(app|config|data|docs)/#', $path)   // internals
+$blocked = preg_match('#^/(app|config|data|docs|manage-client|migrations|scripts)/#', $path) // internals
     || preg_match('#(^|/)\.[^/]#', $path)                     // dotfiles/dirs
     || preg_match('#\.(json|md|lock|buf)$#', $path)           // data/docs
     || str_ends_with($path, '.sample.php');                   // config templates

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

@@ -0,0 +1,209 @@
+#!/usr/bin/env bash
+#
+# Builds a release package for a managed project.
+#
+# The product name, the version file and the exclude list are variables at the
+# top instead of hardcoded paths.
+#
+# It ships with the manage client package and is configured here for the
+# photography portfolio. Run it from the project root:
+#
+#     ./scripts/create-release-zip.sh v1.3.0
+#
+# Then upload build/releases/foto-portfolio-v1.3.0.zip in the manage server
+# under "Releases". See docs/SETUP.md.
+#
+# It writes the version into the version file, packs every git-tracked file
+# minus the exclusions, and prints the SHA-256 and size. Upload the resulting
+# ZIP in the manage server under "Releases".
+
+set -euo pipefail
+
+# --- CONFIGURATION ----------------------------------------------------------
+
+# Package name prefix. Must match MANAGE_PACKAGE_PREFIX on the manage server.
+PRODUCT="foto-portfolio"
+
+# File holding the installed version, relative to the project root.
+VERSION_FILE="app/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.
+#
+# The list is short because the file list comes from `git ls-files`: the target
+# installation's config, credentials and content are gitignored and cannot end
+# up in the package in the first place. The entries below name the files that
+# ARE tracked but must not travel — plus the three config files as belt to that
+# braces, in case one is ever force-added.
+#
+# NOT excluded, on purpose:
+#   manage-client/     this is how the update client updates itself
+#   migrations/        release migrations must travel with the release
+#   config/*.sample.php, docs/
+#                      a new install needs them; both blocked by .htaccess
+#   data/.htaccess, media/.htaccess
+#                      the only tracked files under those two directories, and
+#                      the deny-all fallback a fresh install must not be
+#                      missing. An update skips them anyway — data/ and media/
+#                      are in MANAGE_UPDATE_PROTECTED_PATHS.
+EXCLUDES=(
+    "config/config.php"
+    "config/credentials.php"
+    "manage-client/config.php"
+    "data/galleries/"
+    "build/"
+    "scripts/"
+    ".gitignore"
+)
+
+# --- END CONFIGURATION ------------------------------------------------------
+
+usage() {
+    cat <<USAGE
+Usage: $(basename "$0") vX.Y.Z
+
+Builds ${BUILD_DIR}/${PRODUCT}-vX.Y.Z.zip from the git-tracked files of the
+current repository and writes the version into ${VERSION_FILE}.
+USAGE
+}
+
+VERSION="${1:-}"
+
+if [[ -z "$VERSION" || "$VERSION" == "-h" || "$VERSION" == "--help" ]]; then
+    usage
+    exit 1
+fi
+
+if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+    echo "Error: version must look like v1.3.0" >&2
+    exit 1
+fi
+
+for tool in git zip sed awk; do
+    if ! command -v "$tool" >/dev/null 2>&1; then
+        echo "Error: required tool not found: $tool" >&2
+        exit 1
+    fi
+done
+
+if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
+    echo "Error: not inside a git repository. Run this from the project root." >&2
+    exit 1
+fi
+
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "$REPO_ROOT"
+
+if [[ ! -f "$VERSION_FILE" ]]; then
+    echo "Error: version file not found: $VERSION_FILE" >&2
+    exit 1
+fi
+
+# Uncommitted changes would silently stay out of the package, because the file
+# list comes from git. Warn rather than refuse: building from a dirty tree is
+# sometimes deliberate.
+if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then
+    echo "Warning: the working tree has uncommitted changes." >&2
+    echo "         Only committed content is packaged." >&2
+fi
+
+# --- write the version ------------------------------------------------------
+
+write_version() {
+    if [[ -n "$VERSION_CONSTANT" ]]; then
+        # PHP file with a define(). Replace only the version literal.
+        sed -i.bak -E \
+            "s/(define\\(\\s*[\"']${VERSION_CONSTANT}[\"']\\s*,\\s*[\"'])[^\"']*([\"'])/\\1${VERSION}\\2/" \
+            "$VERSION_FILE"
+        rm -f "${VERSION_FILE}.bak"
+    else
+        printf '%s\n' "$VERSION" > "$VERSION_FILE"
+    fi
+}
+
+read_version() {
+    if [[ -n "$VERSION_CONSTANT" ]]; then
+        grep -oE "define\\(\\s*[\"']${VERSION_CONSTANT}[\"']\\s*,\\s*[\"']v[0-9]+\\.[0-9]+\\.[0-9]+[\"']" \
+            "$VERSION_FILE" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1
+    else
+        tr -d '[:space:]' < "$VERSION_FILE"
+    fi
+}
+
+write_version
+
+WRITTEN="$(read_version)"
+if [[ "$WRITTEN" != "$VERSION" ]]; then
+    echo "Error: could not write the version into $VERSION_FILE (found: '${WRITTEN}')." >&2
+    echo "       Check VERSION_CONSTANT and the file's format." >&2
+    exit 1
+fi
+
+echo "Version written to ${VERSION_FILE}: ${VERSION}"
+
+# --- collect the files ------------------------------------------------------
+
+is_excluded() {
+    local path="$1"
+    local pattern
+    for pattern in "${EXCLUDES[@]}"; do
+        if [[ "$pattern" == */ ]]; then
+            [[ "$path" == "${pattern}"* ]] && return 0
+        else
+            [[ "$path" == "$pattern" ]] && return 0
+        fi
+    done
+    return 1
+}
+
+FILE_LIST="$(mktemp)"
+trap 'rm -f "$FILE_LIST"' EXIT
+
+COUNT=0
+while IFS= read -r path; do
+    if is_excluded "$path"; then
+        continue
+    fi
+    [[ -f "$path" ]] || continue
+    printf '%s\n' "$path" >> "$FILE_LIST"
+    COUNT=$((COUNT + 1))
+done < <(git ls-files)
+
+if [[ "$COUNT" -eq 0 ]]; then
+    echo "Error: no files to package." >&2
+    exit 1
+fi
+
+# --- build ------------------------------------------------------------------
+
+mkdir -p "$BUILD_DIR"
+ARCHIVE="${BUILD_DIR}/${PRODUCT}-${VERSION}.zip"
+rm -f "$ARCHIVE"
+
+zip -q -X "$ARCHIVE" -@ < "$FILE_LIST"
+
+if command -v sha256sum >/dev/null 2>&1; then
+    SHA="$(sha256sum "$ARCHIVE" | awk '{print $1}')"
+else
+    SHA="$(shasum -a 256 "$ARCHIVE" | awk '{print $1}')"
+fi
+
+SIZE="$(wc -c < "$ARCHIVE" | tr -d '[:space:]')"
+
+cat <<SUMMARY
+
+Package:  ${ARCHIVE}
+Files:    ${COUNT}
+Size:     ${SIZE} bytes
+SHA-256:  ${SHA}
+
+Next: upload it in the manage server under "Releases" with version ${VERSION}.
+      Checksum and size are recomputed there; the values above are for checking.
+SUMMARY

+ 44 - 0
scripts/manage-client.cron

@@ -0,0 +1,44 @@
+# Backup and update client — cron for installations that have it.
+#
+# Cron is OPTIONAL here. This host is assumed not to have it, so the backoffice
+# drives the schedule itself: any admin page load past the interval starts the
+# backup in the background (see app/manage.php). Everything below is the same
+# work on a dependable timer instead — set it up if the host offers cron, and
+# the web fallback simply never finds anything due.
+#
+# Two ways, pick one.
+
+MAILTO=admin@example.org
+
+# --- A. Over the web (no shell needed; works on hosts whose "cron" is really
+# ---    just a URL fetcher). The key lives in data/worker-key.json.
+#
+# Runs whatever is due: the weekly backup, the hourly heartbeat. Never updates.
+
+*/15 * * * * curl -s -m 10 "https://www.example.com/manage-worker.php?key=YOUR_WORKER_KEY" >/dev/null
+
+# --- B. Over the shell, with the CLI. Same jobs, explicit schedule.
+#
+# Check the PHP binary with `which php`; shared hosts often want a versioned
+# one such as /usr/bin/php8.2. --quiet suppresses normal output — errors still
+# go to STDERR, and cron mails those to MAILTO, which is the point.
+
+PHP=/usr/bin/php
+SITE=/var/www/html/foto-portfolio
+
+# Nightly backup at 03:20: data/ and media/, uploaded to the manage server.
+# Runs at night because it archives every locally hosted image.
+20 3 * * * $PHP $SITE/manage-client/bin/manage-client.php backup --trigger=cron --quiet
+
+# Hourly status report: version, PHP version, free disk space, last backup.
+7 * * * * $PHP $SITE/manage-client/bin/manage-client.php heartbeat --quiet
+
+# Update check on weekday mornings. Exit code 2 means "update available"; cron
+# reports a non-zero exit as mail, which is exactly the notification wanted.
+# The backoffice checks on its own when the Maintenance page is opened, so this
+# line is only worth having if you want to be told without looking.
+0 8 * * 1-5 $PHP $SITE/manage-client/bin/manage-client.php check --quiet
+
+# Updates are deliberately not installed by either variant. `update` overwrites
+# files while the site is live, has no rollback, and may run migrations — that
+# belongs in front of a human, at Admin -> Maintenance.