/** * Client proofing sessions — "Proofing 2.0". * * The anonymous proofing on public albums keeps everything in the visitor's * browser: favourites in localStorage, a bitmask in the URL, and a mailto the * photographer may or may not receive. A proofing session is the handover * version of that: the photographer creates a link for one client and one * album, the client's picks are saved here as they make them, and a final * "submit" locks the selection and tells the photographer. * * Storage is one JSON file, `content/proofing.json`, next to analytics.json — * a portfolio has a handful of these at a time, not thousands. The file holds * the link tokens, which are capabilities, so it is written 0600. * * The link token is the only credential a client has. It is 192 random bits, * compared as an exact string lookup, and never derived from anything else. * Admin routes address a session by its separate, non-secret `id`, so an admin * URL in a log or a browser history does not leak a working client link. * * Selections are stored as Immich asset IDs. Clients only ever see the opaque * asset tokens (lib/tokens.ts); the routes decode them and check that every * one belongs to the session's album before anything is written. */ import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { z } from 'zod'; import { atomicWrite } from './atomicWrite'; import { isUuid } from './uuid'; /** What a session lets its client download as a ZIP of originals. */ export type ProofDownload = 'none' | 'selection' | 'album'; export interface ProofSession { /** Non-secret handle for the admin panel. */ id: string; /** The client link's capability: /proof/. */ token: string; /** Optional friendly client URL: /c/. */ alias?: string; /** Who the link is for, as the photographer wants to see it. */ clientName: string; albumId: string; createdAt: string; /** Last day the link works (YYYY-MM-DD, inclusive), or absent for no expiry. */ expiresOn?: string; download: ProofDownload; /** How many ZIP downloads the link allows; absent means unlimited. */ downloadLimit?: number; downloadsUsed: number; /** Selected Immich asset IDs, in the order the client picked them. */ selection: string[]; /** When the client last changed the selection. */ updatedAt?: string; /** Set once the client submits; the selection is read-only from then on. */ submittedAt?: string; } interface ProofStore { sessions: ProofSession[]; } /** The largest selection a session stores — far above any real proofing round. */ export const MAX_SELECTION = 2000; /** Upper bound on sessions, so a runaway script cannot grow the file forever. */ export const MAX_SESSIONS = 500; // ── Pure helpers ──────────────────────────────────────────────────── export type ProofState = 'open' | 'submitted' | 'expired'; /** * Whether a session still works on `now`. `expiresOn` is inclusive: a link * "valid until 12 May" still opens on the evening of 12 May, in the server's * time zone. */ export function isExpired(session: Pick, now = new Date()): boolean { if (!session.expiresOn) return false; const end = new Date(`${session.expiresOn}T23:59:59.999`); return Number.isNaN(end.getTime()) ? false : now.getTime() > end.getTime(); } export function sessionState(session: ProofSession, now = new Date()): ProofState { if (isExpired(session, now)) return 'expired'; return session.submittedAt ? 'submitted' : 'open'; } /** ZIP downloads left, or null for unlimited. */ export function downloadsRemaining(session: ProofSession): number | null { if (session.downloadLimit === undefined) return null; return Math.max(0, session.downloadLimit - session.downloadsUsed); } /** A 192-bit URL-safe token. */ export function newToken(): string { return crypto.randomBytes(24).toString('base64url'); } const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32}$/; /** Cheap shape check before any file is read for a token from a URL. */ export function isTokenShaped(token: string): boolean { return TOKEN_PATTERN.test(token); } const ALIAS_PATTERN = /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/; const aliasSchema = z .string() .trim() .min(1) .max(64) .regex( ALIAS_PATTERN, 'Alias may only contain letters, numbers and single hyphens', ); /** Input the admin panel may send when creating or editing a session. */ export const sessionInputSchema = z.object({ clientName: z.string().trim().min(1).max(120), alias: aliasSchema.nullable().optional(), albumId: z.string().refine(isUuid, 'Not an Immich album ID'), expiresOn: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .nullable() .optional(), download: z.enum(['none', 'selection', 'album']).default('none'), downloadLimit: z.number().int().min(1).max(1000).nullable().optional(), }); export type SessionInput = z.infer; export const sessionPatchSchema = sessionInputSchema .omit({ albumId: true }) .partial() .extend({ /** Unlock a submitted selection so the client can change it again. */ reopen: z.literal(true).optional(), /** Reset the download counter, e.g. after a failed download. */ resetDownloads: z.literal(true).optional(), }); export type SessionPatch = z.infer; // ── Storage ───────────────────────────────────────────────────────── function storeFile(): string { return path.join(process.cwd(), 'content', 'proofing.json'); } async function readStore(): Promise { let raw: string; try { raw = await fs.readFile(storeFile(), 'utf8'); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { sessions: [] }; throw err; } // A file that exists but does not parse is not treated as empty: the next // write would replace every session with nothing. Let the caller fail. const parsed = JSON.parse(raw) as Partial; return { sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [] }; } /** * Serialises read-modify-write within this process, like the analytics store: * the atomic rename keeps the file whole, but without the queue two concurrent * saves would each write their own copy and one client's heart would vanish. */ let writeQueue: Promise = Promise.resolve(); function mutate(fn: (store: ProofStore) => T): Promise { const run = writeQueue.then(async () => { const store = await readStore(); const result = fn(store); await fs.mkdir(path.dirname(storeFile()), { recursive: true }); await atomicWrite(storeFile(), JSON.stringify(store, null, 2), { mode: 0o600 }); return result; }); // The queue must survive a failed write, or every later save would reject too. writeQueue = run.catch(() => {}); return run; } export async function listSessions(): Promise { return (await readStore()).sessions; } export async function getSessionById(id: string): Promise { return (await readStore()).sessions.find((s) => s.id === id) ?? null; } /** * Constant-time token comparison. With 192 random bits a timing attack is not * practical, but the link token is the client's only credential and the * comparison costs nothing to harden. */ function tokenMatches(stored: string, given: string): boolean { const a = Buffer.from(stored); const b = Buffer.from(given); return a.length === b.length && crypto.timingSafeEqual(a, b); } export async function findSessionByToken(token: string): Promise { if (!isTokenShaped(token)) return null; return (await readStore()).sessions.find((s) => tokenMatches(s.token, token)) ?? null; } export async function findSessionByAlias(alias: string): Promise { const normalized = alias.trim(); if (normalized.length > 64 || !ALIAS_PATTERN.test(normalized)) { return null; } return ( (await readStore()).sessions.find( (session) => session.alias === normalized, ) ?? null ); } export class ProofError extends Error { constructor( public readonly code: | 'not-found' | 'expired' | 'submitted' | 'limit' | 'too-many' | 'empty' | 'alias-taken', message: string, ) { super(message); } } export async function createSession(input: SessionInput): Promise { return mutate((store) => { if (store.sessions.length >= MAX_SESSIONS) { throw new ProofError('too-many', `At most ${MAX_SESSIONS} proofing links can exist.`); } if ( input.alias && store.sessions.some((session) => session.alias === input.alias) ) { throw new ProofError( 'alias-taken', 'That client URL alias is already in use.', ); } const session: ProofSession = { id: crypto.randomBytes(8).toString('hex'), token: newToken(), ...(input.alias ? { alias: input.alias } : {}), clientName: input.clientName, albumId: input.albumId, createdAt: new Date().toISOString(), ...(input.expiresOn ? { expiresOn: input.expiresOn } : {}), download: input.download, ...(input.downloadLimit ? { downloadLimit: input.downloadLimit } : {}), downloadsUsed: 0, selection: [], }; store.sessions.push(session); return session; }); } export async function updateSession(id: string, patch: SessionPatch): Promise { return mutate((store) => { const session = store.sessions.find((s) => s.id === id); if (!session) throw new ProofError('not-found', 'No such proofing link.'); if (patch.alias !== undefined) { if (patch.alias === null) { delete session.alias; } else { const taken = store.sessions.some( (other) => other.id !== id && other.alias === patch.alias, ); if (taken) { throw new ProofError( 'alias-taken', 'That client URL alias is already in use.', ); } session.alias = patch.alias; } } if (patch.clientName !== undefined) session.clientName = patch.clientName; if (patch.download !== undefined) session.download = patch.download; // `null` clears, `undefined` leaves alone. if (patch.expiresOn === null) delete session.expiresOn; else if (patch.expiresOn !== undefined) session.expiresOn = patch.expiresOn; if (patch.downloadLimit === null) delete session.downloadLimit; else if (patch.downloadLimit !== undefined) session.downloadLimit = patch.downloadLimit; if (patch.reopen) delete session.submittedAt; if (patch.resetDownloads) session.downloadsUsed = 0; return session; }); } export async function deleteSession(id: string): Promise { await mutate((store) => { const before = store.sessions.length; store.sessions = store.sessions.filter((s) => s.id !== id); if (store.sessions.length === before) { throw new ProofError('not-found', 'No such proofing link.'); } }); } /** Find the session for a token and refuse anything that may not change. */ function openSession(store: ProofStore, token: string): ProofSession { const session = isTokenShaped(token) ? store.sessions.find((s) => tokenMatches(s.token, token)) : undefined; if (!session) throw new ProofError('not-found', 'No such proofing link.'); if (isExpired(session)) throw new ProofError('expired', 'This proofing link has expired.'); return session; } /** * Replace the client's selection. The caller has already checked that every ID * belongs to the session's album; this re-checks the session's own state inside * the write lock, so a save racing a submit cannot slip in after it. */ export async function saveSelection(token: string, assetIds: string[]): Promise { return mutate((store) => { const session = openSession(store, token); if (session.submittedAt) { throw new ProofError('submitted', 'This selection has already been submitted.'); } session.selection = Array.from(new Set(assetIds)).slice(0, MAX_SELECTION); session.updatedAt = new Date().toISOString(); return session; }); } /** * Lock the selection. Returns the session and whether this call was the one * that submitted it, so a double click notifies the photographer only once. */ export async function submitSelection( token: string, ): Promise<{ session: ProofSession; firstSubmit: boolean }> { return mutate((store) => { const session = openSession(store, token); if (session.submittedAt) return { session, firstSubmit: false }; // Checked here, under the write lock: a save of an empty selection racing // the submit would otherwise lock nothing and still fire the webhook. if (session.selection.length === 0) throw new ProofError('empty', 'Nothing selected.'); session.submittedAt = new Date().toISOString(); return { session, firstSubmit: true }; }); } /** Count one ZIP download against the link's limit, refusing past it. */ export async function claimDownload( token: string, scope: 'selection' | 'album' = 'selection', ): Promise { return mutate((store) => { const session = openSession(store, token); // Re-checked under the lock: the admin may have narrowed the link since // the request read its snapshot. if (session.download === 'none' || (scope === 'album' && session.download !== 'album')) { throw new ProofError('not-found', 'This proofing link offers no downloads.'); } if (session.downloadLimit !== undefined && session.downloadsUsed >= session.downloadLimit) { throw new ProofError('limit', 'The download limit for this link has been reached.'); } session.downloadsUsed += 1; return session; }); }