Add friendly aliases for client proofing links

This commit is contained in:
2026-10-04 15:49:06 +03:00
parent c172d170ae
commit 15dcbf904e
6 changed files with 350 additions and 110 deletions

View File

@@ -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,

View File

@@ -37,6 +37,8 @@ export interface ProofSession {
id: string;
/** The client link's capability: /proof/<token>. */
token: string;
/** Optional friendly client URL: /c/<alias>. */
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<ProofSession |
return (await readStore()).sessions.find((s) => tokenMatches(s.token, token)) ?? null;
}
export async function findSessionByAlias(alias: string): Promise<ProofSession | null> {
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<ProofSession>
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<Pr
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.