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:
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Migration 150: branded URL shortener for gallery share links (#699).
|
||||
*
|
||||
* Lets admins create custom-named short URLs that resolve to a gallery's
|
||||
* full link (e.g. `/s/sofia-graduation` → `/gallery/<slug>`). The short
|
||||
* URL itself answers bot-UA requests with server-rendered OG metadata,
|
||||
* so the SHORT URL is the one that shows the rich preview in iMessage /
|
||||
* Facebook / WhatsApp — not just the destination.
|
||||
*
|
||||
* Backward-compat invariant: this migration only ADDS a new table. No
|
||||
* existing route, table, or column is touched. Operators upgrading
|
||||
* through this migration can opt into creating short URLs per event,
|
||||
* but every existing `/gallery/...` link continues to resolve identically
|
||||
* — the new feature is additive.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (await knex.schema.hasTable('gallery_short_urls')) return;
|
||||
|
||||
await knex.schema.createTable('gallery_short_urls', (t) => {
|
||||
t.increments('id').primary();
|
||||
// Public-facing slug — what appears in /s/<short_slug>. Case-folded
|
||||
// to lowercase at write time by the service; the UNIQUE index here
|
||||
// is the last line of defence against collisions.
|
||||
t.string('short_slug', 64).notNullable().unique();
|
||||
// Hard FK to events — when an admin deletes an event, its short
|
||||
// URLs go with it. ON DELETE CASCADE is the natural model: a short
|
||||
// URL that points at a vanished gallery has no useful behaviour.
|
||||
t.integer('event_id').notNullable()
|
||||
.references('id').inTable('events').onDelete('CASCADE');
|
||||
// Where the short URL resolves to — usually `/gallery/<slug>` or
|
||||
// `/gallery/<share_token>` depending on the operator's #525
|
||||
// "Use short gallery URLs" setting at create time. Stored at create
|
||||
// time so a later flip of the global toggle doesn't silently change
|
||||
// what existing short URLs redirect to.
|
||||
t.text('target_path').notNullable();
|
||||
// For the audit trail + admin UI ("created by Alex two days ago").
|
||||
t.integer('created_by').references('id').inTable('admin_users');
|
||||
t.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
|
||||
// Tiny analytics — admins want to know "is this branded link
|
||||
// actually being clicked?" without a separate analytics service.
|
||||
t.integer('hit_count').notNullable().defaultTo(0);
|
||||
t.timestamp('last_hit_at');
|
||||
// Soft-delete semantics: a deleted short URL returns 410 Gone (not
|
||||
// 404) so the admin sees their delete was intentional, and so a
|
||||
// re-create with the same slug is an explicit "yes, replace" rather
|
||||
// than accidentally taking over a stale link. The UNIQUE constraint
|
||||
// on short_slug means re-create after delete requires either NULLing
|
||||
// the deleted row's slug or hard-deleting it; service layer handles
|
||||
// that explicitly.
|
||||
t.timestamp('deleted_at');
|
||||
t.integer('deleted_by').references('id').inTable('admin_users');
|
||||
});
|
||||
|
||||
// Read patterns:
|
||||
// - /s/:slug hot path — UNIQUE constraint on short_slug already
|
||||
// provides the index. No additional index needed.
|
||||
// - Admin UI "list short URLs for this event" — index event_id.
|
||||
await knex.schema.alterTable('gallery_short_urls', (t) => {
|
||||
t.index(['event_id'], 'gallery_short_urls_event_id_idx');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasTable('gallery_short_urls')) {
|
||||
await knex.schema.dropTable('gallery_short_urls');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user