* 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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user