Merge pull request #702 from PicPeak/feat/branded-short-urls-699

feat(gallery): branded URL shortener — /s/<slug> with OG injection (#699)
This commit is contained in:
Paul Nothaft
2026-06-30 17:01:16 +02:00
committed by GitHub
12 changed files with 1484 additions and 0 deletions
@@ -0,0 +1,231 @@
/**
* HTTP-level tests for the `/s/:shortSlug` public resolver (#699).
*
* Verifies the contract the public route is expected to honour:
* - Browser UA → 302 to target_path
* - Social crawler UA → 200 with OG <meta>, canonical = /s/<slug>
* - Soft-deleted slug → 410 Gone (intentional-delete signal)
* - Unknown slug → 404 Not Found
* - Hit count increments after successful resolutions (both shapes)
*
* Mirrors the production server.js wiring but doesn't load the whole
* server — the surrounding middleware (CORS, helmet, rate limiters)
* isn't part of this route's contract.
*/
const express = require('express');
const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
let db; let cleanup; let service; let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Persist a business_profile + business_name so buildOgMetadata's
// settings-based fields populate consistently.
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting('branding_company_name', JSON.stringify('Test Studio'), 'string');
service = require('../../src/services/galleryShortUrlService');
const {
isSocialCrawler, buildOgMetadata, renderOgHtml,
} = require('../../src/services/galleryOgService');
app = express();
app.get('/s/:shortSlug', async (req, res) => {
try {
const row = await service.findByShortSlug(req.params.shortSlug);
if (!row) return res.status(404).type('text/plain').send('Short URL not found');
if (row.deleted_at) return res.status(410).type('text/plain').send('Short URL has been removed');
if (isSocialCrawler(req.get('user-agent'))) {
const event = await db('events').where({ id: row.event_id }).first('slug');
if (event?.slug) {
const meta = await buildOgMetadata(event.slug, req.originalUrl);
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
meta.url = `${base}/s/${row.short_slug}`;
res.set('Cache-Control', 'public, max-age=300');
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(renderOgHtml(meta));
service.recordHit(row.id).catch(() => {});
return;
}
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
}
service.recordHit(row.id).catch(() => {});
return res.redirect(302, row.target_path);
} catch (err) {
return res.status(500).type('text/plain').send(err.message);
}
});
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function seedEventAndShortUrl({ slug = `evt-${Date.now()}`, shortSlug }) {
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [eventId] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: 'Test Event',
event_date: '2026-06-05',
password_hash: 'x',
expires_at: farFuture,
is_active: true,
is_archived: false,
share_link: slug,
share_token: `tok${Math.random().toString(36).slice(2, 12)}`,
welcome_message: null,
});
const row = await service.createShortUrl({
eventId, customSlug: shortSlug,
});
return { eventId, shortUrl: row };
}
// User-agent strings the production `isSocialCrawler` helper matches.
// Snapshot known-true samples here so the test stays in sync if the
// helper's allowlist evolves.
const BOT_UA_WHATSAPP = 'WhatsApp/2.23.20.0';
const BOT_UA_FACEBOOK = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)';
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
describe('GET /s/:shortSlug — browser (302 redirect)', () => {
it('redirects to the snapshotted target_path with a 302', async () => {
const { shortUrl } = await seedEventAndShortUrl({
slug: 'browser-redirect', shortSlug: 'go-here',
});
const res = await request(app)
.get('/s/go-here')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(302);
expect(res.headers.location).toBe(shortUrl.target_path);
expect(res.headers.location).toMatch(/^\/gallery\//);
});
it('increments hit_count on a browser hit (fire-and-forget — wait briefly)', async () => {
await seedEventAndShortUrl({
slug: 'hit-browser', shortSlug: 'hit-from-browser',
});
await request(app).get('/s/hit-from-browser').set('User-Agent', BROWSER_UA);
await new Promise((r) => setTimeout(r, 50));
const row = await service.findByShortSlug('hit-from-browser');
expect(row.hit_count).toBe(1);
expect(row.last_hit_at).toBeTruthy();
});
});
describe('GET /s/:shortSlug — social crawler (OG metadata)', () => {
it('returns 200 with OG HTML for WhatsApp UA', async () => {
await seedEventAndShortUrl({
slug: 'whatsapp-og', shortSlug: 'wa-preview',
});
const res = await request(app)
.get('/s/wa-preview')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/html/);
expect(res.text).toContain('<meta');
expect(res.text).toMatch(/og:title/);
expect(res.text).toMatch(/og:url/);
});
it('og:url canonical points at /s/<slug>, not the underlying gallery URL', async () => {
await seedEventAndShortUrl({
slug: 'canonical-test', shortSlug: 'canonical-short',
});
const res = await request(app)
.get('/s/canonical-short')
.set('User-Agent', BOT_UA_FACEBOOK);
expect(res.status).toBe(200);
// The og:url meta tag must contain the short-URL path, not the
// /gallery/<slug> path — this is the cache-key invariant from #699.
expect(res.text).toMatch(/property="og:url"\s+content="[^"]*\/s\/canonical-short"/);
expect(res.text).not.toMatch(
/property="og:url"\s+content="[^"]*\/gallery\/canonical-test"/
);
});
it('sets a short cache header so scrapers can re-fetch when admin rotates the preview', async () => {
await seedEventAndShortUrl({
slug: 'cache-header', shortSlug: 'cache-test',
});
const res = await request(app)
.get('/s/cache-test')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.headers['cache-control']).toMatch(/public/);
expect(res.headers['cache-control']).toMatch(/max-age=300/);
});
it('increments hit_count on a crawler hit as well', async () => {
await seedEventAndShortUrl({
slug: 'hit-bot', shortSlug: 'hit-from-bot',
});
await request(app).get('/s/hit-from-bot').set('User-Agent', BOT_UA_WHATSAPP);
await new Promise((r) => setTimeout(r, 50));
const row = await service.findByShortSlug('hit-from-bot');
expect(row.hit_count).toBe(1);
});
});
describe('GET /s/:shortSlug — error states', () => {
it('404 for an unknown slug', async () => {
const res = await request(app)
.get('/s/never-existed')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(404);
});
it('410 for a soft-deleted slug (intentional-delete signal)', async () => {
const { shortUrl } = await seedEventAndShortUrl({
slug: 'gone-test', shortSlug: 'gone-slug',
});
await service.softDelete(shortUrl.id, null);
const res = await request(app)
.get('/s/gone-slug')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(410);
});
it('410 if the event was hard-deleted but the short URL row somehow survives', async () => {
const { eventId } = await seedEventAndShortUrl({
slug: 'orphan-test', shortSlug: 'orphan-slug',
});
// Hard-delete the event row (FK CASCADE would normally clean up the
// short URL too — but if CASCADE didn't fire for whatever reason
// (e.g. SQLite foreign_keys pragma off in a particular runtime), the
// resolver should still degrade safely).
// SQLite's foreign_keys pragma is OFF by default; the migration
// doesn't toggle it, so this delete leaves the short URL row.
await db('events').where({ id: eventId }).delete();
const res = await request(app)
.get('/s/orphan-slug')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.status).toBe(410);
});
it('404 for a malformed slug (rejected at validation, no DB hit)', async () => {
const res = await request(app)
.get('/s/UPPER_CASE')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(404);
});
});
describe('Regression — existing URL paths must still respond the same', () => {
// The /s/* namespace is additive: it must NOT shadow /gallery/*
// or any of the OG routes. We don't load the whole app here, but we
// can at least pin that the route param doesn't accept slashes —
// i.e. /s/foo/bar must NOT be matched by our handler.
it('the /s/:shortSlug route does not match nested paths', async () => {
const res = await request(app)
.get('/s/foo/bar')
.set('User-Agent', BROWSER_UA);
// Express returns its default 404 when no route matches the path.
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,282 @@
/**
* Integration tests for the branded short-URL service (#699).
*
* Exercises createShortUrl + findByShortSlug + listForEvent + softDelete
* + recordHit against a real SQLite DB, including the contracts that
* matter for production correctness:
*
* - Custom slug + collision detection (409 with `suggested`)
* - Auto-generated slug from event slug + year
* - Soft-delete preserves the row (admin can audit)
* - target_path snapshots at create time (toggling the global
* "Use short gallery URLs" setting later doesn't change existing
* short URLs — backward-compat invariant from #699)
* - hit_count increments idempotently
* - findByShortSlug returns soft-deleted rows (caller decides 410 vs 404)
*
* Boots one DB for the whole file (cheap on SQLite); each test seeds
* its own event row to keep scope clean.
*/
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
let db; let cleanup; let service; let adminId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Minimal admin for created_by audit.
const adminInsert = await db('admin_users').insert({
username: 'shorturl-test',
email: 'shorturl@example.com',
password_hash: 'x',
must_change_password: false,
created_at: new Date(),
}).returning('id');
adminId = adminInsert[0]?.id ?? adminInsert[0];
service = require('../../src/services/galleryShortUrlService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
// Each test seeds a fresh event so collisions / counter state don't leak.
async function seedEvent(overrides = {}) {
const slug = overrides.slug || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [id] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: overrides.event_name || 'Test Wedding',
event_date: overrides.event_date || '2026-06-05',
password_hash: 'x',
expires_at: farFuture,
is_active: true,
is_archived: false,
share_link: slug,
share_token: overrides.share_token || `tok${Math.random().toString(36).slice(2, 12)}`,
welcome_message: null,
});
const event = await db('events').where({ id }).first();
return event;
}
describe('createShortUrl — custom slug', () => {
it('creates with a custom slug', async () => {
const event = await seedEvent({ slug: 'sofia-grad-1' });
const row = await service.createShortUrl({
eventId: event.id,
customSlug: 'sofia-graduation-1',
createdBy: adminId,
});
expect(row.short_slug).toBe('sofia-graduation-1');
expect(row.target_path).toBe(`/gallery/${event.slug}`);
expect(row.event_id).toBe(event.id);
expect(row.hit_count).toBe(0);
});
it('lowercases the input — operators pasting mixed-case still get a clean slug', async () => {
const event = await seedEvent({ slug: 'sofia-grad-2' });
const row = await service.createShortUrl({
eventId: event.id,
customSlug: 'Sofia-GraduAtion-2', // mixed case
createdBy: adminId,
});
expect(row.short_slug).toBe('sofia-graduation-2');
});
it('rejects an invalid slug with INVALID_SLUG code', async () => {
const event = await seedEvent({ slug: 'invalid-test' });
await expect(service.createShortUrl({
eventId: event.id,
customSlug: 'invalid slug with spaces',
createdBy: adminId,
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
});
it('rejects a reserved slug with INVALID_SLUG code', async () => {
const event = await seedEvent({ slug: 'reserved-test' });
await expect(service.createShortUrl({
eventId: event.id,
customSlug: 'admin',
createdBy: adminId,
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
});
it('rejects a duplicate slug with SLUG_TAKEN + suggested fallback', async () => {
const event1 = await seedEvent({ slug: 'dup-test-1' });
const event2 = await seedEvent({ slug: 'dup-test-2' });
await service.createShortUrl({ eventId: event1.id, customSlug: 'collide-me' });
await expect(service.createShortUrl({
eventId: event2.id, customSlug: 'collide-me',
})).rejects.toMatchObject({
code: 'SLUG_TAKEN',
suggested: expect.any(String),
});
});
it('throws EVENT_NOT_FOUND when the event id does not exist', async () => {
await expect(service.createShortUrl({
eventId: 9999999, customSlug: 'no-event',
})).rejects.toMatchObject({ code: 'EVENT_NOT_FOUND' });
});
});
describe('createShortUrl — auto-generated slug', () => {
it('uses event slug + year when no custom slug provided', async () => {
const event = await seedEvent({
slug: 'autogen-wedding', event_date: '2026-06-05',
});
const row = await service.createShortUrl({
eventId: event.id,
createdBy: adminId,
});
// First-choice candidate is just the slug; takes that.
expect(row.short_slug).toBe('autogen-wedding');
});
it('falls back to slug-year when the bare slug is already taken', async () => {
// Both events SHARE the same canonical slug so the first-choice
// bare-slug candidate is burned, forcing autoGen to try the
// year-suffixed variant.
const event1 = await seedEvent({
slug: 'collide-base', event_date: '2026-07-01',
});
await service.createShortUrl({
eventId: event1.id, customSlug: 'collide-base',
});
const event2 = await seedEvent({
slug: 'collide-base-2', event_date: '2026-07-01',
});
// Force the bare candidate of event2 to also collide by burning it.
await service.createShortUrl({
eventId: event1.id, customSlug: 'collide-base-2',
});
const row = await service.createShortUrl({
eventId: event2.id, // No custom — auto-gen from event2.slug
});
// Bare candidate `collide-base-2` is taken → year-suffixed picks.
expect(row.short_slug).toBe('collide-base-2-2026');
});
});
describe('createShortUrl — target_path snapshotting (#699 backward-compat)', () => {
it('uses /gallery/<slug> when the global short-URLs setting is OFF (default)', async () => {
const event = await seedEvent({ slug: 'snapshot-off' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'snap-off',
});
expect(row.target_path).toBe(`/gallery/${event.slug}`);
});
it('uses /gallery/<share_token> when the global setting is ON at create time', async () => {
// Persist the setting.
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(true), 'system');
try {
const event = await seedEvent({ slug: 'snapshot-on', share_token: 'tokenAbc123' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'snap-on',
});
expect(row.target_path).toBe(`/gallery/${event.share_token}`);
// CRITICAL backward-compat invariant: now flip the setting OFF.
// Existing short URLs must still resolve to the same target_path
// they were created with — operator's existing share links don't
// silently change behaviour.
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
const refetched = await service.findByShortSlug('snap-on');
expect(refetched.target_path).toBe(`/gallery/${event.share_token}`);
} finally {
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
}
});
});
describe('findByShortSlug + listForEvent', () => {
it('returns null for an unknown slug', async () => {
expect(await service.findByShortSlug('does-not-exist-xyz')).toBeNull();
});
it('returns null for a malformed slug (no DB hit)', async () => {
expect(await service.findByShortSlug('UPPER_CASE')).toBeNull();
expect(await service.findByShortSlug('with spaces')).toBeNull();
expect(await service.findByShortSlug('')).toBeNull();
});
it('returns soft-deleted rows (caller decides 410 vs 404)', async () => {
const event = await seedEvent({ slug: 'softdel-find' });
const created = await service.createShortUrl({
eventId: event.id, customSlug: 'find-deleted',
});
await service.softDelete(created.id, adminId);
const fetched = await service.findByShortSlug('find-deleted');
expect(fetched).not.toBeNull();
expect(fetched.deleted_at).toBeTruthy();
});
it('listForEvent excludes soft-deleted rows', async () => {
const event = await seedEvent({ slug: 'list-test' });
const live = await service.createShortUrl({
eventId: event.id, customSlug: 'list-live',
});
const deleted = await service.createShortUrl({
eventId: event.id, customSlug: 'list-deleted',
});
await service.softDelete(deleted.id, adminId);
const list = await service.listForEvent(event.id);
const ids = list.map((r) => r.id);
expect(ids).toContain(live.id);
expect(ids).not.toContain(deleted.id);
});
});
describe('softDelete', () => {
it('returns true on first call, false on second (idempotent admin clicks)', async () => {
const event = await seedEvent({ slug: 'softdel-idem' });
const created = await service.createShortUrl({
eventId: event.id, customSlug: 'idem-delete',
});
expect(await service.softDelete(created.id, adminId)).toBe(true);
expect(await service.softDelete(created.id, adminId)).toBe(false);
});
it('returns false for an unknown id (caller maps to 404)', async () => {
expect(await service.softDelete(9999999, adminId)).toBe(false);
});
});
describe('createShortUrl after soft-delete — slug rotation', () => {
it('re-creating a soft-deleted slug succeeds (purges the deleted row)', async () => {
const event = await seedEvent({ slug: 'rotate' });
const first = await service.createShortUrl({
eventId: event.id, customSlug: 'rotate-me',
});
await service.softDelete(first.id, adminId);
// The slug is now reclaimable for a fresh row.
const second = await service.createShortUrl({
eventId: event.id, customSlug: 'rotate-me',
});
expect(second.id).not.toBe(first.id);
expect(second.short_slug).toBe('rotate-me');
});
});
describe('recordHit', () => {
it('increments hit_count + stamps last_hit_at', async () => {
const event = await seedEvent({ slug: 'hit-counter' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'count-me',
});
await service.recordHit(row.id);
await service.recordHit(row.id);
const fetched = await service.findByShortSlug('count-me');
expect(fetched.hit_count).toBe(2);
expect(fetched.last_hit_at).toBeTruthy();
});
it('is fire-and-forget — invalid id does not throw', async () => {
await expect(service.recordHit(9999999)).resolves.not.toThrow();
});
});
@@ -0,0 +1,120 @@
/**
* 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();
});
});
});
@@ -0,0 +1,67 @@
/**
* Migration 150: branded URL shortener for gallery share links (#699).
*
* Lets admins create custom-named short URLs that resolve to a gallery's
* full link (e.g. `/s/sofia-graduation` → `/gallery/<slug>`). The short
* URL itself answers bot-UA requests with server-rendered OG metadata,
* so the SHORT URL is the one that shows the rich preview in iMessage /
* Facebook / WhatsApp — not just the destination.
*
* Backward-compat invariant: this migration only ADDS a new table. No
* existing route, table, or column is touched. Operators upgrading
* through this migration can opt into creating short URLs per event,
* but every existing `/gallery/...` link continues to resolve identically
* — the new feature is additive.
*/
exports.up = async function (knex) {
if (await knex.schema.hasTable('gallery_short_urls')) return;
await knex.schema.createTable('gallery_short_urls', (t) => {
t.increments('id').primary();
// Public-facing slug — what appears in /s/<short_slug>. Case-folded
// to lowercase at write time by the service; the UNIQUE index here
// is the last line of defence against collisions.
t.string('short_slug', 64).notNullable().unique();
// Hard FK to events — when an admin deletes an event, its short
// URLs go with it. ON DELETE CASCADE is the natural model: a short
// URL that points at a vanished gallery has no useful behaviour.
t.integer('event_id').notNullable()
.references('id').inTable('events').onDelete('CASCADE');
// Where the short URL resolves to — usually `/gallery/<slug>` or
// `/gallery/<share_token>` depending on the operator's #525
// "Use short gallery URLs" setting at create time. Stored at create
// time so a later flip of the global toggle doesn't silently change
// what existing short URLs redirect to.
t.text('target_path').notNullable();
// For the audit trail + admin UI ("created by Alex two days ago").
t.integer('created_by').references('id').inTable('admin_users');
t.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
// Tiny analytics — admins want to know "is this branded link
// actually being clicked?" without a separate analytics service.
t.integer('hit_count').notNullable().defaultTo(0);
t.timestamp('last_hit_at');
// Soft-delete semantics: a deleted short URL returns 410 Gone (not
// 404) so the admin sees their delete was intentional, and so a
// re-create with the same slug is an explicit "yes, replace" rather
// than accidentally taking over a stale link. The UNIQUE constraint
// on short_slug means re-create after delete requires either NULLing
// the deleted row's slug or hard-deleting it; service layer handles
// that explicitly.
t.timestamp('deleted_at');
t.integer('deleted_by').references('id').inTable('admin_users');
});
// Read patterns:
// - /s/:slug hot path — UNIQUE constraint on short_slug already
// provides the index. No additional index needed.
// - Admin UI "list short URLs for this event" — index event_id.
await knex.schema.alterTable('gallery_short_urls', (t) => {
t.index(['event_id'], 'gallery_short_urls_event_id_idx');
});
};
exports.down = async function (knex) {
if (await knex.schema.hasTable('gallery_short_urls')) {
await knex.schema.dropTable('gallery_short_urls');
}
};
+66
View File
@@ -543,6 +543,68 @@ app.get('/og/gallery/:slug', handleGalleryOgRequest);
// returns 404 unless the opt-in is on AND a hero_photo_id is set.
app.get('/og/gallery/:slug/cover', handleGalleryOgCover);
// Branded URL shortener (#699). /s/<short_slug> is bot-UA aware:
// - Social crawler → server-render OG for the target event so the
// SHORT URL itself is what scrapes cache against. The og:url canonical
// in the rendered HTML points back at /s/<slug>, not the underlying
// gallery URL — so a re-share of the same short URL keeps the cache
// warm even if the underlying gallery slug rotates.
// - Browser → 302 to the stored target_path. The target_path was
// captured at create time from the event's slug + share_token + the
// global "Use short gallery URLs" setting, so it doesn't silently
// change later.
// - Soft-deleted → 410 Gone so the admin can tell their delete worked
// vs. a typo'd unknown slug (which returns 404).
const galleryShortUrlService = require('./src/services/galleryShortUrlService');
const { buildOgMetadata, renderOgHtml } = require('./src/services/galleryOgService');
app.get('/s/:shortSlug', async (req, res) => {
try {
const row = await galleryShortUrlService.findByShortSlug(req.params.shortSlug);
if (!row) {
return res.status(404).type('text/plain').send('Short URL not found');
}
if (row.deleted_at) {
return res.status(410).type('text/plain').send('Short URL has been removed');
}
// Bot UA → render OG metadata for the target event. We look up the
// event via the short URL's event_id rather than re-parsing the
// target_path so a future migration that adds new target shapes
// (slideshow, client-access) doesn't need to rewrite the URL parser.
if (isSocialCrawler(req.get('user-agent'))) {
const event = await require('./src/database/db').db('events')
.where({ id: row.event_id })
.first('slug');
if (event?.slug) {
const meta = await buildOgMetadata(event.slug, req.originalUrl);
// Override the canonical to point at the SHORT URL itself —
// social platforms cache OG by URL, and the short URL is the
// one operators actually share, so that's the cache key we
// want them to stick with.
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
meta.url = `${base}/s/${row.short_slug}`;
res.set('Cache-Control', 'public, max-age=300');
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(renderOgHtml(meta));
// Hit accounting is fire-and-forget — don't block the bot.
galleryShortUrlService.recordHit(row.id).catch(() => {});
return;
}
// Event disappeared (FK CASCADE in flight, or admin hard-deleted
// outside the normal soft-delete path) — fall through to 410 so
// the scraper sees a clean signal.
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
}
// Browser path: redirect. Hit accounting is fire-and-forget.
galleryShortUrlService.recordHit(row.id).catch(() => {});
return res.redirect(302, row.target_path);
} catch (err) {
logger.error('Short URL resolver failed', { slug: req.params.shortSlug, error: err.message });
return res.status(500).type('text/plain').send('Internal server error');
}
});
// robots.txt endpoint (dynamic, served from DB settings)
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
app.get('/robots.txt', async (req, res) => {
@@ -638,6 +700,10 @@ app.use('/api/gallery', require('./src/routes/galleryGuests'));
app.use('/api/admin', adminRoutes);
app.use('/api/admin/auth', adminAuthRoutes);
app.use('/api/admin/system', require('./src/routes/adminSystem'));
// Branded URL shortener admin CRUD (#699) — list/create/delete short URLs
// per event. Mounted at /api/admin so the routes appear at
// /api/admin/events/:eventId/short-urls and /api/admin/short-urls/:id.
app.use('/api/admin', require('./src/routes/adminShortUrls'));
app.use('/api/admin/feature-flags', require('./src/routes/adminFeatureFlags'));
app.use('/api/admin/whatsapp', require('./src/routes/adminWhatsapp'));
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
+114
View File
@@ -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,
};
@@ -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}`);
},
};