Jelajahi Sumber

implementing manage client

Medowar 1 bulan lalu
induk
melakukan
54f924c604

+ 11 - 0
.gitignore

@@ -2,6 +2,17 @@
 /config/config.php
 /config/config.php
 /config/credentials.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/
+
+# The manage client package as delivered: source for manage-client/, not part
+# of the site. Kept out of the repo and out of release ZIPs.
+/client-package/
+
 # Runtime data
 # Runtime data
 /data/*
 /data/*
 !/data/.htaccess
 !/data/.htaccess

+ 4 - 1
.htaccess

@@ -5,9 +5,12 @@ DirectoryIndex index.php
 
 
 # Primary protection: block the internal directories outright. This works even
 # Primary protection: block the internal directories outright. This works even
 # on hosts that ignore the per-directory .htaccess files in app/, config/, data/.
 # 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>
 <IfModule mod_rewrite.c>
     RewriteEngine On
     RewriteEngine On
-    RewriteRule ^(app|config|data|docs)/ - [F,L]
+    RewriteRule ^(app|config|data|docs|manage-client|migrations|scripts|client-package)/ - [F,L]
 </IfModule>
 </IfModule>
 
 
 # Cache static assets
 # Cache static assets

+ 17 - 11
README.md

@@ -25,6 +25,9 @@ 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.
   strip EXIF metadata (camera, lens, timestamps, GPS) from what it stores.
 - **Flat-file storage** — all content lives in JSON files; admin credentials
 - **Flat-file storage** — all content lives in JSON files; admin credentials
   live in a PHP config file. Password can be changed online.
   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. Same operations from the
+  command line, for cron.
 
 
 ## Requirements
 ## Requirements
 
 
@@ -47,7 +50,7 @@ first** (Settings).
 ## Documentation
 ## Documentation
 
 
 - [docs/SETUP.md](docs/SETUP.md) — deployment, Hetzner bucket + CORS setup,
 - [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/ADMIN-GUIDE.md](docs/ADMIN-GUIDE.md) — using the backoffice
 - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how it works inside
 - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how it works inside
 
 
@@ -57,15 +60,18 @@ Upload the contents of this folder straight into your document root —
 `index.php` is the home page.
 `index.php` is the home page.
 
 
 ```
 ```
-index.php  landing page              ← document root
+index.php       landing page                        ← document root
 showreel.php
 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)
+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)
 ```
 ```

+ 22 - 0
admin/index.php

@@ -2,6 +2,24 @@
 require dirname(__DIR__) . '/app/bootstrap.php';
 require dirname(__DIR__) . '/app/bootstrap.php';
 auth_require();
 auth_require();
 
 
+// Hosting without cron: the client creates a backup once
+// MANAGE_BACKUP_AUTO_INTERVAL_SECONDS has passed and returns immediately
+// otherwise, so this is the dashboard's cost for having backups at all. Off by
+// default (interval 0) because cron is the better place for it — see
+// manage-client/config.sample.php.
+//
+// Only with a config.php present: without one the client would fall back to its
+// own defaults and start writing weekly backups nobody asked for. A backup that
+// fails must never cost the operator the dashboard.
+if (is_file(APP_ROOT . '/manage-client/config.php')) {
+    require_once APP_ROOT . '/manage-client/lib/client.php';
+    try {
+        manageBackupCreateAutomaticIfDue();
+    } catch (Throwable $e) {
+        error_log('Automatic backup failed: ' . $e->getMessage());
+    }
+}
+
 $site = site_get();
 $site = site_get();
 $galleries = galleries_all();
 $galleries = galleries_all();
 $active = count(array_filter($galleries, fn($g) => !gallery_is_expired($g)));
 $active = count(array_filter($galleries, fn($g) => !gallery_is_expired($g)));
@@ -39,6 +57,10 @@ flash_render();
         <tr><td>Data format</td>
         <tr><td>Data format</td>
             <td><?= $pending === [] ? 'up to date (v' . (int)SCHEMA_VERSION . ')' : count($pending) . ' pending' ?></td>
             <td><?= $pending === [] ? 'up to date (v' . (int)SCHEMA_VERSION . ')' : count($pending) . ' pending' ?></td>
             <td><a href="migrate.php">Migration →</a></td></tr>
             <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>
     </table>
 </div>
 </div>
 <p class="help">
 <p class="help">

+ 370 - 0
admin/maintenance.php

@@ -0,0 +1,370 @@
+<?php
+/**
+ * Maintenance: backups and software updates, both driven by the manage client
+ * in manage-client/ (see client-package/docs/ for the full reference).
+ *
+ * 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;
+}
+
+// Never throws: an unreachable manage server still renders the page.
+$status = manageClientStatus();
+$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 ($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>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 - 0
app/bootstrap.php

@@ -17,6 +17,10 @@ if (!is_file(CONFIG_DIR . '/config.php')) {
 
 
 $GLOBALS['config'] = require 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'));
 date_default_timezone_set(config('site.timezone', 'UTC'));
 
 
 require APP_ROOT . '/app/storage.php';
 require APP_ROOT . '/app/storage.php';

+ 1 - 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="galleries.php" class="<?= $active === 'galleries' ? 'active' : '' ?>">Galleries</a>
         <a href="contact.php" class="<?= $active === 'contact' ? 'active' : '' ?>">Contact</a>
         <a href="contact.php" class="<?= $active === 'contact' ? 'active' : '' ?>">Contact</a>
         <a href="settings.php" class="<?= $active === 'settings' ? 'active' : '' ?>">Settings</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="../" target="_blank" rel="noopener">View site ↗</a>
         <a href="logout.php">Log out</a>
         <a href="logout.php">Log out</a>
     </nav>
     </nav>

+ 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.0.0');

+ 123 - 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
 page, `index.php`, sits directly in the document root — there is no separate
 web-root subfolder to configure.
 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
 Verify after deploying — each of these must return **403 Forbidden**, never
 their contents:
 their contents:
@@ -123,6 +126,7 @@ their contents:
 - `https://your-domain.com/config/config.php`
 - `https://your-domain.com/config/config.php`
 - `https://your-domain.com/config/credentials.php`
 - `https://your-domain.com/config/credentials.php`
 - `https://your-domain.com/data/site.json`
 - `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
 If they don't, your host ignores `.htaccess` — move `app/`, `config/` and
 `data/` above the document root and adjust the paths, or contact support.
 `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
 - `data/` (and `data/galleries/`) — flat-file content
 - `media/` — hero + showreel images
 - `media/` — hero + showreel images
 - `config/` — only for the online password change
 - `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
 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).
 works; otherwise `chmod 755` the directories (or `775`/`777` as a last resort).
@@ -154,16 +161,125 @@ works; otherwise `chmod 755` the directories (or `775`/`777` as a last resort).
    - tomorrow the gallery shows "not available" (expiry working).
    - tomorrow the gallery shows "not available" (expiry working).
 5. Delete the test gallery — the S3 objects are removed as well.
 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.
+
+### Regular backups
+
+Cron is the way; `scripts/manage-client.cron` has ready-made lines for nightly
+backup, hourly heartbeat and a weekday update check. Adjust the two paths and
+add them with `crontab -e`.
+
+Hosting without cron: set `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` in
+`manage-client/config.php` to `604800`, and the admin dashboard creates the
+weekly backup itself the next time it is opened. It is `0` (off) by default.
+
+### 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.
+
+### Building a release
+
+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: `client-package/docs/06_UPDATE_PACKAGING.md`, 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/`,
 2. Upload the new files over the old ones. Do **not** upload `config/`,
    `data/` or `media/` — those hold your configuration and content, and are
    `data/` or `media/` — those hold your configuration and content, and are
    never overwritten by an update. New config keys are always optional and read
    never overwritten by an update. New config keys are always optional and read
    with defaults, so an existing `config/config.php` keeps working unchanged.
    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*.
 3. Open **`/admin/` → Migration** and press *Run migration*.
 
 
    The dashboard shows a banner while anything is outstanding. The step is safe
    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);
+}

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

@@ -0,0 +1,124 @@
+<?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: client-package/docs/03_CONFIG_REFERENCE.md
+
+// ---------------------------------------------------------------------------
+// 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);
+
+// Interval for manageBackupCreateAutomaticIfDue(), called from the admin
+// dashboard. 0 disables it — the right value when cron runs the CLI instead.
+// On hosting without cron, set 604800 (weekly) and let the dashboard do it.
+define('MANAGE_BACKUP_AUTO_INTERVAL_SECONDS', 0);
+
+// 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 client-package/docs/05_BACKUP_SOURCES.md.
+define('MANAGE_BACKUP_REMOTE_TARGETS', []);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 56 - 0
migrations/README.md

@@ -0,0 +1,56 @@
+# 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:
+`client-package/docs/07_POST_UPDATE_HOOKS.md`.

+ 1 - 1
router.php

@@ -11,7 +11,7 @@
  */
  */
 $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '/';
 $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|client-package)/#', $path) // internals
     || preg_match('#(^|/)\.[^/]#', $path)                     // dotfiles/dirs
     || preg_match('#(^|/)\.[^/]#', $path)                     // dotfiles/dirs
     || preg_match('#\.(json|md|lock|buf)$#', $path)           // data/docs
     || preg_match('#\.(json|md|lock|buf)$#', $path)           // data/docs
     || str_ends_with($path, '.sample.php');                   // config templates
     || str_ends_with($path, '.sample.php');                   // config templates

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

@@ -0,0 +1,210 @@
+#!/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/"
+    "client-package/"
+    ".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

+ 32 - 0
scripts/manage-client.cron

@@ -0,0 +1,32 @@
+# Backup and update client — crontab lines for this installation.
+#
+# Adjust the two paths, then add them with `crontab -e`, or paste them into the
+# hosting panel's cron form. 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 whole point of running it this way.
+
+MAILTO=admin@example.org
+
+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.
+0 8 * * 1-5 $PHP $SITE/manage-client/bin/manage-client.php check --quiet
+
+# Updates are deliberately not installed here. `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.
+#
+# Without cron at all: set MANAGE_BACKUP_AUTO_INTERVAL_SECONDS in
+# manage-client/config.php to 604800, and the admin dashboard makes the weekly
+# backup itself the next time it is opened.