Forráskód Böngészése

adding optional cli/trigger cron

Medowar 2 hete
szülő
commit
3dba0a8ef8
8 módosított fájl, 504 hozzáadás és 36 törlés
  1. 31 4
      admin/maintenance.php
  2. 1 0
      app/bootstrap.php
  3. 244 0
      app/cron.php
  4. 18 0
      config/config.sample.php
  5. 110 0
      cron.php
  6. 21 2
      docs/ARCHITECTURE.md
  7. 40 4
      docs/SETUP.md
  8. 39 26
      scripts/manage-client.cron

+ 31 - 4
admin/maintenance.php

@@ -167,6 +167,18 @@ foreach ($status['backups'] as $backup) {
 }
 $lastHeartbeat = (int)(json_read(manage_state_file())['heartbeat_at'] ?? 0);
 
+// State of the optional cronjob in app/cron.php. Its schedule lives in the
+// crontab, so the only thing worth showing is whether it is actually running.
+$cron = cron_state();
+$cronNote = null;
+if (!empty($cron['last_run_at'])) {
+    $cronNote = 'Last ' . date('Y-m-d H:i', (int)$cron['last_run_at'])
+        . ', ' . count($cron['jobs'] ?? []) . ' job(s) in ' . (float)($cron['duration'] ?? 0) . 's';
+    if ((int)($cron['remaining'] ?? 0) > 0) {
+        $cronNote .= ', ' . (int)$cron['remaining'] . ' gallery(s) still queued';
+    }
+}
+
 // 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.
@@ -293,12 +305,27 @@ flash_render();
                 <?php endif; ?>
             </td>
         </tr>
+        <tr>
+            <td>Cronjob</td>
+            <td><?= cron_enabled() ? 'enabled' : 'not enabled' ?></td>
+            <td class="help" style="margin:0">
+                <?php if (!cron_enabled()): ?>
+                    Optional. The jobs above and the gallery archives run without it.
+                <?php elseif ($cronNote === null): ?>
+                    Enabled, but <code>cron.php</code> has not run yet — check the crontab line.
+                <?php else: ?>
+                    <?= e($cronNote) ?>.
+                <?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>.
+        If the host does offer cron, one line is enough:
+        <code>cron.php?key=…</code> every five minutes, or the same file from the
+        shell. It drains every due gallery archive and whatever the schedule owes,
+        and the web fallback above simply finds nothing left. The key is in
+        <code>data/worker-key.json</code>; set <code>cron.enabled</code> in
+        <code>config/config.php</code> and see <code>scripts/manage-client.cron</code>.
     </p>
 </div>
 

+ 1 - 0
app/bootstrap.php

@@ -34,6 +34,7 @@ require APP_ROOT . '/app/migrate.php';
 require APP_ROOT . '/app/markdown.php';
 require APP_ROOT . '/app/partials.php';
 require APP_ROOT . '/app/manage.php';
