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