ソースを参照

fixing and adapting stuff for manage-client

Medowar 1 ヶ月 前
親
コミット
f73b4bba5a

+ 0 - 4
.gitignore

@@ -9,10 +9,6 @@
 # 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
 /data/*
 !/data/.htaccess

+ 1 - 1
.htaccess

@@ -10,7 +10,7 @@ DirectoryIndex index.php
 # behind the admin login.
 <IfModule mod_rewrite.c>
     RewriteEngine On
-    RewriteRule ^(app|config|data|docs|manage-client|migrations|scripts|client-package)/ - [F,L]
+    RewriteRule ^(app|config|data|docs|manage-client|migrations|scripts)/ - [F,L]
 </IfModule>
 
 # Cache static assets

+ 11 - 2
README.md

@@ -26,8 +26,15 @@ No database, no framework, no build step — upload via FTP/SFTP and it runs.
 - **Flat-file storage** — all content lives in JSON files; admin credentials
   live in a PHP config file. Password can be changed online.
 - **Backup & update** — the backoffice can back the installation up and install
-  a new release, both through a central manage server. Same operations from the
-  command line, for cron.
+  a new release, both through a central manage server. Backups run themselves on
+  a weekly schedule that needs no cron; updates never do, and stay one deliberate
+  click. Same operations from the command line where cron does exist.
+
+## Version
+
+`v1.2.0` — semantic versioning, `vMAJOR.MINOR.PATCH`. The number lives in
+[app/version.php](app/version.php); the release script writes it and an update
+carries it along. See [docs/SETUP.md](docs/SETUP.md) §5a.
 
 ## Requirements
 
@@ -66,6 +73,8 @@ gallery/        client galleries (/gallery/?g=<slug>)
 admin/          backoffice
 assets/         css + js
 media/          local images (hero + showreel)
+worker.php      background archive builder
+manage-worker.php  background backup + heartbeat
 app/            PHP library code                   ┐
 config/         config + credentials               │
 data/           flat-file storage                  │ inside the docroot,

+ 0 - 18
admin/index.php

@@ -2,24 +2,6 @@
 require dirname(__DIR__) . '/app/bootstrap.php';
 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();
 $galleries = galleries_all();
 $active = count(array_filter($galleries, fn($g) => !gallery_is_expired($g)));

+ 80 - 3
admin/maintenance.php

@@ -1,7 +1,7 @@
 <?php
 /**
  * Maintenance: backups and software updates, both driven by the manage client
- * in manage-client/ (see client-package/docs/ for the full reference).
+ * in manage-client/ (reference: https://manage.med0.de/client-docs/).
  *
  * The page holds no update or backup logic of its own — every button calls the
  * same documented function the CLI calls, so `php manage-client/bin/manage-client.php
@@ -137,8 +137,41 @@ function maintenance_backup(string $trigger): array
     return $lines;
 }
 
-// Never throws: an unreachable manage server still renders the page.
+/** An interval as something readable: 604800 -> "every 7 days". */
+function maintenance_interval_text(int $seconds): string
+{
+    if ($seconds % 86400 === 0) {
+        $days = intdiv($seconds, 86400);
+        return $days === 1 ? 'daily' : 'every ' . $days . ' days';
+    }
+    if ($seconds % 3600 === 0) {
+        $hours = intdiv($seconds, 3600);
+        return $hours === 1 ? 'hourly' : 'every ' . $hours . ' hours';
+    }
+    return 'every ' . max(1, intdiv($seconds, 60)) . ' minutes';
+}
+
+// The one place that asks the manage server whether a release is waiting: it is
+// a network round trip, so it happens when this page is opened and nowhere else.
+// Never throws — an unreachable server still renders the page.
 $status = manageClientStatus();
+
+// State of the schedule in app/manage.php.
+$autoInterval = (int)MANAGE_BACKUP_AUTO_INTERVAL_SECONDS;
+$due = manage_due();
+$lastAutoBackup = 0;
+foreach ($status['backups'] as $backup) {
+    if (in_array($backup['trigger'] ?? '', ['automatic', 'cron'], true)) {
+        $lastAutoBackup = max($lastAutoBackup, strtotime((string)($backup['created_at'] ?? '')) ?: 0);
+    }
+}
+$lastHeartbeat = (int)(json_read(manage_state_file())['heartbeat_at'] ?? 0);
+
+// An instance whose server has no release yet answers the manifest with 404.
+// That is a normal state — a new project, nothing published — and reads far
+// too much like a broken connection when it is shown as a failed check.
+$noReleaseYet = $status['update_error'] !== null
+    && str_contains($status['update_error'], 'HTTP 404');
 $update = $status['update'];
 $capabilities = manageRemoteCapabilities();
 
