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,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 (1–64 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user