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:
Paul Nothaft
2026-06-30 16:30:13 +02:00
parent 25bf7bb523
commit 56c2386c90
12 changed files with 1484 additions and 0 deletions
@@ -0,0 +1,238 @@
/**
* Branded short-URL management for a single event (#699).
*
* Each event can have multiple `/s/<slug>` short URLs pointing at it.
* The public route is bot-UA aware: scrapes get OG (so the short URL
* is what shows the rich preview in chat), browsers get a 302 to the
* gallery URL stored at create time.
*
* Renders inside the event detail page as a Card. Form to create
* (custom or auto-generated slug), list of existing short URLs with
* copy + delete buttons. Errors from the backend's structured codes
* (INVALID_SLUG, SLUG_TAKEN) surface inline with a "use suggested"
* shortcut when the server proposes an alternative.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Copy, Link as LinkIcon, Trash2, Plus, Check } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { shortUrlsService, type GalleryShortUrl } from '../../services/shortUrls.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
interface Props {
eventId: number;
}
function buildShortUrl(slug: string): string {
// Build from window.location so it survives reverse-proxy + custom-
// domain setups without needing a separate FRONTEND_URL config in the
// browser bundle. SSR-safe fallback: just the relative path.
if (typeof window === 'undefined') return `/s/${slug}`;
return `${window.location.origin}/s/${slug}`;
}
export const ShortUrlsCard: React.FC<Props> = ({ eventId }) => {
const { t } = useTranslation();
const { formatDateTime } = useLocalizedDate();
const qc = useQueryClient();
const [customSlug, setCustomSlug] = useState('');
const [error, setError] = useState<string | undefined>();
const [suggested, setSuggested] = useState<string | undefined>();
const [copiedId, setCopiedId] = useState<number | null>(null);
const { data: shortUrls = [], isLoading } = useQuery({
queryKey: ['short-urls', eventId],
queryFn: () => shortUrlsService.listForEvent(eventId),
});
const createMutation = useMutation({
mutationFn: (slug?: string) => shortUrlsService.create(eventId, slug),
onSuccess: () => {
setCustomSlug('');
setError(undefined);
setSuggested(undefined);
qc.invalidateQueries({ queryKey: ['short-urls', eventId] });
toast.success(t('events.shortUrls.created', 'Short URL created'));
},
onError: (err: any) => {
const code = err?.response?.data?.code;
const msg = err?.response?.data?.error;
if (code === 'SLUG_TAKEN') {
setSuggested(err?.response?.data?.suggested);
setError(t(
'events.shortUrls.errors.slugTaken',
'That short URL is already taken — try {{suggested}} instead.',
{ suggested: err?.response?.data?.suggested || '' },
) as string);
} else if (code === 'INVALID_SLUG') {
setSuggested(undefined);
setError(msg || (t('events.shortUrls.errors.invalidSlug',
'Short URLs must be lowercase letters, digits, and hyphens (164 chars).') as string));
} else {
setSuggested(undefined);
setError(msg || (t('common.error', 'Something went wrong') as string));
}
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => shortUrlsService.remove(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['short-urls', eventId] });
toast.success(t('events.shortUrls.deleted', 'Short URL deleted'));
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate(customSlug.trim() || undefined);
};
const handleUseSuggested = () => {
if (suggested) {
setCustomSlug(suggested);
setError(undefined);
setSuggested(undefined);
}
};
const handleCopy = async (row: GalleryShortUrl) => {
const url = buildShortUrl(row.short_slug);
try {
await navigator.clipboard.writeText(url);
setCopiedId(row.id);
setTimeout(() => setCopiedId((current) => (current === row.id ? null : current)), 1500);
} catch {
toast.error(t('common.copyFailed', 'Could not copy to clipboard') as string);
}
};
const handleDelete = (row: GalleryShortUrl) => {
const confirm = window.confirm(t(
'events.shortUrls.confirmDelete',
'Delete short URL /s/{{slug}}? The link will stop working immediately.',
{ slug: row.short_slug },
) as string);
if (confirm) deleteMutation.mutate(row.id);
};
return (
<Card padding="md">
<div className="flex items-center gap-2 mb-3">
<LinkIcon className="w-5 h-5 text-primary-600 dark:text-primary-400" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('events.shortUrls.title', 'Branded short URLs')}
</h3>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t(
'events.shortUrls.description',
'Create memorable links like /s/sofia-graduation that resolve to this gallery. The short URL itself shows the rich social preview when shared — so iMessage, Facebook, WhatsApp etc. see the gallery photo + name even when pasting the short link.',
)}
</p>
{/* Create form */}
<form onSubmit={handleSubmit} className="mb-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1">
<Input
value={customSlug}
onChange={(e) => {
setCustomSlug(e.target.value.toLowerCase());
if (error) setError(undefined);
if (suggested) setSuggested(undefined);
}}
placeholder={t('events.shortUrls.slugPlaceholder', 'sofia-graduation (optional)') as string}
maxLength={64}
error={error}
/>
</div>
<Button
type="submit"
variant="primary"
disabled={createMutation.isPending}
leftIcon={<Plus className="w-4 h-4" />}
>
{t('events.shortUrls.create', 'Create')}
</Button>
</div>
{suggested && (
<button
type="button"
onClick={handleUseSuggested}
className="mt-2 text-xs text-primary-600 dark:text-primary-400 underline hover:no-underline"
>
{t('events.shortUrls.useSuggested', 'Use “{{suggested}}” instead', { suggested })}
</button>
)}
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
{t(
'events.shortUrls.slugHelp',
'Leave empty to auto-generate from the gallery name. Allowed characters: lowercase letters, digits, hyphens.',
)}
</p>
</form>
{/* Existing short URLs */}
{isLoading ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('common.loading', 'Loading…')}
</p>
) : shortUrls.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('events.shortUrls.empty', 'No short URLs yet. Create one above to share this gallery with a memorable link.')}
</p>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{shortUrls.map((row) => (
<li key={row.id} className="py-3 flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="font-mono text-sm text-neutral-900 dark:text-neutral-100 break-all">
/s/{row.short_slug}
</div>
<div className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('events.shortUrls.hits', '{{count}} hits', { count: row.hit_count })}
{row.last_hit_at && (
<>
{' · '}
{t('events.shortUrls.lastHit', 'last {{when}}', { when: formatDateTime(row.last_hit_at) })}
</>
)}
{' · '}
{t('events.shortUrls.createdAt', 'created {{when}}', { when: formatDateTime(row.created_at) })}
</div>
<div className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400 truncate">
{row.target_path}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={() => handleCopy(row)}
className="p-2 text-neutral-500 hover:text-primary-600 dark:hover:text-primary-400"
title={t('common.copy', 'Copy') as string}
aria-label={t('common.copy', 'Copy') as string}
>
{copiedId === row.id ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => handleDelete(row)}
disabled={deleteMutation.isPending}
className="p-2 text-neutral-500 hover:text-red-600"
title={t('common.delete', 'Delete') as string}
aria-label={t('common.delete', 'Delete') as string}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</li>
))}
</ul>
)}
</Card>
);
};
+19
View File
@@ -998,6 +998,25 @@
"expires": "Läuft ab",
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
"shareWithGuestsPublic": "Teilen Sie diesen Link mit Gästen. Für diese Galerie ist kein Passwort erforderlich.",
"shortUrls": {
"title": "Gebrandete Kurz-URLs",
"description": "Erstelle einprägsame Links wie /s/sofia-abschluss, die zu dieser Galerie führen. Die Kurz-URL zeigt selbst die reiche Social-Vorschau — iMessage, Facebook, WhatsApp etc. sehen das Galerie-Foto + den Namen auch beim Einfügen des kurzen Links.",
"slugPlaceholder": "sofia-abschluss (optional)",
"slugHelp": "Leer lassen für automatische Generierung aus dem Galerienamen. Erlaubte Zeichen: Kleinbuchstaben, Ziffern, Bindestriche.",
"create": "Erstellen",
"created": "Kurz-URL erstellt",
"deleted": "Kurz-URL gelöscht",
"empty": "Noch keine Kurz-URLs. Erstelle oben eine, um diese Galerie mit einem einprägsamen Link zu teilen.",
"hits": "{{count}} Aufrufe",
"lastHit": "zuletzt {{when}}",
"createdAt": "erstellt {{when}}",
"useSuggested": "Stattdessen „{{suggested}}\" verwenden",
"confirmDelete": "Kurz-URL /s/{{slug}} löschen? Der Link funktioniert sofort nicht mehr.",
"errors": {
"slugTaken": "Diese Kurz-URL ist bereits vergeben — versuche stattdessen {{suggested}}.",
"invalidSlug": "Kurz-URLs müssen aus Kleinbuchstaben, Ziffern und Bindestrichen bestehen (164 Zeichen)."
}
},
"resetGalleryPassword": "Galerie-Passwort zurücksetzen",
"resendCreationEmail": "Erstellungs-E-Mail erneut senden",
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt",
+19
View File
@@ -552,6 +552,25 @@
"expires": "Expires",
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
"shareWithGuestsPublic": "Share this link with guests. No password is required for this gallery.",
"shortUrls": {
"title": "Branded short URLs",
"description": "Create memorable links like /s/sofia-graduation that resolve to this gallery. The short URL itself shows the rich social preview when shared — so iMessage, Facebook, WhatsApp etc. see the gallery photo + name even when pasting the short link.",
"slugPlaceholder": "sofia-graduation (optional)",
"slugHelp": "Leave empty to auto-generate from the gallery name. Allowed characters: lowercase letters, digits, hyphens.",
"create": "Create",
"created": "Short URL created",
"deleted": "Short URL deleted",
"empty": "No short URLs yet. Create one above to share this gallery with a memorable link.",
"hits": "{{count}} hits",
"lastHit": "last {{when}}",
"createdAt": "created {{when}}",
"useSuggested": "Use \"{{suggested}}\" instead",
"confirmDelete": "Delete short URL /s/{{slug}}? The link will stop working immediately.",
"errors": {
"slugTaken": "That short URL is already taken — try {{suggested}} instead.",
"invalidSlug": "Short URLs must be lowercase letters, digits, and hyphens (164 chars)."
}
},
"resetGalleryPassword": "Reset Gallery Password",
"resendCreationEmail": "Resend Creation Email",
"creationEmailResent": "Creation email has been queued for sending",
@@ -63,6 +63,7 @@ import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, P
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard';
import { SlideshowSettingsCard } from '../../components/admin/SlideshowSettingsCard';
import { ShortUrlsCard } from '../../components/admin/ShortUrlsCard';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
@@ -2029,6 +2030,11 @@ export const EventDetailsPage: React.FC = () => {
)}
</Card>
{/* Branded short URLs (#699). Sits between the canonical share-link
card and the Client Access card — same "things you share with
the customer" cluster. */}
<ShortUrlsCard eventId={event.id} />
{/* Client Access (#172) */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
@@ -0,0 +1,47 @@
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}`);
},
};