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,402 @@
/**
* Reveal mode integration tests (#838).
*
* Pins the contract:
* - effective visibility is computed at request time (isGalleryHidden):
* reveal_at in the past opens the gate even before the scheduler stamps
* - /photos returns the event shell with photos: [] + hidden_until_reveal
* for plain guests; slideshow / client / admin-preview see everything
* - image + download endpoints 403 with GALLERY_HIDDEN for plain guests
* - the guest upload route is NOT gated (uploading while hidden is the point)
* - the scheduler stamps revealed_at for due events, exactly once
* - POST /events/:id/reveal stamps revealed_at (idempotent, 400 when the
* mode is off); re-enabling reveal_mode clears revealed_at (re-hide)
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'reveal-test-secret';
const SLUG = 'reveal-test-event';
describe('Reveal mode (#838)', () => {
let db;
let cleanup;
let app;
let eventId;
let photoIds;
let adminToken;
const { isGalleryHidden } = require('../../src/utils/revealMode');
const galleryToken = (extra = {}) => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', ...extra },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Reveal Test',
event_date: '2026-08-01',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'reveal-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_user_uploads: 1,
reveal_mode: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
photoIds = [];
for (let i = 0; i < 2; i++) {
const p = await db('photos').insert({
event_id: eventId,
filename: `photo-${i}.jpg`,
path: `events/reveal/${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(p[0]?.id ?? p[0]);
}
// Super admin for the admin routes.
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'reveal-admin',
email: '[email protected]',
password_hash: await bcrypt.hash('RevealAdmin123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'reveal-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/secure-images', require('../../src/routes/secureImages'));
app.use('/api/images', require('../../src/routes/protectedImages'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('effective visibility math (isGalleryHidden)', () => {
const base = { reveal_mode: true, revealed_at: null, reveal_at: null };
it('is hidden while armed and unrevealed, visible otherwise', () => {
expect(isGalleryHidden({ ...base })).toBe(true);
expect(isGalleryHidden({ ...base, reveal_mode: false })).toBe(false);
expect(isGalleryHidden({ ...base, revealed_at: new Date() })).toBe(false);
// reveal_at in the past opens the gate WITHOUT any stamp — time-exact.
expect(isGalleryHidden({ ...base, reveal_at: new Date(Date.now() - 60_000) })).toBe(false);
expect(isGalleryHidden({ ...base, reveal_at: new Date(Date.now() + 60_000) })).toBe(true);
// SQLite 0/1 booleans
expect(isGalleryHidden({ reveal_mode: 1, revealed_at: null, reveal_at: null })).toBe(true);
expect(isGalleryHidden({ reveal_mode: 0, revealed_at: null, reveal_at: null })).toBe(false);
});
});
describe('gallery routes while hidden', () => {
it('/photos gives plain guests the shell with no photos and the flag', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(true);
expect(res.body.photos).toEqual([]);
expect(res.body.categories).toEqual([]);
expect(res.body.event.event_name).toBe('Reveal Test');
});
it('/photos serves the slideshow token everything (surprise beamer)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'slideshow' })}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('/photos serves client access everything (host review)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'client' })}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('/photos serves the admin preview everything', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos?preview=${encodeURIComponent(adminToken)}`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('image and download endpoints 403 with GALLERY_HIDDEN for plain guests', async () => {
for (const url of [
`/api/gallery/${SLUG}/thumbnail/${photoIds[0]}`,
`/api/gallery/${SLUG}/photo/${photoIds[0]}`,
`/api/gallery/${SLUG}/download/${photoIds[0]}`,
`/api/gallery/${SLUG}/download-all`,
`/api/gallery/${SLUG}/stats`,
`/api/gallery/${SLUG}/hero/${photoIds[0]}`,
]) {
const res = await request(app).get(url).set('Authorization', `Bearer ${galleryToken()}`);
expect(`${url}:${res.status}`).toBe(`${url}:403`);
expect(res.body.code).toBe('GALLERY_HIDDEN');
}
});
it('image endpoints are NOT reveal-blocked for the slideshow token', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/thumbnail/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'slideshow' })}`);
// The seeded file doesn't exist on disk, so anything but the reveal
// gate's 403 is fine here.
expect(res.body.code).not.toBe('GALLERY_HIDDEN');
});
it('/info exposes the effective hidden state without auth', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/info`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(true);
});
it('the guest upload route is not gated', async () => {
const res = await request(app)
.post(`/api/gallery/${eventId}/upload`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({});
// Fails later for other reasons (no multipart body) — but never on the
// reveal gate.
expect(res.body.code).not.toBe('GALLERY_HIDDEN');
});
it('legacy protected-image routes are reveal-gated for plain guests', async () => {
for (const [method, url] of [
['get', `/api/images/${SLUG}/photo/${photoIds[0]}/view`],
['post', `/api/images/${SLUG}/photo/${photoIds[0]}/generate-secure-token`],
['post', `/api/images/${SLUG}/photo/${photoIds[0]}/generate-url`],
]) {
const res = await request(app)[method](url).set('Authorization', `Bearer ${galleryToken()}`);
expect(`${url}:${res.status}`).toBe(`${url}:403`);
expect(res.body.code).toBe('GALLERY_HIDDEN');
}
});
it('feedback endpoints are reveal-gated; my-feedback degrades to empty', async () => {
// Feedback must be enabled for the routes to get past their own gate.
await db('event_feedback_settings').insert({
event_id: eventId, feedback_enabled: 1, allow_likes: 1,
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
});
const getRes = await request(app)
.get(`/api/gallery/${SLUG}/photos/${photoIds[0]}/feedback`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(getRes.status).toBe(403);
expect(getRes.body.code).toBe('GALLERY_HIDDEN');
const postRes = await request(app)
.post(`/api/gallery/${SLUG}/photos/${photoIds[0]}/feedback`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ feedback_type: 'like' });
expect(postRes.status).toBe(403);
expect(postRes.body.code).toBe('GALLERY_HIDDEN');
const mine = await request(app)
.get(`/api/gallery/${SLUG}/my-feedback`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(mine.status).toBe(200);
expect(mine.body).toEqual([]);
});
it('secure-image token minting is reveal-gated for plain guests', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ photoId: photoIds[0] });
expect(res.status).toBe(403);
expect(res.body.code).toBe('GALLERY_HIDDEN');
});
it('customer-portal tokens (via:customer, no accessLevel) bypass reveal mode', async () => {
const acct = await db('customer_accounts').insert({
email: '[email protected]',
password_hash: 'x',
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id');
const customerId = acct[0]?.id ?? acct[0];
await db('event_customer_assignments').insert({
event_id: eventId,
customer_account_id: customerId,
});
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken({ via: 'customer', customerId })}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('a reveal_at in the past opens the gate without any stamp', async () => {
await db('events').where('id', eventId).update({ reveal_at: new Date(Date.now() - 60_000).toISOString() });
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
await db('events').where('id', eventId).update({ reveal_at: null });
});
});
describe('scheduler and admin reveal', () => {
it('the scheduler stamps revealed_at for due events exactly once', async () => {
const revealAt = new Date(Date.now() - 5 * 60_000);
await db('events').where('id', eventId).update({ reveal_at: revealAt.toISOString(), revealed_at: null });
const { checkScheduledReveals } = require('../../src/services/revealScheduler');
await checkScheduledReveals();
const asMs = (v) => new Date(v).getTime();
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).not.toBeNull();
expect(asMs(row.revealed_at)).toBe(revealAt.getTime());
expect(row.reveal_at).toBeNull(); // schedule consumed, like "Reveal now"
// Second pass no-ops (revealed_at already set).
await checkScheduledReveals();
const again = await db('events').where('id', eventId).first();
expect(asMs(again.revealed_at)).toBe(revealAt.getTime());
await db('events').where('id', eventId).update({ reveal_at: null, revealed_at: null });
});
it('POST /:id/reveal stamps revealed_at, clears the schedule, and is idempotent', async () => {
await db('events').where('id', eventId).update({ reveal_at: new Date(Date.now() + 3600_000).toISOString() });
const res = await request(app)
.post(`/api/admin/events/${eventId}/reveal`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body.revealed_at).toBeTruthy();
// "Reveal now" consumes the pending schedule.
const cleared = await db('events').where('id', eventId).first();
expect(cleared.reveal_at).toBeNull();
const first = res.body.revealed_at;
const res2 = await request(app)
.post(`/api/admin/events/${eventId}/reveal`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res2.status).toBe(200);
expect(res2.body.revealed_at).toBe(first);
// Guests see photos now.
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(false);
expect(gallery.body.photos).toHaveLength(2);
});
it('re-enabling reveal_mode clears revealed_at (re-hide)', async () => {
await db('events').where('id', eventId).update({ reveal_mode: 0 });
const res = await request(app)
.put(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ reveal_mode: true });
expect(res.status).toBe(200);
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).toBeNull();
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(true);
});
it('scheduling a FUTURE reveal on a revealed gallery re-arms hiding', async () => {
// State: revealed (previous tests). Saving a future schedule re-hides.
await db('events').where('id', eventId).update({ revealed_at: new Date().toISOString() });
const res = await request(app)
.put(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ reveal_mode: true, reveal_at: new Date(Date.now() + 3600_000).toISOString() });
expect(res.status).toBe(200);
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).toBeNull();
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(true);
await db('events').where('id', eventId).update({ reveal_at: null });
});
it('re-arming without a schedule clears a stale PAST reveal_at', async () => {
// Legacy/partial-API state: revealed with the old past schedule still
// stored. {reveal_mode:false} then {reveal_mode:true} without
// reveal_at must re-hide, not instantly re-open via the stale date.
await db('events').where('id', eventId).update({
reveal_mode: 0,
revealed_at: new Date().toISOString(),
reveal_at: new Date(Date.now() - 3600_000).toISOString(),
});
const res = await request(app)
.put(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ reveal_mode: true });
expect(res.status).toBe(200);
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).toBeNull();
expect(row.reveal_at).toBeNull();
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(true);
expect(gallery.body.photos).toEqual([]);
});
it('POST /:id/reveal 400s while reveal mode is off', async () => {
await db('events').where('id', eventId).update({ reveal_mode: 0, revealed_at: null });
const res = await request(app)
.post(`/api/admin/events/${eventId}/reveal`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(400);
await db('events').where('id', eventId).update({ reveal_mode: 1 });
});
});
});
@@ -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);
});
}
}
};
+3
View File
@@ -20,6 +20,7 @@ const path = require('path');
const { initializeDatabase, db } = require('./src/database/db');
const { startFileWatcher } = require('./src/services/fileWatcher');
const { startExpirationChecker } = require('./src/services/expirationChecker');
const { startRevealScheduler } = require('./src/services/revealScheduler');
const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService');
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
const { startBackupService } = require('./src/services/backupService');
@@ -903,6 +904,8 @@ async function startServer() {
// Start expiration checker
startExpirationChecker();
// Reveal-mode scheduler (#838): minutely stamp for scheduled reveals.
startRevealScheduler();
// CRM invoice scheduler: hourly tick to flush scheduled-send invoices
// + run the overdue reminder ladder. No-op when the `bills` feature
// flag is OFF (the service short-circuits on empty result sets).
+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 {
+7 -2
View File
@@ -207,7 +207,11 @@ async function buildOgMetadata(slug, requestPath) {
// the logo on any of those misses so a half-configured event still
// gets a polished link preview rather than a broken image.
let image = logoUrl;
if (event.og_image_share_enabled && event.hero_photo_id) {
// Reveal mode (#838): while the gallery is hidden from guests, social
// crawlers must not get the hero photo either — fall back to the brand
// logo like the opt-out case.
const { isGalleryHidden } = require('../utils/revealMode');
if (event.og_image_share_enabled && event.hero_photo_id && !isGalleryHidden(event)) {
const heroPhoto = await db('photos')
.where({ id: event.hero_photo_id, event_id: event.id })
.select('id', 'thumbnail_path')
@@ -302,7 +306,8 @@ async function handleGalleryOgCover(req, res) {
return;
}
const event = await resolveSlug(slug);
if (!event || !event.og_image_share_enabled || !event.hero_photo_id) {
const { isGalleryHidden } = require('../utils/revealMode');
if (!event || !event.og_image_share_enabled || !event.hero_photo_id || isGalleryHidden(event)) {
res.status(404).type('text/plain').send('Cover not available');
return;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Reveal scheduler (#838). Runs every minute (the expiration checker's
* hourly cadence is too coarse for a party reveal) and stamps revealed_at on
* events whose scheduled reveal_at has passed.
*
* The stamp is bookkeeping, not the gate: the gallery routes compute
* effective visibility from reveal_at at request time, so the reveal happens
* exactly on schedule even if this job lags. The scheduler makes the state
* durable, writes the activity log entry, and emits the `gallery.revealed`
* workflow trigger so hosts can hook a notification email onto it.
*/
const cron = require('node-cron');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
async function checkScheduledReveals() {
try {
const now = new Date().toISOString();
const due = await db('events')
.where('reveal_mode', formatBoolean(true))
.whereNull('revealed_at')
.whereNotNull('reveal_at')
.where('reveal_at', '<=', now)
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
// Drafts aren't guest-reachable — stamping/notifying would burn the
// reveal before publication and fire workflows for a dead link.
.where('is_draft', formatBoolean(false));
for (const event of due) {
// Conditional update: another worker (multi-replica) may have stamped
// it between the select and here — exactly one emits the events.
// Consume the schedule like "Reveal now" does — a stale past
// reveal_at would otherwise instantly re-open the gate when the
// gallery is later re-armed without a fresh schedule.
const stamped = await db('events')
.where('id', event.id)
.whereNull('revealed_at')
.update({ revealed_at: event.reveal_at, reveal_at: null });
if (stamped !== 1) continue;
logger.info('Reveal mode: scheduled reveal fired', { eventId: event.id, slug: event.slug });
await logActivity('gallery_revealed', { scheduled: true, reveal_at: event.reveal_at }, event.id);
// Best-effort trigger for custom notification flows — never throws
// into the scheduler and no-ops when nothing subscribes.
try {
await require('./workflows').emitWorkflowEvent('gallery.revealed', {
entityType: 'event',
entityId: event.id,
dedupSuffix: String(new Date(event.reveal_at).getTime()),
payload: {
eventId: event.id,
slug: event.slug,
eventName: event.event_name,
revealedAt: event.reveal_at,
scheduled: true,
},
});
} catch (err) {
logger.warn('Failed to emit gallery.revealed workflow event', { eventId: event.id, error: err.message });
}
}
} catch (error) {
logger.error('Reveal scheduler pass failed:', error);
}
}
function startRevealScheduler() {
cron.schedule('* * * * *', checkScheduledReveals);
logger.info('Reveal scheduler started');
}
module.exports = { startRevealScheduler, checkScheduledReveals };
+6 -1
View File
@@ -21,7 +21,11 @@ class SecureImageService {
expiresIn = 300, // 5 minutes default
maxUses = 1,
clientFingerprint = '',
protectionLevel = 'standard'
protectionLevel = 'standard',
// Reveal mode (#838): whether the minting context bypasses the
// hidden-gallery gate — re-checked at SERVE time so a re-hide
// invalidates in-flight guest tokens without breaking the slideshow.
revealBypass = false
} = options;
const tokenData = {
@@ -32,6 +36,7 @@ class SecureImageService {
maxUses,
usedCount: 0,
protectionLevel,
revealBypass,
createdAt: Date.now()
};
+5 -2
View File
@@ -263,7 +263,7 @@ async function resumeRun(runId, { decisionHandle = null } = {}) {
* workflow (idempotent via dedup_key) and starts it. Never throws — safe to
* call after a caller's commit. Fails CLOSED if the flag system is unavailable.
*/
async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {}, targetWorkflowId = null } = {}) {
async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {}, targetWorkflowId = null, dedupSuffix = null } = {}) {
try {
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
let enabled = false;
@@ -285,7 +285,10 @@ async function emitWorkflowEvent(triggerType, { entityType = null, entityId = nu
const tcfg = parseJson(wf.trigger_config, {});
if (tcfg && tcfg.filter && !matchFilter(tcfg.filter, payload)) continue;
const dedupKey = `${wf.id}:${wf.version}:${triggerType}:${entityType || ''}:${entityId || ''}`;
// dedupSuffix (#838): repeatable lifecycle events (a gallery can be
// re-hidden and revealed again) append a cycle marker so each cycle
// gets its own run while accidental double-emits still dedupe.
const dedupKey = `${wf.id}:${wf.version}:${triggerType}:${entityType || ''}:${entityId || ''}${dedupSuffix ? `:${dedupSuffix}` : ''}`;
const existing = await db('workflow_runs').where({ dedup_key: dedupKey }).first();
if (existing) continue;
+75
View File
@@ -0,0 +1,75 @@
/**
* Reveal mode (#838): effective-visibility math, shared by the gallery
* routes, the admin routes and the reveal scheduler.
*
* The gate is computed from the event row at request time — a scheduled
* reveal opens EXACTLY at reveal_at even if the minutely scheduler (which
* only stamps revealed_at durably and fires notifications) lags behind.
*/
/** Truthy check that survives SQLite 0/1 and Postgres booleans. */
function isTrue(value) {
return value === true || value === 1 || value === '1';
}
/**
* Parse a timestamp column that may arrive as a Date (Postgres), an ISO
* string, or a millisecond number/number-string (SQLite stores knex Dates
* as ms — `new Date("178…")` on that string would be Invalid Date and
* silently keep the gallery hidden past its scheduled reveal).
*/
function toDate(value) {
if (value instanceof Date) return value;
if (typeof value === 'number') return new Date(value);
const asNumber = Number(value);
if (!Number.isNaN(asNumber) && String(value).trim() !== '') return new Date(asNumber);
return new Date(value);
}
/**
* Whether the gallery is currently hidden from plain guests.
* Host/admin/slideshow/client access bypasses this at the route layer.
*/
function isGalleryHidden(event, now = new Date()) {
if (!isTrue(event.reveal_mode)) return false;
if (event.revealed_at) return false;
if (event.reveal_at && toDate(event.reveal_at) <= now) return false;
return true;
}
/**
* Which access levels see the full gallery while it is hidden:
* the live slideshow (the "surprise beamer" case), client access and
* customer-portal-minted tokens (both are the host/customer reviewing
* their own event — those tokens carry via:'customer' with NO accessLevel,
* so accessLevel alone would misclassify them as guests) and the admin
* preview.
*/
function bypassesReveal(req) {
if (req.accessLevel === 'slideshow' || req.accessLevel === 'client') return true;
if (req.viaCustomer) return true;
// Lazy require avoids a cycle: middleware/gallery requires nothing from
// here, but keeping the import local makes that permanent.
const { isAdminPreview } = require('../middleware/gallery');
return Boolean(isAdminPreview(req));
}
/** Route guard result: is THIS request blocked by reveal mode? */
function guestBlockedByReveal(req, now = new Date()) {
return isGalleryHidden(req.event, now) && !bypassesReveal(req);
}
/**
* Route guard: hard 403 for plain guests on photo/derivative/download
* endpoints while the gallery is hidden. Photo IDs are sequential, so
* gating only the listing would leave images probeable. Mount AFTER
* verifyGalleryAccess (needs req.event / req.accessLevel).
*/
function blockHiddenGallery(req, res, next) {
if (guestBlockedByReveal(req)) {
return res.status(403).json({ error: 'Gallery is hidden until reveal', code: 'GALLERY_HIDDEN' });
}
next();
}
module.exports = { isGalleryHidden, bypassesReveal, guestBlockedByReveal, blockHiddenGallery };