Klarbild 1.0.0 — first public release
CI / check (push) Has been cancelled

A self-hosted print studio: images at exact physical sizes, sheet layout with
crop marks, tiling, 1:1 PDF. Everything else — image generation, delivery,
share links, mail, Telegram — is an optional module that is off by default.

This is the neutral public twin of a private tool. Compared to that original:

- optional modules behind KLARBILD_MODULES; the default install is print-only
- English interface with German as a full second language (1,038 strings);
  the English sentence is the translation key
- no built-in accounts — first boot creates one admin from ADMIN_USERNAME /
  ADMIN_PASSWORD, or generates a password and logs it once
- delivery and backup targets are generic SFTP/FTPS endpoints, off by default
- English routes, English identifiers, English comments throughout
- two compose files: print-only by default, everything in the full one
- AGPL-3.0, CI (build, tests, type check, secret scan), CONTRIBUTING, SECURITY

124 tests green, type check clean, verified end to end against a live instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
This commit is contained in:
tillandClaude Opus 5 committed 2026-09-15 14:52:41 +00:00
commit e2481139a1
164 files changed
+27190

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
import type { APIRoute } from 'astro';
import { query } from '../../../lib/db';
import { updateShare, revokeShare, unrevokeShare, deleteShare } from '../../../lib/shares';
import { has, moduleOff } from '../../../lib/modules';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** The most recent views of a link — for the report in the frontend. */
export const GET: APIRoute = async ({ params, locals }) => {
if (!has('share')) return moduleOff('share');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
if (!UUID.test(params.id || '')) return json({ error: 'Unknown link.' }, 404);
const isAdmin = locals.user.role === 'admin';
const rows = await query<any>(
`SELECT v.kind, v.at, v.user_agent
FROM share_views v JOIN shares s ON s.id = v.share_id
WHERE v.share_id = $1 AND ($3::boolean OR s.created_by = $2)
ORDER BY v.at DESC LIMIT 100`, [params.id, locals.user.uid, isAdmin]);
return json({ visits: rows });
};
/** Change the title, expiry, passphrase or download permission; revoke or release. */
export const PATCH: APIRoute = async ({ params, request, locals }) => {
if (!has('share')) return moduleOff('share');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
if (!UUID.test(params.id || '')) return json({ error: 'Unknown link.' }, 404);
const b = await request.json().catch(() => ({}));
const isAdmin = locals.user.role === 'admin';
if (b.action === 'revoke') {
return json({ ok: await revokeShare(params.id!, locals.user.uid, isAdmin) });
}
if (b.action === 'unrevoke') {
return json({ ok: await unrevokeShare(params.id!, locals.user.uid, isAdmin) });
}
const ok = await updateShare(params.id!, locals.user.uid, isAdmin, {
title: b.title, note: b.note, days: b.days,
allowDownload: b.allowDownload, password: b.password,
});
return ok ? json({ ok: true }) : json({ error: 'Unknown link.' }, 404);
};
export const DELETE: APIRoute = async ({ params, locals }) => {
if (!has('share')) return moduleOff('share');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
if (!UUID.test(params.id || '')) return json({ error: 'Unknown link.' }, 404);
const ok = await deleteShare(params.id!, locals.user.uid, locals.user.role === 'admin');
return ok ? json({ ok: true }) : json({ error: 'Unknown link.' }, 404);
};
+73
View File
@@ -0,0 +1,73 @@
import type { APIRoute } from 'astro';
import { query } from '../../../lib/db';
import { createShare, listShares, MAX_ITEMS } from '../../../lib/shares';
import { has, moduleOff } from '../../../lib/modules';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** List your own share links (admins see all of them). */
export const GET: APIRoute = async ({ locals }) => {
if (!has('share')) return moduleOff('share');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const shares = await listShares(locals.user.uid, locals.user.role === 'admin');
return json({ shares });
};
/**
* Create a new link.
* Only images you are allowed to see may be shared — otherwise the share button
* would be a comfortable way around the visibility rule.
*/
export const POST: APIRoute = async ({ request, locals }) => {
if (!has('share')) return moduleOff('share');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json().catch(() => ({}));
const ids: string[] = Array.isArray(b.itemIds) ? b.itemIds.filter((x: any) => typeof x === 'string') : [];
if (!ids.length) return json({ error: 'No images selected.' }, 400);
if (ids.length > MAX_ITEMS) return json({ error: `At most ${MAX_ITEMS} images per link.` }, 422);
// Check the ids first — an invalid one would otherwise make Postgres abort with
// "invalid input syntax for type uuid" and the caller would get a 500 instead
// of a clear answer.
if (ids.some((id) => !UUID.test(id))) return json({ error: 'Invalid image id.' }, 400);
const isAdmin = locals.user.role === 'admin';
// One query instead of one per image: with 500 images that was 500 round trips.
const found = await query<{ id: string; status: string; result_path: string | null; created_by: string | null; private: boolean; library_visibility: string }>(
`SELECT i.id, i.status, i.result_path, j.created_by, j.private,
(SELECT library_visibility FROM settings WHERE id=1) AS library_visibility
FROM items i JOIN jobs j ON j.id = i.job_id
WHERE i.id = ANY($1::uuid[])`, [ids]);
if (found.length !== ids.length) return json({ error: 'One of the images is no longer available.' }, 404);
for (const it of found) {
if (!it.result_path || it.status !== 'done') return json({ error: 'One of the images is no longer available.' }, 404);
// Private jobs are deliberately never shared — the whole point is that they
// leave no trace.
if (it.private) return json({ error: 'Images from private sessions cannot be shared.' }, 403);
const own = it.created_by === locals.user.uid;
if (!isAdmin && !own && it.library_visibility !== 'shared')
return json({ error: 'No access to one of the images.' }, 403);
}
try {
const share = await createShare({
itemIds: ids,
kind: b.kind === 'collection' ? 'collection' : undefined,
title: b.title, note: b.note,
days: b.days, allowDownload: b.allowDownload, password: b.password,
userId: locals.user.uid,
});
return json({ share });
} catch (e: any) {
// Pass on our own messages (validity, empty selection), not database
// internals — those give away table and column names.
const own = /Validity|selected|images per link/.test(String(e?.message || ''));
if (!own) console.error('[shares] creating failed:', e?.message || e);
return json({ error: own ? e.message : 'The link could not be created.' }, 400);
}
};
+99
View File
@@ -0,0 +1,99 @@
import type { APIRoute } from 'astro';
import { one } from '../../../lib/db';
import { sendMail, mailLayout, mailReady, isEmail, esc } from '../../../lib/mail';
import { tooOften } from '../../../lib/limit';
import { has, moduleOff } from '../../../lib/modules';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
const baseUrl = () => (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
const MAX_RECIPIENTS = 10;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Send a share link by email.
*
* Deliberately **without an image attachment**: a poster at full resolution
* bursts any mailbox, and an attachment cannot be withdrawn afterwards. A link
* can be — it has an expiry date and can be switched off at any time.
*/
export const POST: APIRoute = async ({ request, locals }) => {
if (!has('share')) return moduleOff('share');
if (!has('mail')) return moduleOff('mail');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json().catch(() => ({}));
const recipients: string[] = (Array.isArray(b.to) ? b.to : String(b.to || '').split(/[,;\s]+/))
.map((x: string) => String(x).trim().toLowerCase()).filter(Boolean);
if (!recipients.length) return json({ error: 'No recipient address given.' }, 400);
if (recipients.length > MAX_RECIPIENTS) return json({ error: `At most ${MAX_RECIPIENTS} recipients at once.` }, 422);
const bad = recipients.filter((e) => !isEmail(e));
if (bad.length) return json({ error: `Not a valid address: ${bad[0]}` }, 400);
if (!(await mailReady())) return json({ error: 'No outgoing mail is configured (Admin → Email).' }, 503);
// Without an absolute base address the mail would contain a relative path that
// no mailbox makes clickable — then it is better not to send at all.
if (!/^https?:\/\//.test(baseUrl()))
return json({ error: 'The base address of the server is missing (PUBLIC_BASE_URL) — the link in the mail would be useless.' }, 503);
// Limit per sender, not per IP: whoever is signed in is identified.
if (tooOften('mailversand:' + locals.user.uid, 50, 60 * 60_000))
return json({ error: 'Enough has been sent for this hour. Please carry on later.' }, 429);
if (!UUID.test(String(b.shareId || ''))) return json({ error: 'Unknown link.' }, 404);
const isAdmin = locals.user.role === 'admin';
const share = await one<any>(
`SELECT s.*, (SELECT count(*)::int FROM share_items si WHERE si.share_id = s.id) AS item_count
FROM shares s WHERE s.id = $1 AND ($3::boolean OR s.created_by = $2)`,
[b.shareId, locals.user.uid, isAdmin]);
if (!share) return json({ error: 'Unknown link.' }, 404);
if (share.revoked_at) return json({ error: 'This link is switched off — release it again first.' }, 409);
// The expiry counts too: otherwise grandma gets a mail pointing at a page that
// tells her "This link has expired".
if (share.expires_at && new Date(share.expires_at).getTime() < Date.now())
return json({ error: 'This link has expired — extend its validity first.' }, 409);
const url = `${baseUrl()}/s/${share.slug}`;
const sender = locals.user.name || locals.user.username || 'Someone';
const title = share.title || (share.item_count > 1 ? `${share.item_count} images` : 'An image');
const message = String(b.message || '').trim().slice(0, 1000);
const validity = share.expires_at
? `The link is valid until ${new Date(share.expires_at).toLocaleDateString('en-GB')}.`
: 'The link does not expire.';
const passphrase = share.password_hash
? 'To open it you also need the passphrase — you will get that separately.'
: '';
// `mailLayout` deliberately puts paragraphs into the HTML raw so that <b>
// works — everything that comes from user input therefore has to be escaped
// **here**. Previously only the message was escaped, not the title: that made
// it possible to send a foreign link in familiar clothing through our own mail
// server, signed with SPF and DKIM.
const paragraphs = [
`${esc(sender)} has sent you something via Klarbild: <b>${esc(title)}</b>.`,
...(message ? [esc(message).replace(/\n/g, '<br>')] : []),
[validity, passphrase].filter(Boolean).join(' '),
];
let ok = 0; const failed: string[] = [];
for (const to of recipients) {
try {
await sendMail({
to,
subject: `${sender} shares: ${title}`,
text: `${sender} has sent you something via Klarbild: ${title}.\n\n`
+ (message ? `${message}\n\n` : '')
+ `${url}\n\n${validity}${passphrase ? ' ' + passphrase : ''}`,
html: mailLayout(title, paragraphs, { text: 'View the images', url }),
});
ok++;
} catch (e: any) {
console.error('[share/send] to', to, 'failed:', e?.message || e);
failed.push(to);
}
}
return json({ ok, failed, url });
};