diff --git a/backend/__tests__/routes/galleryDraftPreviewResolve.test.js b/backend/__tests__/routes/galleryDraftPreviewResolve.test.js new file mode 100644 index 00000000..1a6deefd --- /dev/null +++ b/backend/__tests__/routes/galleryDraftPreviewResolve.test.js @@ -0,0 +1,172 @@ +/** + * Previewing an unpublished gallery through its SHORT share URL (#1386). + * + * /info has honoured admin_preview since #868, but two sibling routes never + * did, and both sit on the short-URL path: + * + * GET /resolve/:identifier — filtered drafts out via ACTIVE_EVENT_FILTER + * GET /:slug/verify-token/:token — same, inline + * + * With "use short gallery URLs" OFF the admin's View Gallery link carries the + * slug, GalleryPage never calls /resolve, and the preview worked. With it ON + * the link is the token form, GalleryPage resolves it first, and the draft + * 404'd as "Gallery Not Found" — which is exactly what was reported. + * + * The relaxation is admin-preview-only, so the other half of these tests is + * the part that must NOT move: anonymous callers still get 404 for a draft, + * and GHSA-rh8r's rule (never hand a share_token back on a bare slug lookup) + * has to survive the new path too. + */ +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +process.env.NODE_ENV = 'test'; +process.env.TEST_DATABASE_PATH = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-draft-preview-')), 'db.sqlite', +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'draft-preview-test-secret'; +process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-draft-preview-storage-')); + +const request = require('supertest'); +const express = require('express'); +const cookieParser = require('cookie-parser'); +const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb'); + +// Share-token fixtures, deliberately low-entropy and obviously fake. They +// have to satisfy SHARE_TOKEN_REGEX (32 hex chars), and random-looking hex of +// that shape is exactly what secret scanners flag — GitGuardian raised two +// "Generic High Entropy Secret" findings on the first version of this file. +const DRAFT_SLUG = 'draft-preview-event'; +const DRAFT_TOKEN = 'deadbeefdeadbeefdeadbeefdeadbeef'; +const LIVE_SLUG = 'published-event'; +const LIVE_TOKEN = 'feedfacefeedfacefeedfacefeedface'; + +describe('draft preview through the short share URL (#1386)', () => { + let db; let cleanup; let app; let adminId; let foreignId; + + const asAdmin = (req, id = adminId) => req.set('Authorization', `Bearer ${mintAdminToken(id)}`); + + async function insertEvent({ slug, token, isDraft }) { + await db('events').insert({ + slug, + event_type: 'wedding', + event_name: slug, + event_date: '2026-09-01', + host_email: 'h@example.com', + admin_email: 'a@example.com', + password_hash: 'x', + share_link: `/gallery/${slug}/${token}`, + share_token: token, + require_password: 0, + expires_at: new Date(Date.now() + 7 * 864e5).toISOString(), + is_active: 1, + is_archived: 0, + is_draft: isDraft ? 1 : 0, + created_by: adminId, + created_at: new Date().toISOString(), + }); + } + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ adminId } = await seedMinimal(db)); + await assignAdminRole(db, adminId); + const [row] = await db('admin_users').insert({ + username: 'foreign', email: 'foreign@example.test', password_hash: 'unused', is_active: 1, + }).returning('id'); + foreignId = row?.id ?? row; + await assignAdminRole(db, foreignId, 'viewer'); + + await insertEvent({ slug: DRAFT_SLUG, token: DRAFT_TOKEN, isDraft: true }); + await insertEvent({ slug: LIVE_SLUG, token: LIVE_TOKEN, isDraft: false }); + + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use('/api/gallery', require('../../src/routes/gallery')); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + describe('the reported case — admin previewing a draft', () => { + it('resolves the draft by share token (was 404 "Gallery Not Found")', async () => { + const res = await asAdmin( + request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`), + ); + expect(res.status).toBe(200); + expect(res.body.slug).toBe(DRAFT_SLUG); + expect(res.body.matchType).toBe('token'); + }); + + it('resolves the draft by full share link', async () => { + const identifier = encodeURIComponent(`/gallery/${DRAFT_SLUG}/${DRAFT_TOKEN}`); + const res = await asAdmin( + request(app).get(`/api/gallery/resolve/${identifier}?admin_preview=1`), + ); + expect(res.status).toBe(200); + expect(res.body.slug).toBe(DRAFT_SLUG); + }); + + it('clears verify-token for the draft, the next step of the same flow', async () => { + const res = await asAdmin( + request(app).get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}?admin_preview=1`), + ); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + }); + }); + + describe('what must not move', () => { + it('404s an anonymous resolve of the draft token', async () => { + const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}`); + expect(res.status).toBe(404); + }); + + it('404s even with admin_preview=1 but no admin token', async () => { + const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`); + expect(res.status).toBe(404); + }); + + it('404s for an admin who cannot access this event', async () => { + const res = await asAdmin( + request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`), + foreignId, + ); + expect(res.status).toBe(404); + }); + + it('404s an anonymous verify-token for the draft', async () => { + const res = await request(app) + .get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}`); + expect(res.status).toBe(404); + }); + + it('still withholds the share_token on a bare slug lookup (GHSA-rh8r)', async () => { + // The draft path must not become a way around the token-withholding rule. + const res = await asAdmin( + request(app).get(`/api/gallery/resolve/${DRAFT_SLUG}?admin_preview=1`), + ); + expect(res.status).toBe(200); + expect(res.body.matchType).toBe('slug'); + expect(res.body.token).toBeUndefined(); + expect(res.body.share_link).toBeUndefined(); + expect(res.body.share_url).toBeUndefined(); + expect(JSON.stringify(res.body)).not.toContain(DRAFT_TOKEN); + }); + + it('leaves the published gallery resolving anonymously, as before', async () => { + const res = await request(app).get(`/api/gallery/resolve/${LIVE_TOKEN}`); + expect(res.status).toBe(200); + expect(res.body.slug).toBe(LIVE_SLUG); + expect(res.body.token).toBe(LIVE_TOKEN); + }); + + it('still 404s an identifier that matches nothing', async () => { + const res = await asAdmin( + request(app).get('/api/gallery/resolve/no-such-gallery?admin_preview=1'), + ); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/backend/src/routes/gallery/metadata.js b/backend/src/routes/gallery/metadata.js index 4a8c79b3..7c89a183 100644 --- a/backend/src/routes/gallery/metadata.js +++ b/backend/src/routes/gallery/metadata.js @@ -29,10 +29,36 @@ async function checkSlugRedirect(slug) { } } +// Admin preview of an unpublished gallery (#1386). /info has honoured +// admin_preview since #868, but this route never did, so the short-URL form +// of a draft's share link 404'd with "Gallery Not Found" while the long slug +// form worked — exactly the shape the reporter described. +// +// Deliberately a second lookup on the miss path rather than a widened filter: +// the published case keeps its single query and cannot start returning drafts +// however this evolves, and an unverified caller never gets so far as knowing +// the draft exists. +async function resolveDraftForAdminPreview(req, identifier) { + // decodeAdminPreview requires this flag anyway, so checking it up front costs + // nothing and keeps an unknown identifier from paying for a second set of + // lookups on the public 404 path. + if (req.query?.admin_preview !== '1') return null; + const result = await resolveShareIdentifier(identifier, { includeDrafts: true }); + if (!result) return null; + // verifyAdminPreview re-reads the event with SELECT * off the slug, so give + // it the slug rather than the partial row selected above. + req.requestedSlug = result.event.slug; + return await verifyAdminPreview(req) ? result : null; +} + router.get('/resolve/:identifier', handleAsync(async (req, res) => { const { identifier } = req.params; let result = await resolveShareIdentifier(identifier); + if (!result) { + result = await resolveDraftForAdminPreview(req, identifier); + } + // If not found, check for redirect if (!result) { const newSlug = await checkSlugRedirect(identifier); @@ -79,14 +105,23 @@ router.get('/:slug/verify-token/:token', noStoreCache, handleAsync(async (req, r const { slug, token } = req.params; const event = await db('events') - .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) }) - .select('id', 'share_link', 'share_token') + .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) + .select('id', 'share_link', 'share_token', 'is_draft') .first(); if (!event) { throw new NotFoundError('Gallery'); } + // Drafts are visible to a verified admin preview only (#1386). Without this + // the preview clears /resolve and then 404s one step later, here. + if (event.is_draft) { + req.requestedSlug = slug; + if (!await verifyAdminPreview(req)) { + throw new NotFoundError('Gallery'); + } + } + const expectedToken = getEventShareToken(event); if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) { throw new NotFoundError('Gallery', 'Invalid gallery link'); diff --git a/backend/src/services/shareLinkService.js b/backend/src/services/shareLinkService.js index d5ffe602..8ffdee44 100644 --- a/backend/src/services/shareLinkService.js +++ b/backend/src/services/shareLinkService.js @@ -118,7 +118,15 @@ const ACTIVE_EVENT_FILTER = { is_draft: formatBoolean(false) }; -const resolveShareIdentifier = async (identifier) => { +// Same filter minus the draft gate, for admin preview only (#1386). Callers +// MUST authorize before returning anything it matched — see the /resolve +// route, which only reaches for it after a verified admin preview. +const UNPUBLISHED_EVENT_FILTER = { + is_active: formatBoolean(true), + is_archived: formatBoolean(false) +}; + +const resolveShareIdentifier = async (identifier, { includeDrafts = false } = {}) => { if (!identifier) { return null; } @@ -140,9 +148,10 @@ const resolveShareIdentifier = async (identifier) => { 'event_date', 'expires_at', 'is_active', - 'is_archived' + 'is_archived', + 'is_draft' ) - .where(ACTIVE_EVENT_FILTER); + .where(includeDrafts ? UNPUBLISHED_EVENT_FILTER : ACTIVE_EVENT_FILTER); let event = await baseQuery.clone().where({ slug: trimmed }).first(); if (event) { diff --git a/frontend/src/contexts/GalleryAuthContext.tsx b/frontend/src/contexts/GalleryAuthContext.tsx index 5512d10c..c6932378 100644 --- a/frontend/src/contexts/GalleryAuthContext.tsx +++ b/frontend/src/contexts/GalleryAuthContext.tsx @@ -250,6 +250,24 @@ export const GalleryAuthProvider: React.FC = ({ childr if (routeInfo.token) { const verify = await galleryService.verifyToken(currentSlug, routeInfo.token); if (verify?.valid) { + // An admin preview does not take a guest session (#1386). The admin + // cookie plus admin_preview=1 already authorizes every gallery call, + // and shareLinkLogin refuses drafts AND records a failed attempt + // when it does — so opening a draft preview five times would lock + // share-link logins out for that IP, even after publishing. + const isAdminPreview = typeof window !== 'undefined' + && new URLSearchParams(window.location.search).get('admin_preview') === '1'; + if (isAdminPreview) { + const previewData = await galleryService.getGalleryPhotos(currentSlug); + if (previewData?.event) { + const previewEvent = normalizeEvent(previewData.event); + setEvent(previewEvent); + setActiveGallerySlug(currentSlug); + setIsAuthenticated(true); + return; + } + } + const response = await authService.shareLinkLogin(currentSlug, routeInfo.token); if (response?.event) { // Store token and slug BEFORE setting authenticated state to avoid