+require APP_ROOT . '/app/cron.php';
 
 /**
  * Read a config value by dot path, e.g. config('s3.bucket').

+ 244 - 0
app/cron.php

@@ -0,0 +1,244 @@
+<?php
+/**
+ * One cron line for every background job, for hosts that do have cron.
+ *
+ * Why this exists next to the web-driven flow
+ * ------------------------------------------
+ * The application assumes no cron and drives itself: a public page render calls
+ * archive_kick() and a chain of worker.php requests builds the gallery archives
+ * (see app/archive.php); an admin page render calls manage_kick() and
+ * manage-worker.php makes the scheduled backup and sends the heartbeat (see
+ * app/manage.php). That works, but it moves one slice or one job per firing and
+ * only while somebody is looking at the site.
+ *
+ * Where real cron exists it can do better, and cron.php is the single entry
+ * point for it: one invocation drains *everything* outstanding — every settled
+ * gallery, not just the first, plus whatever the schedule owes — bounded only by
+ * cron.max_seconds.
+ *
+ * Running alongside the web flow, not instead of it
+ * ------------------------------------------------
+ * Nothing here switches the kicks off. Both drivers stay live and may run at the
+ * same moment, because the locks they already share decide who does what:
+ *
+ *   - data/archive.lock — exactly one process works on an archive. Taken and
+ *     released again *per slice* rather than held across the drain, so a cron
+ *     busy for four minutes never starves the worker chain and never makes an
+ *     admin's "Rebuild now" (which waits only archive_lock(15)) fail.
+ *   - data/manage.lock  — one scheduled backup at a time, the same lock
+ *     manage-worker.php takes. Held elsewhere means somebody else is on it, so
+ *     this run leaves it alone.
+ *   - data/cron.lock    — one cron invocation at a time. A gallery that takes
+ *     longer to archive than the cron interval must not accumulate a queue of
+ *     invocations waiting behind it.
+ *
+ * Off by default: cron.enabled has to be set. A copy of this site restored onto
+ * a staging host brings the crontab with it, and it must not start rebuilding
+ * archives and uploading backups on its own.
+ *
+ * What it deliberately does not do is update the software — same reason as
+ * app/manage.php: that belongs in front of a human, at Admin -> Maintenance.
+ */
+
+declare(strict_types=1);
+
+/** Whether cron.php is allowed to do anything. Opt-in; see the header. */
+function cron_enabled(): bool
+{
+    return (bool)config('cron.enabled', false);
+}
+
+/**
+ * Seconds of work per invocation. Floored at 30 because a budget below one
+ * archive slice would make no progress at all on a large gallery.
+ */
+function cron_max_seconds(): int
+{
+    return max(30, (int)config('cron.max_seconds', 240));
+}
+
+/** One invocation at a time. */
+function cron_lock_file(): string
+{
+    return DATA_DIR . '/cron.lock';
+}
+
+/** What the last run did, for Admin -> Maintenance -> Schedule. */
+function cron_state_file(): string
+{
+    return DATA_DIR . '/cron.json';
+}
+
+/** Last run: ['last_run_at' => int, 'duration' => float, 'jobs' => [], 'remaining' => int]. */
+function cron_state(): array
+{
+    return json_read(cron_state_file());
+}
+
+/**
+ * Galleries still waiting in the archive queue, settled or not. Reported at the
+ * end of a run so a cron that ran out of budget says so.
+ */
+function cron_queued_count(): int
+{
+    $queue = json_read(archive_queue_file());
+    return count($queue['galleries'] ?? []);
+}
+
+/**
+ * The next settled gallery that is not in $skip.
+ *
+ * archive_next_due() always returns the *first* settled entry, which is right
+ * for a worker that only ever does one slice but would hand this loop the same
+ * failing gallery forever. Same settle test, and deliberately a separate
+ * function: archive_next_due() is part of worker.php's contract.
+ *
+ * @param array<string,bool> $skip
+ */
+function cron_next_due(array $skip): ?string
+{
+    $queue  = json_read(archive_queue_file());
+    $settle = (int)config('archive.settle_seconds', 300);
+    foreach ($queue['galleries'] ?? [] as $slug => $dirtyAt) {
+        if (isset($skip[(string)$slug])) {
+            continue;
+        }
+        if (time() - (int)$dirtyAt >= $settle) {
+            return (string)$slug;
+        }
+    }
+    return null;
+}
+
+/**
+ * Run the scheduled backup and heartbeat, if either is due and nobody else is
+ * already on them. The body is manage-worker.php's, sharing its lock.
+ *
+ * @return string[] a line per job, for the log and the cron mail
+ */
+function cron_run_manage(): array
+{
+    if (!manage_available()) {
+        return [];
+    }
+    $lock = fopen(DATA_DIR . '/manage.lock', 'c');
+    if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
+        return [];   // a manage_kick() from the backoffice has it; leave quietly
+    }
+    try {
+        // Never throws: every job inside reports its own failure as a line.
+        return manage_run_due();
+    } finally {
+        flock($lock, LOCK_UN);
+        fclose($lock);
+    }
+}
+
+/**
+ * Build every gallery archive that is due, until the queue is drained or the
+ * budget runs out.
+ *
+ * The budget is checked before each slice, never inside one: archive_run_slice()
+ * stops *starting* photos at archive.step_seconds but still has to finish the one
+ * in flight, so a single large original can overrun the total. A soft ceiling is
+ * the only honest kind here, which is why cron.max_seconds defaults well below a
+ * URL fetcher's timeout.
+ *
+ * @return string[] a line per finished or abandoned archive
+ */
+function cron_run_archives(float $deadline): array
+{
+    $lines    = [];
+    $skip     = [];   // slug => true, for this invocation only
+    $progress = [];   // slug => photos done when last seen, to catch a stall
+    $step     = (float)config('archive.step_seconds', 25);
+    $worked   = false;
+
+    while (microtime(true) < $deadline) {
+        $slug = cron_next_due($skip);
+        if ($slug === null) {
+            break;   // nothing settled; the next tick will look again
+        }
+
+        // Non-blocking: a worker or an admin holding it is already doing this
+        // work, and there is nothing useful to do meanwhile.
+        $lock = archive_lock();
+        if ($lock === null) {
+            $lines[] = 'archive: another worker holds the lock';
+            break;
+        }
+        try {
+            $budget = min($step, $deadline - microtime(true));
+            $result = archive_run_slice($slug, max(1.0, $budget));
+        } finally {
+            flock($lock, LOCK_UN);
+            fclose($lock);
+        }
+        $worked = true;
+
+        if ($result['error'] !== null) {
+            // Left queued on purpose where the slice left it queued: the next
+            // invocation retries, this one stops paying for it.
+            $skip[$slug] = true;
+            $lines[] = 'archive ' . $slug . ' failed: ' . $result['error'];
+            continue;
+        }
+        if ($result['finished']) {
+            $lines[] = 'archive ' . $slug . ' built, ' . $result['total'] . ' photos'
+                . ($result['size'] !== null ? ', ' . human_bytes((int)$result['size']) : '');
+            // Not unqueued when the gallery changed under the build (see
+            // archive_finish): it is due again immediately and the loop rebuilds
+            // it, which is the point of draining.
+            continue;
+        }
+        if ($result['done'] <= ($progress[$slug] ?? -1)) {
+            // A slice that copied nothing and reported no error would spin.
+            $skip[$slug] = true;
+            $lines[] = 'archive ' . $slug . ' made no progress; skipped';
+            continue;
+        }
+        $progress[$slug] = (int)$result['done'];
+    }
+
+    if ($worked) {
+        // The queue is empty, or as empty as this run can make it. Reset the
+        // chain counter the same way worker.php does when a chain ends, so one
+        // left high by an interrupted chain cannot trip archive.max_chain on the
+        // next web-driven build.
+        if (cron_queued_count() === 0) {
+            json_update(archive_queue_file(), function (array $queue): array {
+                $queue['chain'] = 0;
+                return $queue;
+            });
+        }
+        // The next page view can skip its dispatch: whatever it would have
+        // started has just been done.
+        @touch(archive_tick_file());
+    }
+
+    return $lines;
+}
+
+/**
+ * Everything, in order: the cheap bounded schedule first, then the open-ended
+ * archive drain with whatever budget is left.
+ *
+ * @return array{jobs: string[], remaining: int, duration: float}
+ */
+function cron_run_all(?float $budget = null): array
+{
+    $started  = microtime(true);
+    $budget ??= (float)cron_max_seconds();
+    $deadline = $started + $budget;
+
+    $jobs = cron_run_manage();
+    foreach (cron_run_archives($deadline) as $line) {
+        $jobs[] = $line;
+    }
+
+    return [
+        'jobs'      => $jobs,
+        'remaining' => cron_queued_count(),
+        'duration'  => microtime(true) - $started,
+    ];
+}

