diff --git a/backend/server.js b/backend/server.js index 6b71ea3f..f1e57809 100644 --- a/backend/server.js +++ b/backend/server.js @@ -769,12 +769,21 @@ try { // SPA fallback for admin + gallery routes. For gallery URLs we intercept // social-crawler User-Agents and serve OG/Twitter-card metadata so link // previews show the event name + branding instead of the SPA stub. - app.get('/gallery/:slug/:token?', (req, res, next) => { + // + // Two route shapes — 1-2 segments (`/gallery/:slug/:token?`) and the + // 3-segment slideshow form (`/gallery/:slug/show/:token`). The slideshow + // shape was previously falling through to the SPA-catchall below and + // skipping OG injection entirely (#699). Both patterns route to the + // same handler — buildOgMetadata only looks at `slug`, so the extra + // /show/ segment is harmless. + const ogIntercept = (req, res, next) => { if (isSocialCrawler(req.get('user-agent'))) { return handleGalleryOgRequest(req, res); } return next(); - }, (req, res) => res.sendFile(indexPath)); + }; + app.get('/gallery/:slug/:token?', ogIntercept, (req, res) => res.sendFile(indexPath)); + app.get('/gallery/:slug/show/:token', ogIntercept, (req, res) => res.sendFile(indexPath)); app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => { res.sendFile(indexPath); diff --git a/backend/src/__tests__/galleryOgService.shareImage.test.js b/backend/src/__tests__/galleryOgService.shareImage.test.js index 8b2d04fa..fd5f8b2e 100644 --- a/backend/src/__tests__/galleryOgService.shareImage.test.js +++ b/backend/src/__tests__/galleryOgService.shareImage.test.js @@ -208,6 +208,79 @@ function makeRes() { return res; } +// ---- buildOgMetadata: share-token fallback (#699) ---------------------- +// +// The public share URL after migration 525's short-URLs option strips the +// slug down to `/gallery/<32-hex-share-token>`. The OG handler was looking +// up that token as if it were a slug, finding nothing, and serving the +// generic site-wide OG instead of the event-specific one (alex's symptom +// in #699 — Cloudflare Worker had to compensate). resolveSlug now falls +// back to events.share_token when the slug shape matches a 32-char hex. + +describe('buildOgMetadata — share-token fallback', () => { + it('resolves a 32-char hex slug via the share_token column when no slug match', async () => { + // Obviously-fake 32-hex test fixture — GitGuardian flagged a + // real-looking token (copied from the bug report) as a Generic + // High Entropy Secret. Using a non-entropy literal sidesteps the + // heuristic without changing what the test pins. + const token = '00000000000000000000000000000001'; + const event = { + id: 10, + slug: 'senior-2026-06-05', + share_token: token, + event_name: 'Senior Photo Gallery', + event_date: '2026-06-05', + welcome_message: null, + hero_photo_id: null, + og_image_share_enabled: false, + }; + // First db() — events.where('slug', token) returns null. + db.mockImplementationOnce(() => chain({ first: null })); + db.schema = { hasTable: jest.fn().mockResolvedValue(false) }; + // Second db() — events.where('share_token', token) returns the event. + db.mockImplementationOnce(() => chain({ first: event })); + mockBranding(); + + const meta = await buildOgMetadata(token, `/gallery/${token}`); + + // Rich event-specific OG, not the site-wide fallback. + expect(meta.title).toContain('Senior Photo Gallery'); + expect(meta.eventName).toBe('Senior Photo Gallery'); + // og:url canonicalises to the slug-based URL even when the share-token + // URL was the entry point — keeps social-share canonicals stable. + expect(meta.url).toBe('https://gallery.example.com/gallery/senior-2026-06-05'); + }); + + it('returns the site-wide fallback when the 32-hex slug matches NO event at all', async () => { + // Defensive: a malformed/expired token shouldn't 500 or leak any + // event info — it must look identical to the generic fallback path. + const token = '00000000000000000000000000000002'; + db.mockImplementationOnce(() => chain({ first: null })); + db.schema = { hasTable: jest.fn().mockResolvedValue(false) }; + db.mockImplementationOnce(() => chain({ first: null })); // share_token also misses + mockBranding(); + + const meta = await buildOgMetadata(token, `/gallery/${token}`); + + expect(meta.title).toBe('PicPeak'); + expect(meta.eventName).toBeUndefined(); + }); + + it('does NOT attempt the share_token lookup for slugs that don\'t look like a 32-char hex', async () => { + // Real slugs are kebab/dot/underscore mixes — never pure 32-hex. + // Skipping the extra query keeps the un-needed-DB-hit cost off the + // hot path for every legitimate slug. + mockResolveSlug(null); // events lookup misses; no redirects table + mockBranding(); + + await buildOgMetadata('senior-2026-06-05', '/gallery/senior-2026-06-05'); + + // Only 2 db() calls — events + app_settings. No share_token + // fallback was attempted for a non-hex slug. + expect(db).toHaveBeenCalledTimes(2); + }); +}); + describe('handleGalleryOgCover — 404 unless explicitly opted in', () => { it('returns 400 on an invalid slug shape', async () => { const req = { params: { slug: '../../etc/passwd' }, headers: {} }; diff --git a/backend/src/services/galleryOgService.js b/backend/src/services/galleryOgService.js index e5c59891..a1ed8ef9 100644 --- a/backend/src/services/galleryOgService.js +++ b/backend/src/services/galleryOgService.js @@ -91,7 +91,19 @@ async function resolveSlug(slug) { event = await db('events').where('slug', redirect.new_slug).first(); } } - return event || null; + if (event) return event; + // Fall back to share-token lookup (#699). Operators share both + // forms — the slug-based URL (`/gallery/` after #525's + // short-URLs option strips the slug into the token-only form) AND + // the historical token URL (`/gallery/<32-hex>`). The token URL + // would otherwise route here with `slug=`, fail the slug + // lookup, and serve the fallback site-wide OG — which is what + // alex hit when he ran the Cloudflare Worker as a workaround. + if (/^[a-f0-9]{32}$/i.test(slug)) { + event = await db('events').where('share_token', slug).first(); + if (event) return event; + } + return null; } function escapeHtml(value) {