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.
48 lines
1.5 KiB
TypeScript
48 lines
1.5 KiB
TypeScript
import { api } from '../config/api';
|
|
|
|
export interface GalleryShortUrl {
|
|
id: number;
|
|
short_slug: string;
|
|
target_path: string;
|
|
hit_count: number;
|
|
last_hit_at: string | null;
|
|
created_at: string;
|
|
created_by: number | null;
|
|
}
|
|
|
|
/**
|
|
* Branded URL shortener (#699). Each event can have multiple short URLs
|
|
* pointing at it; the public route lives at `/s/<short_slug>` and is
|
|
* bot-UA aware (serves OG to scrapers, 302 to browsers).
|
|
*/
|
|
export const shortUrlsService = {
|
|
async listForEvent(eventId: number): Promise<GalleryShortUrl[]> {
|
|
const { data } = await api.get(`/admin/events/${eventId}/short-urls`);
|
|
return data?.shortUrls ?? [];
|
|
},
|
|
|
|
/**
|
|
* Create a short URL for an event. `customSlug` is optional — omit to
|
|
* let the backend auto-generate from the event's slug + year.
|
|
*
|
|
* Surfaces structured errors:
|
|
* - 400 INVALID_SLUG → bad shape (letters/digits/hyphens, 1-64 chars)
|
|
* - 409 SLUG_TAKEN → another live row holds the slug; the response
|
|
* body includes `suggested` with an available
|
|
* alternative the caller can pre-fill in the
|
|
* input on retry.
|
|
*/
|
|
async create(
|
|
eventId: number,
|
|
customSlug?: string,
|
|
): Promise<GalleryShortUrl> {
|
|
const body = customSlug ? { customSlug } : {};
|
|
const { data } = await api.post(`/admin/events/${eventId}/short-urls`, body);
|
|
return data;
|
|
},
|
|
|
|
async remove(id: number): Promise<void> {
|
|
await api.delete(`/admin/short-urls/${id}`);
|
|
},
|
|
};
|