56c2386c90
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.
121 lines
3.7 KiB
JavaScript
121 lines
3.7 KiB
JavaScript
/**
|
|
* Pure-function tests for the slug validator in galleryShortUrlService.
|
|
* The validator is the security boundary for the `/s/<slug>` public
|
|
* route — bad shapes leak into a UNIQUE column that's used in URLs
|
|
* without further escaping, so the rules need to be tight.
|
|
*/
|
|
|
|
// Provide a minimal db stub so requiring the service doesn't crash —
|
|
// the validator path doesn't touch the DB.
|
|
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
|
jest.mock('../../src/utils/logger', () => ({
|
|
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
|
}));
|
|
jest.mock('../../src/utils/appSettings', () => ({
|
|
getAppSetting: jest.fn().mockResolvedValue(false),
|
|
}));
|
|
|
|
const {
|
|
validateSlug,
|
|
_RESERVED_SLUGS,
|
|
} = require('../../src/services/galleryShortUrlService');
|
|
|
|
describe('validateSlug', () => {
|
|
describe('accepts', () => {
|
|
test.each([
|
|
'sofia-graduation',
|
|
'sofia',
|
|
'a', // single char (alphanumeric)
|
|
'1', // single digit
|
|
'abc123',
|
|
'123-abc',
|
|
'sofia-2026-06-05',
|
|
'sofia-2026',
|
|
'a-b-c-d',
|
|
'wedding-2026',
|
|
'xK7p2'.toLowerCase(), // lowercase 5-char
|
|
'a'.repeat(64), // exactly at the limit
|
|
])('%j', (slug) => {
|
|
expect(validateSlug(slug)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('rejects', () => {
|
|
test.each([
|
|
['', 'cannot be empty'],
|
|
[' ', 'cannot be empty'], // trimmed → empty
|
|
['-sofia', 'lowercase letters'], // leading hyphen
|
|
['sofia-', 'lowercase letters'], // trailing hyphen
|
|
['Sofia', 'lowercase letters'], // uppercase
|
|
['sofia_graduation', 'lowercase letters'], // underscore
|
|
['sofia.graduation', 'lowercase letters'], // dot
|
|
['sofia graduation', 'lowercase letters'], // space
|
|
['sofia/graduation', 'lowercase letters'], // slash (path traversal vector)
|
|
['sofia%20graduation', 'lowercase letters'],
|
|
['a'.repeat(65), 'at most 64'], // one over limit
|
|
])('%j → %s', (slug, expectedReason) => {
|
|
const result = validateSlug(slug);
|
|
expect(result).not.toBeNull();
|
|
expect(result.toLowerCase()).toContain(expectedReason);
|
|
});
|
|
|
|
test('null', () => {
|
|
expect(validateSlug(null)).toContain('must be a string');
|
|
});
|
|
|
|
test('undefined', () => {
|
|
expect(validateSlug(undefined)).toContain('must be a string');
|
|
});
|
|
|
|
test('number', () => {
|
|
expect(validateSlug(42)).toContain('must be a string');
|
|
});
|
|
|
|
test('object', () => {
|
|
expect(validateSlug({})).toContain('must be a string');
|
|
});
|
|
});
|
|
|
|
describe('reserved slugs', () => {
|
|
test.each([
|
|
'admin',
|
|
'api',
|
|
'auth',
|
|
'gallery',
|
|
'og',
|
|
'health',
|
|
's', // can't shadow the shortener itself
|
|
'login',
|
|
'favicon.ico', // even with the dot — covered by SLUG_REGEX fail too
|
|
])('reserves %j', (slug) => {
|
|
expect(_RESERVED_SLUGS.has(slug)).toBe(true);
|
|
});
|
|
|
|
test('"admin" → rejected with "reserved" reason', () => {
|
|
// validateSlug short-circuits at the regex for slugs containing
|
|
// dots (favicon.ico fails the regex first). Test a clean
|
|
// alphanumeric reserved word.
|
|
const result = validateSlug('admin');
|
|
expect(result).toBe('short_slug is reserved');
|
|
});
|
|
});
|
|
|
|
describe('path-traversal + URL-injection vectors are rejected at the regex', () => {
|
|
test.each([
|
|
'../etc/passwd',
|
|
'foo/../bar',
|
|
'foo?query=1',
|
|
'foo#fragment',
|
|
'foo&bar',
|
|
'foo bar',
|
|
'foo<script>',
|
|
'foo>',
|
|
'foo"',
|
|
'foo\'',
|
|
'foo;rm -rf',
|
|
])('%j', (slug) => {
|
|
expect(validateSlug(slug)).not.toBeNull();
|
|
});
|
|
});
|
|
});
|