fix(og): rich social previews for share-token + slideshow URLs (#699)

Two SSR-OG injection bugs reported by @alexvaltchev. Both made his link
previews fall back to the brand logo + site-wide tagline instead of the
event-specific name/photo, even though the bot UA was hitting our
already-existing OG handler. He compensated with a Cloudflare Worker as
SSR middleware — which then created bug 3 below (og:image at the
auth-gated /api/.../hero/ path, not the public /og/.../cover one), so
Instagram never rendered the image either.

## Bug A — slideshow URLs miss the OG handler entirely

`/gallery/<slug>/show/<token>` has 3 segments after `/gallery/`. The OG
route was wired only at `/gallery/:slug/:token?` (1-2 segments), so
slideshow links fell through to the SPA-catchall `/gallery/*` and never
invoked the OG handler at all. Added a second route handler for the
3-segment slideshow shape, sharing the same intercept middleware so a
recognised social crawler still gets the rich preview.

## Bug B — share-token-only URLs resolve to nothing

`/gallery/<32-char-share-token>` (the form produced when migration 525's
short-URLs option strips the event slug) routes to the OG handler with
`slug=<token>`. resolveSlug then queries `events.slug = <token>`, which
never matches because the token is in a separate `share_token` column.
Result: falls through to the "no event found" branch and serves the
generic site-wide OG.

Fix: when the slug shape matches a 32-char hex AND the slug lookup
missed AND no redirect rule applies, try `events.share_token = slug` as
a final fallback. Real slugs are kebab/dot/underscore mixes, never pure
32-hex, so the extra DB roundtrip is gated to only fire for the
token-shaped URL.

## Tests

3 new tests in galleryOgService.shareImage.test.js using non-entropy
32-hex fixtures (deliberately zero-padded to avoid tripping
GitGuardian's Generic High Entropy Secret detector while still
matching the route's /^[a-f0-9]{32}$/i shape check):
- share-token slug resolves via the share_token column (alex's case)
- malformed/expired 32-hex token returns the site-wide fallback (no leak)
- non-hex slugs skip the share_token query entirely (hot-path cost guarded)

All 14 tests in the file pass.

## Out of scope here (separate follow-up)

- Issue 2 (Instagram og:image) — alex-side CF Worker bug pointing
  og:image at /api/gallery/<slug>/hero/<id>, which requires gallery
  auth. PicPeak already has the right unauthenticated path
  (/og/gallery/<slug>/cover) gated by events.og_image_share_enabled
  per-event opt-in (#474). Documented in the issue reply.
- Issue 3 (URL shortener with custom names) — real feature request,
  meaningfully different from the existing #525 short-URLs option that
  just strips the slug. Designing separately.
This commit is contained in:
Paul Nothaft
2026-06-30 16:03:49 +02:00
parent de789faec5
commit 1b8747dc82
3 changed files with 97 additions and 3 deletions
+11 -2
View File
@@ -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);
@@ -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: {} };
+13 -1
View File
@@ -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/<slug>` 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=<token>`, 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) {