@@ -181,7 +214,9 @@ flash_render();
                 <?php endif; ?>
             </td>
             <td class="help" style="margin:0">
-                <?php if ($status['update_error'] !== null): ?>
+                <?php if ($noReleaseYet): ?>
+                    No release has been published on the manage server yet.
+                <?php elseif ($status['update_error'] !== null): ?>
                     Check failed: <?= e($status['update_error']) ?>
                 <?php elseif ($update !== null && $update['available']): ?>
                     Back up first, then deploy.
@@ -225,6 +260,48 @@ flash_render();
     </form>
 </div>
 
+<h2>Schedule</h2>
+<div class="card">
+    <p class="help" style="margin-top:0">
+        This host is assumed to have no cron, so the backoffice drives both jobs:
+        opening any admin page past the interval starts them in the background
+        (<code>manage-worker.php</code>). Updates are never part of that, and the
+        release check runs only when this page is opened.
+    </p>
+    <table>
+        <tr>
+            <td>Automatic backup</td>
+            <td><?= $autoInterval > 0 ? e(maintenance_interval_text($autoInterval)) : 'off' ?></td>
+            <td class="help" style="margin:0">
+                <?php if ($autoInterval <= 0): ?>
+                    <code>MANAGE_BACKUP_AUTO_INTERVAL_SECONDS</code> is 0 — only cron or the button above make backups.
+                <?php elseif (in_array('backup', $due, true)): ?>
+                    Due now — starts on the next admin page load.
+                <?php else: ?>
+                    Next <?= e(date('Y-m-d H:i', $lastAutoBackup + $autoInterval)) ?>.
+                <?php endif; ?>
+            </td>
+        </tr>
+        <tr>
+            <td>Heartbeat</td>
+            <td><?= e(maintenance_interval_text(manage_heartbeat_interval())) ?></td>
+            <td class="help" style="margin:0">
+                <?php if ($lastHeartbeat === 0): ?>
+                    Not sent yet.
+                <?php else: ?>
+                    Last <?= e(date('Y-m-d H:i', $lastHeartbeat)) ?>.
+                <?php endif; ?>
+            </td>
+        </tr>
+    </table>
+    <p class="help">
+        If the host does offer cron, point it at
+        <code>manage-worker.php?key=…</code> every 15 minutes instead — the key
+        is in <code>data/worker-key.json</code>, and the jobs are the same code
+        either way. See <code>scripts/manage-client.cron</code>.
+    </p>
+</div>
+
 <h2>Update</h2>
 <div class="card">
     <?php if ($update !== null && $update['available']): ?>

+ 4 - 44
app/archive.php

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

+ 56 - 0
app/bootstrap.php

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

+ 193 - 0
app/manage.php

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

+ 5 - 0
app/partials.php

