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

+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);
}
};