Add friendly aliases for client proofing links
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user