Merge pull request #700 from PicPeak/fix/og-injection-share-token-and-slideshow-699
fix(og): rich social previews for share-token + slideshow URLs (#699)
This commit is contained in:
+11
-2
@@ -769,12 +769,21 @@ try {
|
|||||||
// SPA fallback for admin + gallery routes. For gallery URLs we intercept
|
// SPA fallback for admin + gallery routes. For gallery URLs we intercept
|
||||||
// social-crawler User-Agents and serve OG/Twitter-card metadata so link
|
// social-crawler User-Agents and serve OG/Twitter-card metadata so link
|
||||||
// previews show the event name + branding instead of the SPA stub.
|
// 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'))) {
|
if (isSocialCrawler(req.get('user-agent'))) {
|
||||||
return handleGalleryOgRequest(req, res);
|
return handleGalleryOgRequest(req, res);
|
||||||
}
|
}
|
||||||
return next();
|
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) => {
|
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
|
||||||
res.sendFile(indexPath);
|
res.sendFile(indexPath);
|
||||||
|
|||||||
@@ -208,6 +208,79 @@ function makeRes() {
|
|||||||
return res;
|
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', () => {
|
describe('handleGalleryOgCover — 404 unless explicitly opted in', () => {
|
||||||
it('returns 400 on an invalid slug shape', async () => {
|
it('returns 400 on an invalid slug shape', async () => {
|
||||||
const req = { params: { slug: '../../etc/passwd' }, headers: {} };
|
const req = { params: { slug: '../../etc/passwd' }, headers: {} };
|
||||||
|
|||||||
@@ -91,7 +91,19 @@ async function resolveSlug(slug) {
|
|||||||
event = await db('events').where('slug', redirect.new_slug).first();
|
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) {
|
function escapeHtml(value) {
|
||||||
|
|||||||
Reference in New Issue
Block a user