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,114 @@
|
||||
/**
|
||||
* Admin CRUD for the branded URL shortener (#699).
|
||||
*
|
||||
* - GET /api/admin/events/:eventId/short-urls — list per event
|
||||
* - POST /api/admin/events/:eventId/short-urls — create (custom or auto-generated slug)
|
||||
* - DELETE /api/admin/short-urls/:id — soft-delete
|
||||
*
|
||||
* All paths require admin auth + `settings.view` permission (read) /
|
||||
* `events.edit` permission (mutate) — short URLs are a per-event admin
|
||||
* concern, gated by the same permission as editing the event itself.
|
||||
*/
|
||||
const express = require('express');
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const galleryShortUrlService = require('../services/galleryShortUrlService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(adminAuth);
|
||||
|
||||
/**
|
||||
* GET /api/admin/events/:eventId/short-urls
|
||||
* List live short URLs for an event.
|
||||
*/
|
||||
router.get(
|
||||
'/events/:eventId/short-urls',
|
||||
requirePermission('events.view'),
|
||||
param('eventId').isInt({ min: 1 }),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
try {
|
||||
const rows = await galleryShortUrlService.listForEvent(parseInt(req.params.eventId, 10));
|
||||
res.json({ shortUrls: rows });
|
||||
} catch (err) {
|
||||
logger.error('adminShortUrls.list failed', { error: err.message, eventId: req.params.eventId });
|
||||
res.status(500).json({ error: 'Failed to list short URLs' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/admin/events/:eventId/short-urls
|
||||
* Body: { customSlug?: string } — omit for auto-generated slug.
|
||||
*/
|
||||
router.post(
|
||||
'/events/:eventId/short-urls',
|
||||
requirePermission('events.edit'),
|
||||
param('eventId').isInt({ min: 1 }),
|
||||
body('customSlug').optional({ nullable: true })
|
||||
.isString().isLength({ min: 1, max: 64 }),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
try {
|
||||
const row = await galleryShortUrlService.createShortUrl({
|
||||
eventId: parseInt(req.params.eventId, 10),
|
||||
customSlug: req.body.customSlug || null,
|
||||
createdBy: req.admin?.id || null,
|
||||
});
|
||||
res.status(201).json(row);
|
||||
} catch (err) {
|
||||
// Structured-error fallthrough — the service tags collisions and
|
||||
// validation failures with a `code` so the UI can surface a
|
||||
// useful message + a suggested alternative slug.
|
||||
if (err.code === 'INVALID_SLUG') {
|
||||
return res.status(400).json({ error: err.message, code: err.code });
|
||||
}
|
||||
if (err.code === 'SLUG_TAKEN') {
|
||||
return res.status(409).json({
|
||||
error: err.message, code: err.code, suggested: err.suggested,
|
||||
});
|
||||
}
|
||||
if (err.code === 'EVENT_NOT_FOUND') {
|
||||
return res.status(404).json({ error: err.message, code: err.code });
|
||||
}
|
||||
logger.error('adminShortUrls.create failed', { error: err.message });
|
||||
res.status(500).json({ error: 'Failed to create short URL' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/short-urls/:id
|
||||
* Soft-delete. The public route serves 410 Gone on a deleted row so the
|
||||
* admin can tell their delete worked (vs. 404 for an unknown slug).
|
||||
*/
|
||||
router.delete(
|
||||
'/short-urls/:id',
|
||||
requirePermission('events.edit'),
|
||||
param('id').isInt({ min: 1 }),
|
||||
async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
try {
|
||||
const ok = await galleryShortUrlService.softDelete(
|
||||
parseInt(req.params.id, 10),
|
||||
req.admin?.id || null,
|
||||
);
|
||||
if (!ok) return res.status(404).json({ error: 'Short URL not found' });
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
logger.error('adminShortUrls.delete failed', { error: err.message });
|
||||
res.status(500).json({ error: 'Failed to delete short URL' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user