+ 18 - 0
config/config.sample.php

@@ -89,4 +89,22 @@ return [
         // running visible there, so hourly is the sensible floor.
         'heartbeat_interval' => 3600,
     ],
+
+    // ---- The optional cronjob ---------------------------------------------
+    // Everything above runs without cron: page renders drive the archives and
+    // the schedule themselves. Where the host does offer cron, one line at
+    // cron.php replaces all of it and drains every outstanding job in one go —
+    // see scripts/manage-client.cron. Both drivers may run at the same time;
+    // the locks they share decide who does what.
+    'cron' => [
+        // Off until a cron line actually exists. A copy of this site restored
+        // onto another host brings the crontab with it, and it must not start
+        // rebuilding archives and uploading backups on its own.
+        'enabled'     => false,
+        // Seconds of work per invocation. A slice that has already started a
+        // photo runs to its end, so this is a point to stop at rather than a
+        // hard ceiling — keep it under the timeout of whatever calls it, which
+        // for a URL fetcher is typically 300 s.
+        'max_seconds' => 240,
+    ],
 ];

+ 110 - 0
cron.php

@@ -0,0 +1,110 @@
+<?php
+/**
+ * The one cron entry point. Optional, and off until cron.enabled is set.
+ *
+ * Installed as a single crontab line, either way round — every five minutes, as
+ * a URL fetch or from the shell (see scripts/manage-client.cron for both):
+ *
+ *     curl -s -m 300 "https://www.example.com/cron.php?key=YOUR_WORKER_KEY"
+ *     /usr/bin/php /path/to/cron.php --quiet
+ *
+ * It drains every outstanding background job — all due gallery archives, the
+ * scheduled backup, the heartbeat — and the web-driven flow keeps running
+ * alongside it untouched. The logic, and why that is safe, is in app/cron.php.
+ *
+ * Over HTTP the key in data/worker-key.json authenticates, exactly as it does
+ * for worker.php and manage-worker.php: a wrong or missing key is
+ * indistinguishable from the script not existing. From the shell there is no
+ * request to authenticate — reaching the CLI already means shell access.
+ *
+ * Unlike worker.php, whose caller hangs up immediately, this caller is listening:
+ * cron mails what a job writes. So the response is a plain-text summary, and
+ * --quiet (the manage client's convention) keeps a healthy run silent while
+ * errors still go to STDERR where cron will mail them.
+ */
+
+require __DIR__ . '/app/bootstrap.php';
+
+$cli   = PHP_SAPI === 'cli';
+$quiet = $cli && in_array('--quiet', $argv ?? [], true);
+
+if (!$cli) {
+    if (!hash_equals(archive_worker_key(), (string)($_GET['key'] ?? ''))) {
+        http_response_code(404);
+        exit;
+    }
+    header('Content-Type: text/plain; charset=utf-8');
+    header('Cache-Control: no-store');
+}
+
+/** Report a line the operator should see even under --quiet, and stop. */
+function cron_fail(string $message, bool $cli): never
+{
+    if ($cli) {
+        fwrite(STDERR, $message . "\n");
+        exit(1);
+    }
+    echo $message, "\n";
+    exit;
+}
+
+if (!cron_enabled()) {
+    // A crontab line that can never do anything is worth one mail, not silence.
+    cron_fail("cron is disabled: set 'cron' => ['enabled' => true] in config/config.php", $cli);
+}
+
+// Over HTTP the fetcher may hang up on its own timeout long before the work is
+// done; finish the slice in hand either way rather than leaving a half-written
+// buffer behind. set_time_limit is not honoured everywhere and nothing relies on
+// it — cron.max_seconds is what actually bounds the run.
+ignore_user_abort(true);
+@set_time_limit(0);
+
+// One invocation at a time. A gallery slower to archive than the cron interval
+// would otherwise pile invocations up behind itself.
+$lock = fopen(cron_lock_file(), 'c');
+if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
+    if (!$quiet) {
+        echo "already running; nothing to do\n";
+    }
+    exit;
+}
+
+try {
+    $run = cron_run_all();
+} finally {
+    flock($lock, LOCK_UN);
+    fclose($lock);
+}
+
+json_write(cron_state_file(), [
+    'last_run_at' => time(),
+    'duration'    => round($run['duration'], 1),
+    'jobs'        => $run['jobs'],
+    'remaining'   => $run['remaining'],
+]);
+
+if ($run['jobs'] !== [] && function_exists('manageClientLog')) {
+    manageClientLog('INFO', 'Cron run finished', [
+        'jobs'      => $run['jobs'],
+        'duration'  => round($run['duration'], 1),
+        'remaining' => $run['remaining'],
+    ]);
+}
+
+if ($quiet) {
+    exit;
+}
+
+foreach ($run['jobs'] as $line) {
+    echo $line, "\n";
+}
+if ($run['jobs'] === []) {
+    echo "nothing to do\n";
+}
+if ($run['remaining'] > 0) {
+    // Not handed to worker.php: the next tick picks it up, and the web flow is
+    // still a second driver. A third path would add nothing.
+    echo $run['remaining'], " gallery(s) still queued\n";
+}
+echo 'done in ', round($run['duration'], 1), "s\n";

