release: v0.19.0

Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

105
lib/proofWebhook.ts Normal file
View File

@@ -0,0 +1,105 @@
/**
* Tell the photographer that a client submitted a proofing selection.
*
* One POST to `PROOFING_WEBHOOK_URL` with a JSON body shaped to be useful to
* as many receivers as possible without a per-service setting: Discord reads
* `content`, Slack and Mattermost read `text`, Gotify reads `title` and
* `message`, and n8n, Home Assistant or a script of your own get the
* structured `proofing` object.
*
* Fire-and-forget by design. A webhook that is down must never turn a client's
* submit into an error — the selection is already saved and visible in the
* admin panel — so failures are logged and swallowed, and the request is
* bounded by a short timeout.
*/
import type { ProofSession } from './proofSessions';
const WEBHOOK_TIMEOUT_MS = 5000;
export interface ProofWebhookPayload {
event: 'proofing.submitted';
title: string;
message: string;
text: string;
content: string;
proofing: {
id: string;
clientName: string;
albumName: string;
selected: number;
submittedAt: string;
/** The admin page for this session, when a site URL is configured. */
adminUrl: string | null;
};
}
export function buildWebhookPayload(
session: ProofSession,
albumName: string,
siteUrl: string | null,
): ProofWebhookPayload {
const count = session.selection.length;
const title = `Proofing: ${session.clientName} submitted a selection`;
const adminUrl = siteUrl ? `${siteUrl}/admin/proofing?session=${session.id}` : null;
const message =
`${session.clientName} picked ${count} ${count === 1 ? 'photo' : 'photos'} from "${albumName}".` +
(adminUrl ? ` ${adminUrl}` : '');
return {
event: 'proofing.submitted',
title,
message,
text: `${title}\n${message}`,
content: `**${title}**\n${message}`,
proofing: {
id: session.id,
clientName: session.clientName,
albumName,
selected: count,
submittedAt: session.submittedAt ?? new Date().toISOString(),
adminUrl,
},
};
}
/** Parse PROOFING_WEBHOOK_URL; anything that is not an http(s) URL is ignored. */
export function webhookUrl(raw = process.env.PROOFING_WEBHOOK_URL): string | null {
const value = raw?.trim();
if (!value) return null;
try {
const url = new URL(value);
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
} catch {
return null;
}
}
export async function notifySubmitted(
session: ProofSession,
albumName: string,
siteUrl: string | null,
): Promise<void> {
const url = webhookUrl();
if (!url) return;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildWebhookPayload(session, albumName, siteUrl)),
signal: controller.signal,
});
if (!res.ok) {
console.warn(`[Proofing] Webhook answered ${res.status}; the selection is saved regardless.`);
}
} catch (err) {
console.warn(
'[Proofing] Webhook failed; the selection is saved regardless:',
err instanceof Error ? err.message : err,
);
} finally {
clearTimeout(timer);
}
}