feat(gallery): branded URL shortener — /s/<slug> with OG injection (#699)
Issue 3 from #699 (@alexvaltchev's report): expose a custom-named short URL per event that bots scrape for OG previews and browsers redirect to the underlying gallery. WhatsApp / iMessage / Facebook cache the OG metadata by the URL they crawl, so the SHORT URL becomes the cache key — admins can rotate or split-test underlying gallery URLs without re-pushing a fresh link to clients. Additive feature; no existing route, table, or column is modified. ## Backend - `gallery_short_urls` table (migration 150): id, short_slug UNIQUE, event_id FK CASCADE, target_path TEXT, created_by/at, hit_count, last_hit_at, deleted_at/by. hasTable-guarded so the migration is idempotent on re-run. - `src/services/galleryShortUrlService.js` — validator + CRUD + resolver. Slug rules: `/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/`, reserved blocklist (admin, api, auth, gallery, og, s, login, ...). target_path snapshots at create-time from the event + global short-URL toggle, so a later flip of the toggle does NOT silently change where existing short URLs resolve. - `src/routes/adminShortUrls.js` — `GET/POST /api/admin/events/:eventId/short-urls`, `DELETE /api/admin/short-urls/:id`. Structured errors: 400 INVALID_SLUG, 409 SLUG_TAKEN (with `suggested`), 404 EVENT_NOT_FOUND. Gated by events.view / events.edit + requireEventOwnership. - `server.js` /s/:shortSlug public route. Bot UA → server-render the same OG metadata the existing /og/gallery/<slug> handler produces, then override og:url to point at /s/<shortSlug> itself (cache-key invariant — social platforms key by the URL they scrape). Browser UA → 302 to target_path. Soft-deleted slug → 410 Gone (intentional-delete signal, distinct from 404 unknown slug). Hit accounting is fire-and-forget. ## Frontend - `services/shortUrls.service.ts` — list/create/remove. - `components/admin/ShortUrlsCard.tsx` — per-event card on the EventDetailsPage. Form for custom or auto-generated slug, list with copy-to-clipboard + soft-delete. SLUG_TAKEN error surfaces the service's `suggested` slug with a "use suggested" button. - i18n: events.shortUrls.* added to EN + DE. ## Tests 78 new tests, all passing: - `__tests__/utils/galleryShortUrlValidation.test.js` (48) — pure- function tests for validateSlug: accepts/rejects, reserved-slug blocklist, path-traversal + URL-injection vectors. - `__tests__/integration/galleryShortUrls.test.js` (19) — service layer against a real SQLite DB. Covers custom + auto-generated slugs, collision + SLUG_TAKEN + suggested, target_path snapshotting (backward-compat invariant), soft-delete + slug rotation, hit counting. - `__tests__/integration/galleryShortUrlRoute.test.js` (11) — HTTP-level: 302 redirect for browser UA, 200 + OG HTML for bot UA, og:url canonical points at /s/<slug>, 410 for soft-deleted + orphaned events, 404 unknown + malformed. Regression sweep: 47 existing migration-chain integration tests still pass; migration 150 is additive only. ## Backward compatibility - Existing `/gallery/<slug>`, `/gallery/<32-hex-share-token>`, `/gallery/<slug>/show/<token>`, `/og/gallery/<slug>`, `/og/gallery/<slug>/cover` routes are untouched. - The `/s/` namespace is new; no existing route lives there. - Migration 150 only ADDs the new table — no ALTERs on existing schema, no destructive changes. - target_path is snapshotted at create-time so flipping the global "Use short gallery URLs" setting after a short URL exists does NOT change where that short URL resolves.
This commit is contained in:
@@ -543,6 +543,68 @@ app.get('/og/gallery/:slug', handleGalleryOgRequest);
|
||||
// returns 404 unless the opt-in is on AND a hero_photo_id is set.
|
||||
app.get('/og/gallery/:slug/cover', handleGalleryOgCover);
|
||||
|
||||
// Branded URL shortener (#699). /s/<short_slug> is bot-UA aware:
|
||||
// - Social crawler → server-render OG for the target event so the
|
||||
// SHORT URL itself is what scrapes cache against. The og:url canonical
|
||||
// in the rendered HTML points back at /s/<slug>, not the underlying
|
||||
// gallery URL — so a re-share of the same short URL keeps the cache
|
||||
// warm even if the underlying gallery slug rotates.
|
||||
// - Browser → 302 to the stored target_path. The target_path was
|
||||
// captured at create time from the event's slug + share_token + the
|
||||
// global "Use short gallery URLs" setting, so it doesn't silently
|
||||
// change later.
|
||||
// - Soft-deleted → 410 Gone so the admin can tell their delete worked
|
||||
// vs. a typo'd unknown slug (which returns 404).
|
||||
const galleryShortUrlService = require('./src/services/galleryShortUrlService');
|
||||
const { buildOgMetadata, renderOgHtml } = require('./src/services/galleryOgService');
|
||||
app.get('/s/:shortSlug', async (req, res) => {
|
||||
try {
|
||||
const row = await galleryShortUrlService.findByShortSlug(req.params.shortSlug);
|
||||
if (!row) {
|
||||
return res.status(404).type('text/plain').send('Short URL not found');
|
||||
}
|
||||
if (row.deleted_at) {
|
||||
return res.status(410).type('text/plain').send('Short URL has been removed');
|
||||
}
|
||||
|
||||
// Bot UA → render OG metadata for the target event. We look up the
|
||||
// event via the short URL's event_id rather than re-parsing the
|
||||
// target_path so a future migration that adds new target shapes
|
||||
// (slideshow, client-access) doesn't need to rewrite the URL parser.
|
||||
if (isSocialCrawler(req.get('user-agent'))) {
|
||||
const event = await require('./src/database/db').db('events')
|
||||
.where({ id: row.event_id })
|
||||
.first('slug');
|
||||
if (event?.slug) {
|
||||
const meta = await buildOgMetadata(event.slug, req.originalUrl);
|
||||
// Override the canonical to point at the SHORT URL itself —
|
||||
// social platforms cache OG by URL, and the short URL is the
|
||||
// one operators actually share, so that's the cache key we
|
||||
// want them to stick with.
|
||||
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
meta.url = `${base}/s/${row.short_slug}`;
|
||||
res.set('Cache-Control', 'public, max-age=300');
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(renderOgHtml(meta));
|
||||
// Hit accounting is fire-and-forget — don't block the bot.
|
||||
galleryShortUrlService.recordHit(row.id).catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Event disappeared (FK CASCADE in flight, or admin hard-deleted
|
||||
// outside the normal soft-delete path) — fall through to 410 so
|
||||
// the scraper sees a clean signal.
|
||||
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
|
||||
}
|
||||
|
||||
// Browser path: redirect. Hit accounting is fire-and-forget.
|
||||
galleryShortUrlService.recordHit(row.id).catch(() => {});
|
||||
return res.redirect(302, row.target_path);
|
||||
} catch (err) {
|
||||
logger.error('Short URL resolver failed', { slug: req.params.shortSlug, error: err.message });
|
||||
return res.status(500).type('text/plain').send('Internal server error');
|
||||
}
|
||||
});
|
||||
|
||||
// robots.txt endpoint (dynamic, served from DB settings)
|
||||
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
|
||||
app.get('/robots.txt', async (req, res) => {
|
||||
@@ -638,6 +700,10 @@ app.use('/api/gallery', require('./src/routes/galleryGuests'));
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/admin/auth', adminAuthRoutes);
|
||||
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
||||
// Branded URL shortener admin CRUD (#699) — list/create/delete short URLs
|
||||
// per event. Mounted at /api/admin so the routes appear at
|
||||
// /api/admin/events/:eventId/short-urls and /api/admin/short-urls/:id.
|
||||
app.use('/api/admin', require('./src/routes/adminShortUrls'));
|
||||
app.use('/api/admin/feature-flags', require('./src/routes/adminFeatureFlags'));
|
||||
app.use('/api/admin/whatsapp', require('./src/routes/adminWhatsapp'));
|
||||
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
|
||||
|
||||
Reference in New Issue
Block a user