+ 21 - 2
docs/ARCHITECTURE.md

@@ -36,6 +36,7 @@ app/               library code — blocked by .htaccess
   zip.php          store-only ZIP64 writer
   archive.php      archive build slices, dirty queue, worker dispatch
   manage.php       backup/heartbeat schedule for hosts without cron
+  cron.php         the optional cronjob: drains every background job at once
   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
@@ -321,8 +322,26 @@ lives exactly as long as the queue is non-empty and is bounded by
 `archive.max_chain`; a site-wide `flock` keeps it to one worker. Where the host
 cannot make an HTTP request to itself, the same page-render hook runs a slice
 inline after `fastcgi_finish_request()` instead, and progress needs one page view
-per slice. A real cron job hitting `worker.php?key=…` works too and is better
-than either (see SETUP.md).
+per slice.
+
+**Getting work done with cron.** Where the host has cron, `cron.php` at the
+project root is the one line to install, from the shell or as a URL fetch. It
+drains everything outstanding in a single invocation — every settled gallery, not
+just the first, plus whatever the backup/heartbeat schedule owes — bounded by
+`cron.max_seconds`, and is off until `cron.enabled` is set. The logic is in
+`app/cron.php`.
+
+It runs *alongside* the page-render hooks rather than replacing them, and the
+locks decide who does what. The one subtlety: the drain takes and releases the
+site-wide archive lock **per slice**, where `worker.php` holds it across its whole
+body. Holding it for a four-minute drain would starve the worker chain and make
+an admin's "Rebuild now" — which waits only `archive_lock(15)` — fail in front of
+a human. Releasing between slices means the two drivers interleave slice by
+slice, while the lock still guarantees that only one process ever touches a given
+archive's state file, buffer and multipart upload. The drain also never sleeps out
+a settle window (the next tick will look again), skips a gallery for the rest of
+the invocation if its slice errored or made no progress, and resets the chain
+counter when it empties the queue, exactly as a finished chain does.
 
 ## Updates and backups (manage-client/, app/manage.php)
 

