From 15dcbf904e3a4f104c9c9364a9b18cfa4d01d879 Mon Sep 17 00:00:00 2001 From: koko210Serve Date: Sun, 4 Oct 2026 15:49:06 +0300 Subject: [PATCH] Add friendly aliases for client proofing links --- app/admin/components/ProofingView.tsx | 43 +++++++ app/c/[alias]/page.tsx | 54 +++++++++ app/proof/[token]/page.tsx | 129 ++++---------------- app/proof/renderProof.tsx | 163 ++++++++++++++++++++++++++ lib/admin/proofing-view.ts | 4 +- lib/proofSessions.ts | 67 ++++++++++- 6 files changed, 350 insertions(+), 110 deletions(-) create mode 100644 app/c/[alias]/page.tsx create mode 100644 app/proof/renderProof.tsx diff --git a/app/admin/components/ProofingView.tsx b/app/admin/components/ProofingView.tsx index 3c467bd..51bc760 100644 --- a/app/admin/components/ProofingView.tsx +++ b/app/admin/components/ProofingView.tsx @@ -52,6 +52,7 @@ function CreateForm({ onCreated }: { onCreated: () => void }) { const [albums, setAlbums] = useState(null); const [clientName, setClientName] = useState(''); const [albumId, setAlbumId] = useState(''); + const [alias, setAlias] = useState(''); const [expiresOn, setExpiresOn] = useState(''); const [download, setDownload] = useState('none'); const [limit, setLimit] = useState(''); @@ -76,6 +77,7 @@ function CreateForm({ onCreated }: { onCreated: () => void }) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientName, + alias: alias.trim() || null, albumId, expiresOn: expiresOn || null, download, @@ -83,6 +85,7 @@ function CreateForm({ onCreated }: { onCreated: () => void }) { }), }); setClientName(''); + setAlias(''); setAlbumId(''); setExpiresOn(''); setDownload('none'); @@ -113,6 +116,22 @@ function CreateForm({ onCreated }: { onCreated: () => void }) { maxLength={120} required /> +
+ + + setAlias( + e.target.value + .replace(/\s+/g, '-') + .replace(/[^A-Za-z0-9-]/g, ''), + ) + } + placeholder="Client" + maxLength={64} + /> +
@@ -294,6 +313,30 @@ function SessionCard({ {copied === 'link' ? : }{' '} {copied === 'link' ? 'Copied' : 'Copy link'} + ; +} + + +export async function generateMetadata({ + params, +}: ClientPageProps): Promise { + const { alias } = await params; + + const session = await findSessionByAlias(alias); + + if (!session) { + return { + title: 'Not found', + robots: { + index: false, + follow: false, + }, + }; + } + + return proofMetadata(session.token); +} + + +export default async function ClientPage({ + params, +}: ClientPageProps) { + const { alias } = await params; + + const session = await findSessionByAlias(alias); + + if (!session) { + notFound(); + } + + return renderProof(session.token); +} diff --git a/app/proof/[token]/page.tsx b/app/proof/[token]/page.tsx index 108c5c1..14efcfd 100644 --- a/app/proof/[token]/page.tsx +++ b/app/proof/[token]/page.tsx @@ -1,121 +1,34 @@ -/** - * A client proofing link: /proof/. - * - * Renders the session's album with the proofing controls in session mode — - * hearts save to the server, and the client submits the selection when done. - * The album does not have to be published; see immich.getProofingAlbum(). - * - * Kept out of search engines and the sitemap: the URL is the credential. - */ - import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; -import { immich } from '@/lib/immich'; -import { getConfig, hasExifPanelContent } from '@/lib/config'; -import { encodeAssetId } from '@/lib/tokens'; -import { getServerDictionary } from '@/lib/i18n/server'; -import { downloadsRemaining, findSessionByToken, isExpired } from '@/lib/proofSessions'; -import { AlbumDetailView } from '../../[...path]/AlbumDetailView'; -import { toPhotoItems } from '../../[...path]/photoItems'; + +import { + proofMetadata, + renderProof, +} from '../renderProof'; + export const dynamic = 'force-dynamic'; + interface ProofPageProps { - params: Promise<{ token: string }>; + params: Promise<{ + token: string; + }>; } -export async function generateMetadata({ params }: ProofPageProps): Promise { + +export async function generateMetadata({ + params, +}: ProofPageProps): Promise { const { token } = await params; - const session = await findSessionByToken(token); - const t = getServerDictionary(); - return { - // An expired link names nobody, not even in the tab title — but the tab - // says the same as the page, rather than "not found" over "expired". - title: !session - ? t.error.notFoundTitle - : isExpired(session) - ? t.proofSession.expiredTitle - : t.proofSession.greeting(session.clientName), - robots: { index: false, follow: false }, - // Never let the link travel as a Referer, not even to our own font host. - referrer: 'no-referrer', - }; + + return proofMetadata(token); } -export default async function ProofPage({ params }: ProofPageProps) { + +export default async function ProofPage({ + params, +}: ProofPageProps) { const { token } = await params; - const session = await findSessionByToken(token); - if (!session) notFound(); - const t = getServerDictionary(); - - if (isExpired(session)) { - return ( -
-

{t.proofSession.expiredTitle}

-

{t.proofSession.expiredText}

-
- ); - } - - const album = await immich.getProofingAlbum(session.albumId); - if (!album) notFound(); - - const config = getConfig(); - const images = toPhotoItems( - album.assets, - config.exif.onHover && config.exif.camera, - config.exif.caption, - ); - - // Only what is still in the album: a photo removed in Immich after it was - // picked drops out of the selection instead of haunting the counter. - const inAlbum = new Set(album.assets.map((a) => a.id)); - const selected = session.selection.filter((id) => inAlbum.has(id)).map(encodeAssetId); - - const validUntil = session.expiresOn - ? new Date(`${session.expiresOn}T12:00:00`).toLocaleDateString(t.dateLocale, { - year: 'numeric', - month: 'long', - day: 'numeric', - }) - : null; - - const gridStyle = { - '--grid-columns': config.grid.columns, - ...(config.gridGapExplicit ? { '--grid-gap': `${config.grid.gap}px` } : {}), - '--grid-aspect-ratio': config.grid.aspectRatio, - } as React.CSSProperties; - - return ( - -

- {t.proofSession.greeting(session.clientName)} -

-

- {session.submittedAt ? t.proofSession.locked : t.proofSession.intro} -

- {validUntil && ( -

{t.proofSession.validUntil(validUntil)}

- )} -
- } - /> - ); + return renderProof(token); } diff --git a/app/proof/renderProof.tsx b/app/proof/renderProof.tsx new file mode 100644 index 0000000..5bd2752 --- /dev/null +++ b/app/proof/renderProof.tsx @@ -0,0 +1,163 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import { immich } from '@/lib/immich'; +import { getConfig, hasExifPanelContent } from '@/lib/config'; +import { encodeAssetId } from '@/lib/tokens'; +import { getServerDictionary } from '@/lib/i18n/server'; +import { + downloadsRemaining, + findSessionByToken, + isExpired, +} from '@/lib/proofSessions'; + +import { AlbumDetailView } from '../[...path]/AlbumDetailView'; +import { toPhotoItems } from '../[...path]/photoItems'; + + +/** + * Generate metadata for a proofing session. + * + * This is shared by both: + * + * /proof/ + * /c/ + */ +export async function proofMetadata(token: string): Promise { + const session = await findSessionByToken(token); + const t = getServerDictionary(); + + return { + title: !session + ? t.error.notFoundTitle + : isExpired(session) + ? t.proofSession.expiredTitle + : t.proofSession.greeting(session.clientName), + + robots: { + index: false, + follow: false, + }, + + // Do not leak the proof URL through the Referer header. + referrer: 'no-referrer', + }; +} + + +/** + * Render an existing Folio proofing session from its real token. + * + * Both /proof/ and our friendly /c/ route can call this, + * which means there is only one copy of the actual gallery code. + */ +export async function renderProof(token: string) { + const session = await findSessionByToken(token); + + if (!session) { + notFound(); + } + + const t = getServerDictionary(); + + if (isExpired(session)) { + return ( +
+

+ {t.proofSession.expiredTitle} +

+ +

+ {t.proofSession.expiredText} +

+
+ ); + } + + const album = await immich.getProofingAlbum(session.albumId); + + if (!album) { + notFound(); + } + + const config = getConfig(); + + const images = toPhotoItems( + album.assets, + config.exif.onHover && config.exif.camera, + config.exif.caption, + ); + + /* + * Only preserve selected photos that still exist in the Immich album. + * + * If a photo was selected by the client and is later removed from + * the album, it should disappear from the saved selection too. + */ + const inAlbum = new Set(album.assets.map((asset) => asset.id)); + + const selected = session.selection + .filter((id) => inAlbum.has(id)) + .map(encodeAssetId); + + const validUntil = session.expiresOn + ? new Date(`${session.expiresOn}T12:00:00`).toLocaleDateString( + t.dateLocale, + { + year: 'numeric', + month: 'long', + day: 'numeric', + }, + ) + : null; + + const gridStyle = { + '--grid-columns': config.grid.columns, + + ...(config.gridGapExplicit + ? { + '--grid-gap': `${config.grid.gap}px`, + } + : {}), + + '--grid-aspect-ratio': config.grid.aspectRatio, + } as React.CSSProperties; + + return ( + +

+ {t.proofSession.greeting(session.clientName)} +

+ +

+ {session.submittedAt + ? t.proofSession.locked + : t.proofSession.intro} +

+ + {validUntil && ( +

+ {t.proofSession.validUntil(validUntil)} +

+ )} + + } + /> + ); +} diff --git a/lib/admin/proofing-view.ts b/lib/admin/proofing-view.ts index cbd14d5..706f663 100644 --- a/lib/admin/proofing-view.ts +++ b/lib/admin/proofing-view.ts @@ -19,6 +19,7 @@ export interface AdminProofSession { /** Null when Immich no longer has the album (or cannot be reached). */ albumName: string | null; albumAssetCount: number | null; + alias: string | null; /** Relative path of the client link. */ link: string; createdAt: string; @@ -60,7 +61,8 @@ export function toAdminSession( albumId: session.albumId, albumName: album?.albumName ?? null, albumAssetCount: album ? album.assets.length : null, - link: `/proof/${session.token}`, + alias: session.alias ?? null, + link: session.alias ? `/c/${session.alias}` : `/proof/${session.token}`, createdAt: session.createdAt, expiresOn: session.expiresOn ?? null, download: session.download, diff --git a/lib/proofSessions.ts b/lib/proofSessions.ts index 7886c1f..410db33 100644 --- a/lib/proofSessions.ts +++ b/lib/proofSessions.ts @@ -37,6 +37,8 @@ export interface ProofSession { 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; @@ -103,9 +105,22 @@ 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() @@ -194,9 +209,30 @@ export async function findSessionByToken(token: string): Promise 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', + public readonly code: + | 'not-found' + | 'expired' + | 'submitted' + | 'limit' + | 'too-many' + | 'empty' + | 'alias-taken', message: string, ) { super(message); @@ -208,9 +244,19 @@ export async function createSession(input: SessionInput): Promise 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(), @@ -230,6 +276,25 @@ export async function updateSession(id: string, patch: SessionPatch): Promise 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.