@@ -84,9 +84,14 @@ function admin_header(string $title, string $active = ''): void
 function admin_footer(): void
 {
     ?></main>
+<footer class="admin-foot"><?= e(APP_VERSION) ?></footer>
 </body>
 </html>
 <?php
+    // Last thing on every admin page: run the scheduled backup if one is due.
+    // Cheap when it is not, and dispatched in the background when it is, so the
+    // operator never waits on it. See app/manage.php.
+    manage_kick();
 }
 
 /**

+ 1 - 1
app/version.php

@@ -13,4 +13,4 @@
 
 declare(strict_types=1);
 
-define('APP_VERSION', 'v1.0.0');
+define('APP_VERSION', 'v1.2.0');

+ 12 - 0
assets/site.css

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

+ 12 - 0
config/config.sample.php

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

+ 61 - 3
docs/ARCHITECTURE.md

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

+ 44 - 10
docs/SETUP.md

@@ -215,15 +215,37 @@ Two deliberate omissions:
 There is **no restore command**. A backup is a ZIP: unpack it over `data/` and
 `media/`, and the installation is back.
 
-### Regular backups
+### The schedule, without cron
 
-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`.
+Shared hosting rarely has dependable cron, so the backoffice drives the two
+recurring jobs itself — the same mechanism gallery archives already use:
 
-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.
+| Job | When | Trigger |
+| --- | --- | --- |
+| Backup | the newest scheduled backup is older than a week | any admin page load past that point |
+| Heartbeat | hourly | the same |
+
+An admin page load past the interval hands the work to `manage-worker.php` in
+the background and returns immediately; the operator never waits on a backup.
+If the host blocks outbound HTTP to itself — which also breaks archive
+building — the job runs inline instead, after the page has been sent. The
+current state is on **Admin → Maintenance → Schedule**.
+
+Two things are deliberately *not* on that schedule:
+
+- **Updates.** Never automatic. See below.
+- **The release check.** It is a request to the manage server, so it happens
+  when the Maintenance page is opened and nowhere else. No page of the
+  backoffice waits on the network to render.
+
+Tuning: `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` in `manage-client/config.php`
+(a week; `0` turns the backup schedule off) and `manage.heartbeat_interval` in
+`config/config.php` (an hour).
+
+If the host *does* offer cron, use it — `scripts/manage-client.cron` has lines
+for both variants: a `curl` at `manage-worker.php?key=…` every 15 minutes (the
+key is in `data/worker-key.json`), or the CLI on an explicit schedule. Both run
+the same code as the web fallback, which then finds nothing due.
 
 ### Running an update
 
@@ -253,7 +275,18 @@ 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
+### Versions and building a release
+
+The installed version lives in `app/version.php` as `APP_VERSION`, and nothing
+writes it at runtime: it changes when a release package copies that file over.
+It is shown at the foot of every backoffice page, on the dashboard, and on the
+Maintenance page, and it is what the manage server compares against to decide
+whether an update is available.
+
+The format is semantic versioning with a `v` prefix — `vMAJOR.MINOR.PATCH`,
+currently **v1.2.0**. Both the client and the manage server reject anything
+else, so there are no `-beta` or `-rc` suffixes: patch for fixes, minor for
+features, major for a release that needs manual steps.
 
 For whoever maintains the software rather than the site:
 
@@ -264,8 +297,9 @@ For whoever maintains the software rather than the site:
 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.
+server. Full reference: [the client documentation](https://manage.med0.de/client-docs/),
+06_UPDATE_PACKAGING — and `migrations/README.md` for changes that need a
+migration.
 
 ## 5b. Updating by hand
 

+ 7 - 6
manage-client/config.sample.php

@@ -8,7 +8,7 @@
 //
 // 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
+// Reference: https://manage.med0.de/client-docs/ (03_CONFIG_REFERENCE)
 
 // ---------------------------------------------------------------------------
 // Connection
@@ -107,10 +107,11 @@ define('MANAGE_BACKUP_DATABASE', null);
 // 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);
+// How old the last backup may get before the backoffice makes a new one, in
+// seconds. This is the schedule on hosting without cron: any admin page load
+// past the interval starts a backup in the background (see app/manage.php).
+// 0 disables it — the right value only when a real cron job runs the CLI.
+define('MANAGE_BACKUP_AUTO_INTERVAL_SECONDS', 604800);
 
 // JPEGs do not compress; the JSON files do, and they are the small part.
 // Leaving deflate on costs little and keeps the JSON side small.
@@ -120,5 +121,5 @@ define('MANAGE_BACKUP_COMPRESS', true);
 define('MANAGE_BACKUP_UPLOAD', true);
 
 // Additional targets besides the manage server: s3, sftp, custom.
-// See client-package/docs/05_BACKUP_SOURCES.md.
+// See https://manage.med0.de/client-docs/ (05_BACKUP_SOURCES).
 define('MANAGE_BACKUP_REMOTE_TARGETS', []);

+ 52 - 0
manage-worker.php

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

+ 2 - 1
migrations/README.md

@@ -53,4 +53,5 @@ 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`.
+[the client documentation](https://manage.med0.de/client-docs/),
+07_POST_UPDATE_HOOKS.

+ 1 - 1
router.php

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

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

@@ -60,7 +60,6 @@ EXCLUDES=(
     "data/galleries/"
     "build/"
     "scripts/"
-    "client-package/"
     ".gitignore"
 )
 

+ 25 - 13
scripts/manage-client.cron

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