feat(gallery): reveal mode — hide gallery from guests until reveal (#838) (#856)

* feat(gallery): reveal mode — hide gallery from guests until reveal (#838)

Guests can upload during the event but see no photos until the host
reveals the gallery, manually ("Reveal now") or at a scheduled time.

- migration 165: events.reveal_mode / reveal_at / revealed_at. Effective
  visibility is computed at REQUEST time (reveal_at <= now opens the
  gate exactly on schedule); the minutely scheduler only stamps
  revealed_at durably and emits a gallery.revealed workflow trigger
- server-side enforcement in gallery.js: /photos returns the event
  shell with photos: [] + hidden_until_reveal for plain guests;
  image/download/stats endpoints 403 with GALLERY_HIDDEN (photo IDs are
  sequential — listing-only gating would be probeable); feedback-summary
  gated too. Slideshow tokens (surprise beamer), client access and the
  admin preview bypass; the guest upload route stays open
- admin: reveal toggle + optional scheduled datetime next to the guest
  upload settings, status line and "Reveal now" button on the overview;
  re-enabling the toggle clears revealed_at so a gallery can re-hide
- guest UI: upload-only view (hero, friendly message, scheduled time,
  upload button) for every layout; i18n for all 8 locales
- timestamps written as ISO strings — the SQLite driver stringifies raw
  Date objects into garbage; ISO round-trips on both engines
- 14 integration tests over minted gallery/slideshow/client/admin tokens

* fix(gallery): reveal/re-arm semantics + upload button i18n key (#838)

- "Reveal now" also clears a pending reveal_at: the schedule is
  consumed, so the full-form admin save can't accidentally re-hide a
  revealed gallery with a stale future date
- setting a FUTURE reveal_at on a revealed gallery re-arms hiding —
  the one intentional way to re-hide without double-toggling the mode
- guest upload button uses the existing upload.uploadPhotos key
  (gallery.uploadPhotos never existed; the button showed EN everywhere)

* fix(gallery): close reveal bypasses from review round 1 (#838)

- the hero-derivative route and the secure-images token-mint +
  secure-download routes are now reveal-gated: hero serves a 1920px
  derivative of ANY sequential photo id and secure tokens fetch
  originals — both were open bypasses while hidden. blockHiddenGallery
  moved to utils/revealMode.js and shared
- customer-portal tokens (via:'customer', no accessLevel) now bypass
  reveal mode — they are the host/customer, not a guest, and were
  getting the upload-only view
- an open hidden guest view refetches exactly at reveal_at plus a 60s
  fallback poll, so the gallery appears without a manual reload
- gallery.revealed added to the workflow editor's trigger picker so
  the advertised notification hook is reachable in the UI
- migration 165 guards each column independently (partial-state safe)

* fix(gallery): reveal round 2 — remaining bypass surfaces + lifecycle edges (#838)

- legacy /api/images router reveal-gated (view, secure-token + signed-url
  minting), and the signed-URL SERVE path re-checks hidden state via a
  backward-compatible bypass flag in the token payload
- secure-image tokens record revealBypass at mint and are re-validated
  at serve time — a re-hide kills in-flight guest tokens within the
  request, while slideshow/client tokens keep working
- OG metadata and the unauthenticated /og cover fall back to the brand
  logo / 404 while hidden — no hero-photo spoiler for social crawlers
- photo-feedback GET/POST reveal-gated (sequential ids were enumerable);
  /my-feedback returns the empty back-compat shape (rows leak filename +
  storage path)
- the reveal scheduler skips drafts — no premature stamp/notification
  for unpublished galleries
- emitWorkflowEvent gains an additive dedupSuffix; both reveal emitters
  pass the reveal timestamp so a re-hidden gallery's second reveal
  fires workflows again instead of deduping into silence

* fix(gallery): reveal round 3 — schedule consumption + two-way client sync (#838)

- the scheduler now consumes reveal_at when stamping (matching "Reveal
  now"), and re-arming via a partial API update clears a stale PAST
  schedule — previously {reveal_mode:true} without reveal_at could
  instantly re-open the gate through the leftover date
- /photos exposes reveal_armed so an open VISIBLE gallery keeps a 60s
  poll while the mode is on — a re-hide now propagates to open clients
  in both directions, not just hidden→visible

Codex round-3 claim about timestamp-without-timezone drift on non-UTC
Postgres was verified FALSE: knex's table.timestamp() creates
timestamptz on PG (confirmed via information_schema on a live install),
which stores absolute instants regardless of server TZ.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-07-22 20:59:59 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 3d6c9848dc
commit 2f05fcc39d
29 changed files with 1108 additions and 24 deletions
@@ -0,0 +1,44 @@
/**
* Reveal mode (#838): hide the gallery from guests until a manual or
* scheduled reveal — guests can still upload, the host/admin/slideshow see
* everything.
*
* - events.reveal_mode: the per-event toggle (only meaningful together with
* allow_user_uploads; off by default so nothing changes for existing events)
* - events.reveal_at: optional scheduled reveal time. Effective visibility is
* computed at REQUEST time (reveal_at <= now opens the gate even before the
* scheduler runs), the minutely scheduler only stamps revealed_at durably.
* - events.revealed_at: set by "Reveal now" or the scheduler; NULL while
* hidden. Re-enabling reveal_mode clears it (re-hide).
*/
// Each column guarded independently: a partially applied prior run (or a
// fork that added one of them) must not leave the others missing — the
// routes select all three.
exports.up = async function (knex) {
if (!(await knex.schema.hasColumn('events', 'reveal_mode'))) {
await knex.schema.alterTable('events', (table) => {
table.boolean('reveal_mode').defaultTo(false);
});
}
if (!(await knex.schema.hasColumn('events', 'reveal_at'))) {
await knex.schema.alterTable('events', (table) => {
table.timestamp('reveal_at').nullable();
});
}
if (!(await knex.schema.hasColumn('events', 'revealed_at'))) {
await knex.schema.alterTable('events', (table) => {
table.timestamp('revealed_at').nullable();
});
}
};
exports.down = async function (knex) {
for (const column of ['revealed_at', 'reveal_at', 'reveal_mode']) {
if (await knex.schema.hasColumn('events', column)) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn(column);
});
}
}
};