* 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:
co-authored by
Paul Nothaft
parent
3d6c9848dc
commit
2f05fcc39d
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user