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

+104
View File
@@ -0,0 +1,104 @@
import type { APIRoute } from 'astro';
import sharp from 'sharp';
import { one } from '../../../../lib/db';
import { getObject } from '../../../../lib/storage';
import { resolveShare, shareHasItem, trackShare, proofValid, proofFromCookie } from '../../../../lib/shares';
import { has, moduleOff } from '../../../../lib/modules';
export const prerender = false;
/**
* Serve an image behind a share link — **without a sign-in**.
*
* The access check hangs on the link alone, not on the image: only what is in
* `share_items` comes out. So nobody can fetch someone else's images through a
* valid link by substituting a different image id.
*/
export const GET: APIRoute = async ({ params, url, request, clientAddress }) => {
if (!has('share')) return moduleOff('share');
const q = url.searchParams;
// No `?pw=` any more: for protected links only the proof counts that the page
// set as a cookie after checking once. Otherwise argon2 (130 ms, 64 MiB) would
// run again for every single image — with a gallery of 40 images that is enough
// to drive the process into the memory limit.
let { status, share } = await resolveShare(params.slug!, null);
if (status === 'passphrase' && share
&& proofValid(share.id, proofFromCookie(request.headers.get('cookie'), params.slug!))) {
status = 'ok';
}
if (status !== 'ok' || !share) {
return new Response(
status === 'expired' ? 'This link has expired.'
: status === 'revoked' ? 'This link has been switched off.'
: status === 'passphrase' ? 'A passphrase is required.'
: 'Not found.',
{ status: status === 'passphrase' ? 401 : status === 'not-found' ? 404 : 410 });
}
const itemId = q.get('item');
if (!itemId || !/^[0-9a-f-]{36}$/i.test(itemId)) return new Response('Not found.', { status: 404 });
if (!(await shareHasItem(share.id, itemId))) return new Response('Not found.', { status: 404 });
const item = await one<any>(
'SELECT result_path, thumb_path, filename, has_alpha FROM items WHERE id=$1', [itemId]);
if (!item?.result_path) return new Response('Not found.', { status: 404 });
const download = q.get('download') === '1';
const wantThumb = q.get('thumb') === '1';
// Whoever switched the download off does not hand out the original file either —
// otherwise the setting would be decoration.
if (download && !share.allow_download) return new Response('Downloading is off for this link.', { status: 403 });
const path = wantThumb ? (item.thumb_path || item.result_path) : item.result_path;
try {
let buf = await getObject(path);
const sniff = (b: Buffer): string =>
b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff ? 'image/jpeg'
: b.length > 12 && b.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp'
: 'image/png';
let contentType = path.endsWith('.webp') ? 'image/webp' : sniff(buf);
// Viewing take: smaller and faster, above all on a phone. The download still
// delivers the untouched original file at full resolution.
//
// Important: this did **not** used to apply to cut-out motifs — they went
// through unfiltered, at full resolution, saveable with a right click. That
// made "downloading off" useless for exactly stickers and cut-outs. Alpha
// images are therefore scaled down as PNG instead of being skipped.
if (!download && !wantThumb) {
try {
const small = sharp(buf, { failOn: 'none', limitInputPixels: 400_000_000 })
.resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true });
if (item.has_alpha) { buf = await small.png({ compressionLevel: 8 }).toBuffer(); contentType = 'image/png'; }
else { buf = await small.jpeg({ quality: 88, mozjpeg: true }).toBuffer(); contentType = 'image/jpeg'; }
} catch (e: any) {
// Only when the download is blocked is the original off limits — then it
// is better to serve nothing than to serve full resolution by accident.
console.error('[share] viewing take failed:', e?.message || e);
if (!share.allow_download) return new Response('Preview not available.', { status: 503 });
}
}
if (download) {
await trackShare(share.id, 'download', clientAddress, request.headers.get('user-agent'));
}
const headers: Record<string, string> = {
'Content-Type': contentType,
'Content-Length': String(buf.length),
// Not into search engines and not into anyone else's cache.
'X-Robots-Tag': 'noindex, nofollow, noarchive, noimageindex',
'Cache-Control': 'private, max-age=300',
};
if (download) {
const name = (item.filename || 'klarbild.jpg').replace(/[^\w.\-]/g, '_');
headers['Content-Disposition'] = `attachment; filename="${name}"`;
}
return new Response(new Uint8Array(buf), { headers });
} catch (e: any) {
// The cause goes into the log — otherwise, during a storage outage, the
// operator searches in vain while every recipient sees "image no longer there".
console.error('[share] delivery failed', path, e?.message || e);
return new Response('This image is no longer there.', { status: 404 });
}
};
+117
View File
@@ -0,0 +1,117 @@
import type { APIRoute } from 'astro';
import archiver from 'archiver';
import { Readable } from 'node:stream';
import { once } from 'node:events';
import { query } from '../../../../lib/db';
import { getObject } from '../../../../lib/storage';
import { resolveShare, shareItems, trackShare, proofValid, proofFromCookie,
archiveName, packageName } from '../../../../lib/shares';
import { tooOften, realIp } from '../../../../lib/limit';
import { has, moduleOff } from '../../../../lib/modules';
export const prerender = false;
/** Upper limits. A ZIP is the most expensive way to pull data out of this server. */
const MAX_FILES = 200;
const MAX_BYTES = 500 * 1024 * 1024; // 500 MB unpacked
/**
* All images of a share link as a ZIP.
*
* Deliberately **GET**: that makes it a plain link on the page which works even
* without JavaScript — and the recipient is often on a phone.
*
* Unlike `/api/items/zip` (for signed-in users), here **one file after another**
* is read and passed on: after every `append` the code waits for the `entry`
* event. Otherwise twenty posters would quickly put a few gigabytes into memory
* at the same time — and `mem_limit` is 3 GB.
*/
export const GET: APIRoute = async ({ params, request, clientAddress }) => {
if (!has('share')) return moduleOff('share');
const slug = params.slug!;
let { status, share } = await resolveShare(slug, null);
if (status === 'passphrase' && share
&& proofValid(share.id, proofFromCookie(request.headers.get('cookie'), slug))) {
status = 'ok';
}
if (status !== 'ok' || !share) {
return new Response(
status === 'expired' ? 'This link has expired.'
: status === 'revoked' ? 'This link has been switched off.'
: status === 'passphrase' ? 'A passphrase is required.'
: 'Not found.',
{ status: status === 'passphrase' ? 401 : status === 'not-found' ? 404 : 410 });
}
// Whoever blocked the download means the bundle too.
if (!share.allow_download) {
return new Response('Downloading is switched off for this link.', { status: 403 });
}
// A ZIP costs processing time and bandwidth — not once a second.
const ip = realIp(request, clientAddress);
if (tooOften(`zip:${share.id}:${ip}`, 5, 10 * 60_000)) {
return new Response('Just downloaded a moment ago. Please wait a little.', { status: 429 });
}
const images = await shareItems(share.id);
if (!images.length) return new Response('There are no images for this link.', { status: 404 });
if (images.length > MAX_FILES) {
return new Response(`This bundle would be too large (${images.length} images). Please download them one by one.`, { status: 413 });
}
const paths = await query<{ id: string; result_path: string; filename: string | null }>(
`SELECT id, result_path, filename FROM items
WHERE id = ANY($1::uuid[]) AND result_path IS NOT NULL`,
[images.map((b) => b.id)]);
const byId = new Map(paths.map((p) => [p.id, p]));
const archive = archiver('zip', { zlib: { level: 6 } });
// Errors must not take the process with them; the stream then breaks off.
archive.on('error', (e: any) => console.error('[share/zip]', e?.message || e));
archive.on('warning', (e: any) => console.error('[share/zip] warning:', e?.message || e));
// The handlers are in place **before** anything is written into it.
const web = Readable.toWeb(archive) as ReadableStream<Uint8Array>;
(async () => {
const taken = new Map<string, number>();
let bytes = 0;
try {
for (const b of images) {
const p = byId.get(b.id);
if (!p) continue;
let buf: Buffer;
try { buf = await getObject(p.result_path); }
catch (e: any) { console.error('[share/zip] file missing', p.result_path, e?.message || e); continue; }
bytes += buf.length;
if (bytes > MAX_BYTES) {
archive.append(
Buffer.from(
'This bundle was cut off at 500 MB.\r\n'
+ 'The remaining images can be downloaded one by one on the page.\r\n', 'utf8'),
{ name: 'NOTE.txt' });
break;
}
archive.append(buf, { name: archiveName(p.filename, taken) });
// One file after another: only read on once this one is in the stream.
await once(archive, 'entry');
}
} finally {
archive.finalize().catch(() => {});
}
})();
await trackShare(share.id, 'download', clientAddress, request.headers.get('user-agent'));
return new Response(web, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${packageName(share.title)}"`,
'X-Robots-Tag': 'noindex, nofollow',
'Cache-Control': 'no-store',
},
});
};