+ 40 - 4
docs/SETUP.md

@@ -102,6 +102,8 @@ Edit `config/config.php`:
 | `s3.url_ttl` | Lifetime of presigned view URLs in seconds |
 | `uploads.thumb_size` | Longest edge of grid thumbnails (browser-generated) |
 | `uploads.resize_quality` | JPEG quality (0.0–1.0) for galleries that cap their upload resolution |
+| `cron.enabled` | `true` only when a real cron line calls `cron.php` — see below |
+| `cron.max_seconds` | Budget for one cron invocation (default 240) |
 
 The default admin login is `admin` / `changeme` — **change it in the admin
 Settings page immediately after the first login.**
@@ -242,10 +244,44 @@ 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.
+### The schedule, with cron
+
+If the host *does* offer cron, one line replaces all of the above:
+`cron.php`. It is the single entry point for every background job — each gallery
+archive that is due, the scheduled backup, the heartbeat — and one invocation
+drains **all** of them rather than moving one slice forward. Two variants, in
+`scripts/manage-client.cron`:
+
+```
+*/5 * * * * curl -s -m 300 "https://www.example.com/cron.php?key=YOUR_WORKER_KEY" >/dev/null
+*/5 * * * * /usr/bin/php /var/www/html/foto-portfolio/cron.php --quiet
+```
+
+The key is in `data/worker-key.json`; over the shell there is no key, since
+reaching the CLI already means shell access.
+
+It is **off until you turn it on**: set `'cron' => ['enabled' => true]` in
+`config/config.php`. A copy of this site restored onto a staging host brings the
+crontab with it and must not start rebuilding archives and uploading backups on
+its own. A disabled `cron.php` says so on STDERR and exits non-zero, so cron
+mails it once instead of failing silently.
+
+Nothing is switched off in exchange. The web-driven flow above stays live and the
+two may run at the same moment, because they share the locks that already exist:
+`data/archive.lock` (one process per archive, taken and released per *slice*, so
+cron never starves the worker chain or an admin's "Rebuild now"),
+`data/manage.lock` (one backup), and `data/cron.lock` (one cron invocation, so a
+gallery slower to archive than the cron interval cannot pile invocations up).
+In practice the web fallback simply finds nothing left to do.
+
+`cron.max_seconds` (240) bounds one invocation. It is a point to stop at rather
+than a hard ceiling: a slice that has already started copying a photo runs to its
+end, so keep it under the timeout of whatever calls it. Work left over is
+reported and picked up by the next tick. The last run is shown on
+**Admin → Maintenance → Schedule**, which is how you confirm cron is really
+firing.
+
+Updates are still never automatic, by either driver.
 
 ### Running an update
 

+ 39 - 26
scripts/manage-client.cron

@@ -1,44 +1,57 @@
-# Backup and update client — cron for installations that have it.
+# Cron for this installation — one line is enough.
 #
-# 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.
+# Cron is OPTIONAL here. This host is assumed not to have it, so the site drives
+# itself: a public page render builds gallery archives in the background (see
+# app/archive.php) and an admin page render runs the backup and heartbeat (see
+# app/manage.php). Everything below is the same work on a dependable timer
+# instead, and both drivers may run at the same time — the locks they share make
+# sure only one process ever works on a given archive.
 #
-# Two ways, pick one.
+# Two prerequisites:
+#
+#   1. set 'cron' => ['enabled' => true] in config/config.php, otherwise
+#      cron.php refuses to run (and says so),
+#   2. for variant A, the worker key from data/worker-key.json.
 
 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.
+# ---    just a URL fetcher).
 #
-# Runs whatever is due: the weekly backup, the hourly heartbeat. Never updates.
+# One invocation drains everything outstanding: every gallery archive that is
+# due, the weekly backup, the hourly heartbeat. Never updates. -m 300 matches
+# cron.max_seconds (240 s) with room to spare; a run that finds nothing to do
+# costs a couple of file reads.
 
-*/15 * * * * curl -s -m 10 "https://www.example.com/manage-worker.php?key=YOUR_WORKER_KEY" >/dev/null
+*/5 * * * * curl -s -m 300 "https://www.example.com/cron.php?key=YOUR_WORKER_KEY" >/dev/null
 
-# --- B. Over the shell, with the CLI. Same jobs, explicit schedule.
+# --- B. Over the shell. Same file, same jobs.
 #
-# 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.
+# Check the PHP binary with `which php`; shared hosts often want a versioned one
+# such as /usr/bin/php8.2. --quiet suppresses normal output — errors still go to
+# STDERR, and cron mails those to MAILTO, which is the point.
 
 PHP=/usr/bin/php
 SITE=/var/www/html/foto-portfolio
 
-# Nightly backup at 03:20: data/ and media/, uploaded to the manage server.
-# Runs at night because it archives every locally hosted image.
-20 3 * * * $PHP $SITE/manage-client/bin/manage-client.php backup --trigger=cron --quiet
+*/5 * * * * $PHP $SITE/cron.php --quiet
 
-# Hourly status report: version, PHP version, free disk space, last backup.
-7 * * * * $PHP $SITE/manage-client/bin/manage-client.php heartbeat --quiet
+# --- Optional extra: the release check.
+#
+# Not background work — a notification. Exit code 2 means "update available", and
+# cron reports a non-zero exit as mail, which is exactly that notification. 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.
 
-# Update check on weekday mornings. Exit code 2 means "update available"; cron
-# reports a non-zero exit as mail, which is exactly the notification wanted.
-# The backoffice checks on its own when the Maintenance page is opened, so this
-# line is only worth having if you want to be told without looking.
 0 8 * * 1-5 $PHP $SITE/manage-client/bin/manage-client.php check --quiet
 
-# Updates are deliberately not installed by either variant. `update` overwrites
-# files while the site is live, has no rollback, and may run migrations — that
-# belongs in front of a human, at Admin -> Maintenance.
+# The individual jobs still have their own entry points, if you would rather give
+# them an explicit schedule than let cron.php decide what is due:
+#
+#   */5 * * * * curl -s "https://www.example.com/worker.php?key=YOUR_WORKER_KEY"
+#   20 3 * * *  $PHP $SITE/manage-client/bin/manage-client.php backup --trigger=cron --quiet
+#   7  * * * *  $PHP $SITE/manage-client/bin/manage-client.php heartbeat --quiet
+#
+# Updates are deliberately installed by none of this. `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.