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;
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Branded URL shortener for gallery share links (#699).
|
||||
*
|
||||
* Admins create `/s/<short_slug>` URLs that resolve to a gallery's full
|
||||
* link AND answer social-crawler scrapes with the gallery's OG preview.
|
||||
* The short URL is what photographers actually paste into chat — the
|
||||
* og:url canonical points back at the short URL itself, so each social
|
||||
* platform's cache is keyed on the slug the operator chose, not the
|
||||
* underlying gallery URL that may rotate.
|
||||
*
|
||||
* Behaviour decisions worth pinning here (and in the migration comment):
|
||||
* - Soft-delete with `deleted_at` so an accidental delete is recoverable.
|
||||
* Public route serves 410 Gone (not 404) on a soft-deleted slug so
|
||||
* the admin sees their delete was intentional in scrapes/logs.
|
||||
* - Re-creating a soft-deleted slug rotates ownership: the old row is
|
||||
* hard-deleted, the new row is created. The UNIQUE constraint on
|
||||
* short_slug enforces this — you can't have two live rows for the
|
||||
* same public path.
|
||||
* - target_path is captured AT CREATE TIME from the event's current
|
||||
* state (slug + share_token + the global "Use short gallery URLs"
|
||||
* toggle). A later flip of that toggle doesn't silently change
|
||||
* where existing short URLs resolve. Same principle as quote PDFs
|
||||
* snapshotting at issuance time.
|
||||
*/
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
|
||||
// Slug rules:
|
||||
// - Lowercase a-z, digits, hyphens only
|
||||
// - Must start with a letter or digit (no leading hyphen, no double-hyphen-leading)
|
||||
// - 1-64 chars
|
||||
// - Trailing hyphen disallowed to keep URLs tidy
|
||||
const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
||||
|
||||
// Reserved top-level paths the application already uses. Allowing a
|
||||
// short URL to shadow any of these would break either the app itself
|
||||
// (admin/api/auth) or future routes we may add (assets/static). The
|
||||
// public route is mounted at `/s/<slug>` so technically the only real
|
||||
// risk is shadowing other things mounted at `/s/...` — but operators
|
||||
// occasionally point Cloudflare rules at top-level paths, and keeping
|
||||
// a sane blocklist costs nothing.
|
||||
const RESERVED_SLUGS = new Set([
|
||||
'admin', 'api', 'auth', 'assets', 'static', 'public', 'gallery',
|
||||
'og', 'health', 'metrics', 'robots.txt', 's', 'docs', 'login',
|
||||
'logout', 'signup', 'register', 'reset', 'reset-password', 'app',
|
||||
'manifest.json', 'favicon.ico', 'sitemap.xml',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate a candidate short slug.
|
||||
* @returns {string|null} null if valid; otherwise a human-readable reason.
|
||||
*/
|
||||
function validateSlug(slug) {
|
||||
if (typeof slug !== 'string') return 'short_slug must be a string';
|
||||
const trimmed = slug.trim();
|
||||
if (!trimmed) return 'short_slug cannot be empty';
|
||||
if (trimmed.length > 64) return 'short_slug must be at most 64 characters';
|
||||
if (!SLUG_REGEX.test(trimmed)) {
|
||||
return 'short_slug must be lowercase letters, digits, and hyphens, starting and ending with a letter or digit';
|
||||
}
|
||||
if (RESERVED_SLUGS.has(trimmed)) return 'short_slug is reserved';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the target path for a gallery short URL based on the event's
|
||||
* current state + the global "Use short gallery URLs" setting. Snapshot
|
||||
* this value at create time so future toggle flips don't silently
|
||||
* change what existing short URLs resolve to.
|
||||
*/
|
||||
async function targetPathForEvent(event) {
|
||||
if (!event) throw new Error('event required');
|
||||
const useShortGallery = (await getAppSetting('general_use_short_gallery_urls', false)) === true;
|
||||
if (useShortGallery && event.share_token) {
|
||||
return `/gallery/${event.share_token}`;
|
||||
}
|
||||
return `/gallery/${event.slug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build candidate auto-generated slugs in preference order. Walks each
|
||||
* candidate against the UNIQUE constraint and returns the first that's
|
||||
* free. Falls back to a 6-char random alphanum if every shaped
|
||||
* candidate collides.
|
||||
*
|
||||
* <slug> — when short and clean
|
||||
* <slug>-<year> — e.g. senior-2026
|
||||
* <slug>-<random> — last-ditch
|
||||
*/
|
||||
async function autoGenerateSlug(event) {
|
||||
const base = String(event.slug || '').toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48); // leave headroom for suffix
|
||||
const year = event.event_date
|
||||
? new Date(event.event_date).getFullYear()
|
||||
: new Date().getFullYear();
|
||||
|
||||
const candidates = [];
|
||||
if (base) candidates.push(base);
|
||||
if (base) candidates.push(`${base}-${year}`);
|
||||
|
||||
for (const cand of candidates) {
|
||||
const validity = validateSlug(cand);
|
||||
if (validity) continue; // skip if it'd fail validation (e.g. trailing hyphen)
|
||||
const taken = await db('gallery_short_urls')
|
||||
.where({ short_slug: cand })
|
||||
.whereNull('deleted_at')
|
||||
.first();
|
||||
if (!taken) return cand;
|
||||
}
|
||||
|
||||
// Random fallback. Six alphanum chars = ~31 bits of entropy; for a
|
||||
// namespace of at-most-N-galleries-per-instance this is more than
|
||||
// enough to avoid collisions in practice.
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const random = Math.random().toString(36).slice(2, 8).replace(/[^a-z0-9]/g, '');
|
||||
if (random.length < 6) continue;
|
||||
const taken = await db('gallery_short_urls')
|
||||
.where({ short_slug: random })
|
||||
.whereNull('deleted_at')
|
||||
.first();
|
||||
if (!taken) return random;
|
||||
}
|
||||
throw new Error('Failed to auto-generate a unique short slug after 5 attempts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a short URL for an event. `customSlug` is optional — when
|
||||
* absent, we auto-generate from the event slug + year.
|
||||
*
|
||||
* Returns { id, short_slug, target_path, ... }.
|
||||
*
|
||||
* Throws on collision with a structured error:
|
||||
* { code: 'SLUG_TAKEN', suggested: 'sofia-graduation-2' }
|
||||
*/
|
||||
async function createShortUrl({ eventId, customSlug = null, createdBy = null }) {
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
const err = new Error('Event not found');
|
||||
err.code = 'EVENT_NOT_FOUND';
|
||||
throw err;
|
||||
}
|
||||
|
||||
let slug;
|
||||
if (customSlug != null) {
|
||||
const lowered = String(customSlug).toLowerCase().trim();
|
||||
const validityError = validateSlug(lowered);
|
||||
if (validityError) {
|
||||
const err = new Error(validityError);
|
||||
err.code = 'INVALID_SLUG';
|
||||
throw err;
|
||||
}
|
||||
// Collision check (only against live rows; soft-deleted rows are
|
||||
// hard-deleted on conflict to keep the UNIQUE constraint sane).
|
||||
const existing = await db('gallery_short_urls')
|
||||
.where({ short_slug: lowered })
|
||||
.first();
|
||||
if (existing && !existing.deleted_at) {
|
||||
const err = new Error(`Short slug '${lowered}' is already in use`);
|
||||
err.code = 'SLUG_TAKEN';
|
||||
err.suggested = await autoGenerateSlug({ ...event, slug: lowered });
|
||||
throw err;
|
||||
}
|
||||
if (existing && existing.deleted_at) {
|
||||
// Soft-deleted row in the way of the UNIQUE constraint — purge
|
||||
// it so the admin can re-claim the slug. This is the intended
|
||||
// "yes, replace the old link" path; if the admin wanted the old
|
||||
// link back, they'd restore the soft-deleted row, not create a
|
||||
// new one.
|
||||
await db('gallery_short_urls').where({ id: existing.id }).delete();
|
||||
}
|
||||
slug = lowered;
|
||||
} else {
|
||||
slug = await autoGenerateSlug(event);
|
||||
}
|
||||
|
||||
const targetPath = await targetPathForEvent(event);
|
||||
|
||||
const inserted = await db('gallery_short_urls').insert({
|
||||
short_slug: slug,
|
||||
event_id: event.id,
|
||||
target_path: targetPath,
|
||||
created_by: createdBy,
|
||||
created_at: new Date(),
|
||||
hit_count: 0,
|
||||
}).returning(['id', 'short_slug', 'target_path', 'created_at']);
|
||||
|
||||
const row = inserted[0] || {};
|
||||
logger.info('gallery_short_urls: created', {
|
||||
shortSlug: slug, eventId: event.id, createdBy,
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
short_slug: row.short_slug || slug,
|
||||
event_id: event.id,
|
||||
target_path: row.target_path || targetPath,
|
||||
created_at: row.created_at,
|
||||
hit_count: 0,
|
||||
last_hit_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a short URL by its public slug. Returns null if not found.
|
||||
* Soft-deleted rows are NOT filtered out here — callers decide how to
|
||||
* present them (the public route uses presence-of-deleted_at to send
|
||||
* 410 Gone instead of 404).
|
||||
*/
|
||||
async function findByShortSlug(slug) {
|
||||
if (!slug || typeof slug !== 'string') return null;
|
||||
const lowered = slug.toLowerCase().trim();
|
||||
if (validateSlug(lowered)) return null; // malformed input — no lookup
|
||||
const row = await db('gallery_short_urls').where({ short_slug: lowered }).first();
|
||||
return row || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all live short URLs for an event, newest first.
|
||||
*/
|
||||
async function listForEvent(eventId) {
|
||||
const rows = await db('gallery_short_urls')
|
||||
.where({ event_id: eventId })
|
||||
.whereNull('deleted_at')
|
||||
.orderBy('created_at', 'desc')
|
||||
.select('id', 'short_slug', 'target_path', 'hit_count', 'last_hit_at', 'created_at', 'created_by');
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a short URL. Returns true if a row was affected, false
|
||||
* otherwise (caller can map false → 404).
|
||||
*/
|
||||
async function softDelete(id, deletedBy = null) {
|
||||
const affected = await db('gallery_short_urls')
|
||||
.where({ id })
|
||||
.whereNull('deleted_at')
|
||||
.update({ deleted_at: new Date(), deleted_by: deletedBy });
|
||||
if (affected) {
|
||||
logger.info('gallery_short_urls: soft-deleted', { id, deletedBy });
|
||||
}
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment hit_count + stamp last_hit_at. Called from the public route
|
||||
* AFTER the response has been queued so the user doesn't wait on the
|
||||
* write. Wrapped in try/catch so a DB blip can't 500 the redirect.
|
||||
*/
|
||||
async function recordHit(id) {
|
||||
try {
|
||||
await db('gallery_short_urls').where({ id }).update({
|
||||
hit_count: db.raw('hit_count + 1'),
|
||||
last_hit_at: new Date(),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('gallery_short_urls: recordHit failed (non-fatal)', {
|
||||
id, error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateSlug,
|
||||
targetPathForEvent,
|
||||
autoGenerateSlug,
|
||||
createShortUrl,
|
||||
findByShortSlug,
|
||||
listForEvent,
|
||||
softDelete,
|
||||
recordHit,
|
||||
// Exposed for tests
|
||||
_RESERVED_SLUGS: RESERVED_SLUGS,
|
||||
};
|
||||
Reference in New Issue
Block a user