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
+88
View File
@@ -1177,6 +1177,9 @@ module.exports = (router) => {
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(),
// Reveal mode (#838): hide the gallery from guests until reveal.
body('reveal_mode').optional().isBoolean(),
body('reveal_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
// Migration 143 — per-event reminder overrides. All three are
// optional; nullable values are accepted so admins can clear an
// override (e.g. drop a custom offset back to the global default).
@@ -1489,6 +1492,39 @@ module.exports = (router) => {
}
}
// Reveal mode (#838). Turning the toggle ON from off clears
// revealed_at, so a gallery can be re-hidden after a reveal;
// reveal_at accepts null/'' to drop a schedule.
if (Object.prototype.hasOwnProperty.call(updates, 'reveal_mode')) {
const nextRevealMode = parseBooleanInput(updates.reveal_mode, false);
updates.reveal_mode = formatBoolean(nextRevealMode);
const wasOn = event.reveal_mode === true || event.reveal_mode === 1 || event.reveal_mode === '1';
if (nextRevealMode && !wasOn) {
updates.revealed_at = null;
// A stale PAST schedule from a previous cycle would instantly
// re-open the gate on re-arm. Only bites partial API updates —
// isGalleryHidden() with the stamp cleared tells us whether the
// stored schedule still hides anything.
const { isGalleryHidden } = require('../../utils/revealMode');
if (!Object.prototype.hasOwnProperty.call(updates, 'reveal_at')
&& event.reveal_at
&& !isGalleryHidden({ reveal_mode: true, revealed_at: null, reveal_at: event.reveal_at })) {
updates.reveal_at = null;
}
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'reveal_at')) {
// ISO string, not a Date object — the SQLite driver stringifies raw
// Dates uselessly; ISO round-trips on both engines.
updates.reveal_at = updates.reveal_at ? new Date(updates.reveal_at).toISOString() : null;
// Scheduling a FUTURE reveal on an already-revealed gallery re-arms
// hiding — that's the only way this state can be reached, since
// "Reveal now" and the scheduler both clear/consume the schedule.
if (updates.reveal_at && new Date(updates.reveal_at) > new Date() && event.revealed_at) {
updates.revealed_at = null;
}
}
// Handle client access fields (#172)
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
@@ -1543,6 +1579,58 @@ module.exports = (router) => {
});
// Delete event
// Reveal now (#838): stamp revealed_at so the gallery opens for guests
// immediately. Idempotent — revealing an already-revealed event no-ops.
router.post('/:id/reveal', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const isOn = event.reveal_mode === true || event.reveal_mode === 1 || event.reveal_mode === '1';
if (!isOn) {
return res.status(400).json({ error: 'Reveal mode is not enabled for this event' });
}
const now = new Date().toISOString();
// Also clear a pending schedule — "reveal now" makes it obsolete, and
// a stale future reveal_at would re-arm hiding on the next form save.
const stamped = await db('events')
.where('id', id)
.whereNull('revealed_at')
.update({ revealed_at: now, reveal_at: null });
if (stamped === 1) {
await logActivity('gallery_revealed', { scheduled: false }, id, {
type: 'admin', id: req.admin.id, name: req.admin.username,
});
try {
await require('../../services/workflows').emitWorkflowEvent('gallery.revealed', {
entityType: 'event',
entityId: parseInt(id, 10),
dedupSuffix: String(new Date(now).getTime()),
payload: {
eventId: parseInt(id, 10),
slug: event.slug,
eventName: event.event_name,
revealedAt: now,
scheduled: false,
},
});
} catch (e) {
logger.warn('Failed to emit gallery.revealed workflow event', { eventId: id, error: e.message });
}
}
const fresh = await db('events').where('id', id).first();
res.json({ message: 'Gallery revealed', revealed_at: fresh.revealed_at });
} catch (error) {
logger.error('Failed to reveal gallery:', error);
res.status(500).json({ error: 'Failed to reveal gallery' });
}
});
router.delete('/:id', adminAuth, requirePermission('events.delete'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
+35 -6
View File
@@ -28,6 +28,7 @@ const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
const { isGalleryHidden, guestBlockedByReveal, blockHiddenGallery } = require('../utils/revealMode');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
const downloadZipService = require('../services/downloadZipService');
@@ -205,6 +206,9 @@ router.get('/:slug/info', async (req, res) => {
'share_token',
'allow_downloads',
'allow_user_uploads',
'reveal_mode',
'reveal_at',
'revealed_at',
'disable_right_click',
'watermark_downloads',
'watermark_text',
@@ -275,6 +279,10 @@ router.get('/:slug/info', async (req, res) => {
color_theme: event.color_theme,
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1',
// Reveal mode (#838): effective hidden state (computed, time-exact) so
// the landing page can hint at the reveal before login too.
hidden_until_reveal: isGalleryHidden(event),
reveal_at: isGalleryHidden(event) ? (event.reveal_at || null) : null,
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
watermark_text: event.watermark_text,
@@ -622,8 +630,15 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder);
}
// Reveal mode (#838): while the gallery is hidden, plain guests get
// the event shell with an empty photo/category set plus the
// hidden_until_reveal flag — the frontend renders the upload-only view
// from it. Slideshow, client access and the admin preview bypass
// (guestBlockedByReveal). Enforced here, not just in the UI.
const hiddenForGuest = guestBlockedByReveal(req);
// Execute the query
let photos = await photosQuery;
let photos = hiddenForGuest ? [] : await photosQuery;
// Apply filtering if requested (supports global stats + per-guest interactions)
if (filter) {
@@ -749,7 +764,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// Get actual categories used by photos in this event
// This includes both global categories and event-specific ones
const usedCategoryIds = await db('photos')
const usedCategoryIds = hiddenForGuest ? [] : await db('photos')
.where('event_id', req.event.id)
.whereNotNull('category_id')
.distinct('category_id')
@@ -853,6 +868,9 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
hero_photo_id: req.event.hero_photo_id,
allow_downloads: req.event.allow_downloads !== false,
allow_user_uploads: req.event.allow_user_uploads === true,
// Reveal mode (#838): armed flag lets an open VISIBLE gallery keep
// polling so a re-hide propagates without a manual reload.
reveal_armed: req.event.reveal_mode === true || req.event.reveal_mode === 1 || req.event.reveal_mode === '1',
disable_right_click: req.event.disable_right_click === true,
watermark_downloads: req.event.watermark_downloads === true,
watermark_text: req.event.watermark_text,
@@ -872,6 +890,10 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
use_original_filenames: useOriginalFilenames,
...protectionSettings
},
// Reveal mode (#838): the guest UI switches to the upload-only view
// on this flag; reveal_at lets it show the scheduled time.
hidden_until_reveal: hiddenForGuest,
reveal_at: hiddenForGuest ? (req.event.reveal_at || null) : undefined,
categories: categories,
photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
@@ -1006,8 +1028,9 @@ router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, r
}
});
// Download single photo
router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
const { photoId } = req.params;
@@ -1128,7 +1151,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
});
// Download all photos as ZIP
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
// Check if downloads are allowed for this event
if (req.event.allow_downloads === false) {
@@ -1313,7 +1336,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
});
// Download selected photos as ZIP
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
// Check if downloads are allowed for this event
if (req.event.allow_downloads === false) {
@@ -1437,6 +1460,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
// View single photo (with watermark if enabled)
router.get('/:slug/photo/:photoId',
verifyGalleryAccess,
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
@@ -1669,6 +1693,7 @@ router.get('/:slug/photo/:photoId',
// Serve thumbnail
router.get('/:slug/thumbnail/:photoId',
verifyGalleryAccess,
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
@@ -1760,6 +1785,9 @@ router.get('/:slug/thumbnail/:photoId',
// Serve hero-optimized image (1920x1080 for full-width hero sections)
router.get('/:slug/hero/:photoId',
verifyGalleryAccess,
// Reveal-gated too: this route serves a 1920px derivative of ANY photo id,
// not just the chosen hero — an open bypass while hidden (review round 1).
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
@@ -1858,6 +1886,7 @@ router.get('/:slug/hero/:photoId',
// guest would see on the full original.
router.get('/:slug/preview/:photoId',
verifyGalleryAccess,
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
@@ -1967,7 +1996,7 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
});
// Get photo stats
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
router.get('/:slug/stats', verifyGalleryAccess, blockHiddenGallery, async (req, res) => {
try {
const totalPhotos = await db('photos')
.where('event_id', req.event.id)
+20 -1
View File
@@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
const { guestBlockedByReveal, blockHiddenGallery } = require('../utils/revealMode');
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const { resolveGuest } = require('../middleware/guestAuth');
const feedbackService = require('../services/feedbackService');
@@ -53,6 +54,9 @@ router.get('/:slug/feedback-settings',
// Get feedback for a specific photo
router.get('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
// Reveal-gated (#838): sequential photo ids would let hidden-gallery
// guests enumerate comments/stats.
blockHiddenGallery,
resolveGuest,
validatePhotoId,
checkValidation,
@@ -156,6 +160,8 @@ router.get('/:slug/photos/:photoId/feedback',
router.post('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
denySlideshowToken,
// Reveal-gated (#838): no interacting with photos you cannot see.
blockHiddenGallery,
resolveGuest,
validatePhotoId,
validateFeedbackSubmission,
@@ -326,7 +332,13 @@ router.get('/:slug/feedback-summary',
async (req, res) => {
try {
const event = req.event;
// Reveal mode (#838): the summary lists top photos by filename —
// hidden along with the gallery for plain guests.
if (guestBlockedByReveal(req)) {
return res.json({ enabled: false, summary: null });
}
// Get feedback settings
const settings = await feedbackService.getEventFeedbackSettings(event.id);
@@ -379,6 +391,13 @@ router.get('/:slug/my-feedback',
try {
const event = req.event;
// Reveal mode (#838): rows join photos (filename + storage path) —
// return the empty back-compat shape rather than a 403 so the gallery
// shell loading in parallel doesn't surface error toasts.
if (guestBlockedByReveal(req)) {
return res.json([]);
}
const query = db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.event_id', event.id);
+23 -8
View File
@@ -2,6 +2,7 @@ const express = require('express');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
const watermarkService = require('../services/watermarkService');
const secureImageService = require('../services/secureImageService');
const { getStorage } = require('../services/storage');
@@ -16,10 +17,13 @@ const router = express.Router();
/**
* Generate a signed URL token for image access
*/
function generateImageToken(photoId, expiresIn = 3600) {
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false) {
const secret = process.env.JWT_SECRET;
const expires = Date.now() + (expiresIn * 1000);
const data = `${photoId}:${expires}`;
// Third segment (#838): whether the minting context bypasses reveal mode
// (slideshow/client/admin). Old two-segment tokens verify unchanged and
// read as no-bypass.
const data = `${photoId}:${expires}:${revealBypass ? 1 : 0}`;
const signature = crypto.createHmac('sha256', secret).update(data).digest('hex');
return `${Buffer.from(data).toString('base64')}.${signature}`;
}
@@ -32,7 +36,7 @@ function verifyImageToken(token) {
const secret = process.env.JWT_SECRET;
const [data, signature] = token.split('.');
const decoded = Buffer.from(data, 'base64').toString();
const [photoId, expires] = decoded.split(':');
const [photoId, expires, bypassFlag] = decoded.split(':');
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
@@ -45,7 +49,7 @@ function verifyImageToken(token) {
return null;
}
return { photoId: parseInt(photoId), expires: parseInt(expires) };
return { photoId: parseInt(photoId), expires: parseInt(expires), revealBypass: bypassFlag === '1' };
} catch (error) {
return null;
}
@@ -54,7 +58,7 @@ function verifyImageToken(token) {
/**
* Serve protected image with enhanced security
*/
router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) => {
router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery, async (req, res) => {
try {
const { photoId } = req.params;
const { protectionLevel = 'standard', token } = req.query;
@@ -174,7 +178,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
/**
* Generate secure token for enhanced image access
*/
router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess, async (req, res) => {
router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess, blockHiddenGallery, async (req, res) => {
try {
const { photoId } = req.params;
const { protectionLevel = 'standard', expiresIn = 300 } = req.body;
@@ -219,6 +223,11 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
* Generate signed URL for image access (legacy support)
*/
router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (req, res) => {
// Reveal-gated for plain guests; bypass callers get a token that stays
// valid at SERVE time too (third token segment below).
if (require('../utils/revealMode').guestBlockedByReveal(req)) {
return res.status(403).json({ error: 'Gallery is hidden until reveal', code: 'GALLERY_HIDDEN' });
}
try {
const { photoId } = req.params;
@@ -235,7 +244,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
}
// Generate signed token
const token = generateImageToken(photoId);
const token = generateImageToken(photoId, 3600, bypassesReveal(req));
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
res.json({
@@ -271,7 +280,13 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Reveal mode (#838): a signed URL minted before a re-hide must not keep
// serving hidden photos; tokens minted by bypass contexts carry the flag.
if (isGalleryHidden(event) && !tokenData.revealBypass) {
return res.status(403).json({ error: 'Gallery is hidden until reveal', code: 'GALLERY_HIDDEN' });
}
// Get photo
const photo = await db('photos')
.where({
+13 -2
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { db } = require('../database/db');
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
const secureImageService = require('../services/secureImageService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger');
@@ -23,7 +24,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
// Add slug to request for verifyGalleryAccess
req.requestedSlug = req.params.slug;
next();
}, verifyGalleryAccess, denySlideshowToken, async (req, res) => {
}, verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
const { photoId, accessType = 'view' } = req.body;
@@ -51,7 +52,10 @@ router.post('/:slug/generate-token', async (req, res, next) => {
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
maxUses: accessType === 'download' ? 1 : 3,
clientFingerprint,
protectionLevel
protectionLevel,
// Reveal mode (#838): recorded in the token so a re-hide invalidates
// in-flight guest tokens at serve time without breaking the slideshow.
revealBypass: bypassesReveal(req)
};
const token = secureImageService.generateSecureToken(
@@ -137,6 +141,12 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Gallery not found' });
}
// Reveal mode (#838): tokens minted by plain guests die the moment the
// gallery is (re-)hidden — bypass contexts keep working.
if (isGalleryHidden(event) && !tokenValidation.data?.revealBypass) {
return res.status(403).json({ error: 'Gallery is hidden until reveal', code: 'GALLERY_HIDDEN' });
}
// Verify photo exists and belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: event.id })
@@ -273,6 +283,7 @@ router.get('/:slug/secure-download/:photoId/:token',
next();
},
verifyGalleryAccess,
blockHiddenGallery,
denySlideshowToken,
async (req, res) => {
try {