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

+24
View File
@@ -0,0 +1,24 @@
---
import Base from '../layouts/Base.astro';
import AccountApp from '../components/AccountApp';
import { tFor, dictFor } from '../lib/i18n.server';
const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
---
<Base title={t('My account · Klarbild')}>
<section class="page">
<header class="pagehead">
<h1>{t('My account')}</h1>
<p class="lead">{t('Email address and notifications.')}</p>
</header>
<AccountApp client:load locale={locale} dict={dict} />
</section>
<style>
.page { max-width: var(--maxw); margin: 0 auto; padding: 26px 20px 80px; }
.pagehead { margin-bottom: 18px; }
.pagehead h1 { font-size: clamp(1.4rem, 4vw, 1.9rem); margin: 0 0 6px; letter-spacing: -0.02em; }
.lead { margin: 0; color: var(--ink-soft); line-height: 1.6; font-size: 0.95rem; }
@media (max-width: 640px) { .page { padding: 18px 14px 90px; } }
</style>
</Base>
+14
View File
@@ -0,0 +1,14 @@
---
import Base from '../../layouts/Base.astro';
import AdminApp from '../../components/AdminApp.tsx';
import { tFor, dictFor } from '../../lib/i18n.server';
if (Astro.locals.user?.role !== 'admin') return Astro.redirect('/');
const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
---
<Base title={t('Admin · Klarbild')}>
<h1 class="page-title">{t('Admin')}</h1>
<AdminApp client:load locale={locale} dict={dict} />
<style>.page-title{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 16px;letter-spacing:-.02em;}</style>
</Base>
+45
View File
@@ -0,0 +1,45 @@
import type { APIRoute } from 'astro';
import posixpath from 'node:path/posix';
import { loadConfig, cleanupTmp } from '../../../lib/remotetarget';
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' } });
/** Diagnostics: lists a folder on the delivery target (default: the base folder). Body { path? }
* Clean up: body { action:'cleanup_tmp', gallery } removes .tmp files left behind. */
export const POST: APIRoute = async ({ request }) => {
if (!has('delivery')) return moduleOff('delivery');
const b = await request.json().catch(() => ({} as any));
const cfg = await loadConfig();
if (!cfg) return json({ error: 'Delivery target is not configured.' }, 400);
if (b.action === 'cleanup_tmp') {
try {
const removed = await cleanupTmp(cfg, b.gallery || '');
return json({ cleaned: removed });
} catch (e: any) { return json({ error: e?.message || 'Clean-up failed.' }, 500); }
}
const path = b.path || cfg.basePath || '/';
try {
if (cfg.protocol === 'sftp') {
const SftpClient = (await import('ssh2-sftp-client')).default;
const c = new SftpClient();
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 });
try {
const list = await c.list(path);
return json({ path, entries: list.map((e: any) => ({ name: e.name, type: e.type, size: e.size })) });
} finally { await c.end(); }
} else {
const { Client } = await import('basic-ftp');
const c = new Client(15000);
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
try {
const list = await c.list(path);
return json({ path, entries: list.map((e: any) => ({ name: e.name, type: e.type, size: e.size })) });
} finally { c.close(); }
}
} catch (e: any) {
return json({ path, error: e?.message || 'Listing failed.' });
}
};
+66
View File
@@ -0,0 +1,66 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
import { encrypt } from '../../../lib/crypto';
import { loadTargetConfig, testConnection } from '../../../lib/remotetarget';
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' } });
export const GET: APIRoute = async () => {
if (!has('delivery')) return moduleOff('delivery');
const rows = await query<any>(`SELECT id, name, protocol, host, port, username, base_path, is_backup, metadata_sidecar,
(password_enc IS NOT NULL) AS password_set FROM delivery_targets ORDER BY name`);
return json({ targets: rows });
};
// POST: create | { action:'test', id } | { action:'backup_all' }
export const POST: APIRoute = async ({ request }) => {
if (!has('delivery')) return moduleOff('delivery');
const b = await request.json();
if (b.action === 'test') {
const cfg = await loadTargetConfig(b.id);
if (!cfg) return json({ ok: false, message: 'Target is not fully configured.' });
return json(await testConnection(cfg));
}
if (b.action === 'backup_all') {
const { backupAll } = await import('../../../lib/backup');
return json(await backupAll());
}
if (!b.name?.trim()) return json({ error: 'Name is missing.' }, 400);
const row = await one<any>(
`INSERT INTO delivery_targets (name, protocol, host, port, username, password_enc, base_path, is_backup, metadata_sidecar)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
[b.name.trim(), b.protocol || 'sftp', b.host || null, b.port || null, b.username || null,
b.password ? encrypt(String(b.password)) : null, b.base_path || null, !!b.is_backup, !!b.metadata_sidecar]);
return json({ id: row!.id });
};
// PATCH: edit
export const PATCH: APIRoute = async ({ request }) => {
if (!has('delivery')) return moduleOff('delivery');
const b = await request.json();
if (!b.id) return json({ error: 'No id.' }, 400);
const sets: string[] = []; const args: any[] = [];
const set = (c: string, v: any) => { args.push(v); sets.push(`${c}=$${args.length}`); };
for (const c of ['name', 'protocol', 'host', 'port', 'username', 'base_path']) {
if (c in b) set(c, b[c] === '' ? null : b[c]);
}
if ('is_backup' in b) set('is_backup', !!b.is_backup);
if ('metadata_sidecar' in b) set('metadata_sidecar', !!b.metadata_sidecar);
if (b.password) set('password_enc', encrypt(String(b.password)));
if (!sets.length) return json({ ok: true });
args.push(b.id);
await query(`UPDATE delivery_targets SET ${sets.join(',')} WHERE id=$${args.length}`, args);
return json({ ok: true });
};
export const DELETE: APIRoute = async ({ request, url }) => {
if (!has('delivery')) return moduleOff('delivery');
let id = url.searchParams.get('id');
if (!id) { try { id = (await request.json())?.id; } catch { /* no matter */ } }
if (!id) return json({ error: 'No id.' }, 400);
await query('DELETE FROM delivery_targets WHERE id=$1', [id]);
return json({ ok: true });
};
+52
View File
@@ -0,0 +1,52 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
import { listImageModels } from '../../../lib/openrouter';
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' } });
// GET: the stored models; ?available=1 adds the ones OpenRouter offers.
export const GET: APIRoute = async ({ url }) => {
if (!has('ai')) return moduleOff('ai');
const models = await query('SELECT * FROM models ORDER BY sort, label');
const out: any = { models };
if (url.searchParams.get('available') === '1') {
try {
const avail = await listImageModels();
out.available = avail.map((m: any) => ({
model_id: m.id, name: m.name,
supports_alpha: (m.supported_parameters?.background?.values || []).includes('transparent')
|| !!m.supported_parameters?.background,
}));
} catch (e: any) { out.available_error = e?.friendly || 'Could not fetch the model list.'; }
}
return json(out);
};
// POST: create/update a model (upsert by model_id) or run an action (toggle/default/delete).
export const POST: APIRoute = async ({ request }) => {
if (!has('ai')) return moduleOff('ai');
const b = await request.json();
if (b.action === 'delete' && b.id) { await query('DELETE FROM models WHERE id=$1', [b.id]); return json({ ok: true }); }
if (b.action === 'default' && b.id) {
await query('UPDATE models SET is_default=false'); await query('UPDATE models SET is_default=true WHERE id=$1', [b.id]);
return json({ ok: true });
}
if (b.action === 'nsfw' && b.id) {
await query('UPDATE models SET nsfw = NOT nsfw WHERE id=$1', [b.id]);
return json({ ok: true });
}
if (!b.model_id || !b.label) return json({ error: 'model_id and label are required.' }, 400);
const existing = await one<{ id: string }>('SELECT id FROM models WHERE model_id=$1', [b.model_id]);
if (existing) {
await query(`UPDATE models SET label=$2, description=$3, active=$4, supports_alpha=$5, sort=$6 WHERE id=$1`,
[existing.id, b.label, b.description || null, b.active ?? true, b.supports_alpha ?? false, b.sort ?? 0]);
return json({ id: existing.id });
}
const row = await one(`INSERT INTO models (model_id, label, description, active, supports_alpha, sort)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[b.model_id, b.label, b.description || null, b.active ?? true, b.supports_alpha ?? false, b.sort ?? 0]);
return json({ model: row });
};
+56
View File
@@ -0,0 +1,56 @@
import type { APIRoute } from 'astro';
import net from 'node:net';
import dns from 'node:dns/promises';
import { one } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** TCP connection test from inside the container (with a timeout). */
function tcpProbe(host: string, port: number, timeoutMs = 6000): Promise<{ ok: boolean; ms: number; error?: string }> {
return new Promise((resolve) => {
const start = Date.now();
const sock = new net.Socket();
let done = false;
const finish = (ok: boolean, error?: string) => {
if (done) return; done = true;
try { sock.destroy(); } catch { /* no matter */ }
resolve({ ok, ms: Date.now() - start, error });
};
sock.setTimeout(timeoutMs);
sock.once('connect', () => finish(true));
sock.once('timeout', () => finish(false, 'Timeout — no answer (firewall or routing?)'));
sock.once('error', (e: any) => finish(false, e?.code || e?.message || 'Connection error'));
sock.connect(port, host);
});
}
/** Checks whether a host:port can be reached from the container. Body {host,port}, or
* the configured backup mirror host if nothing is given. Also resolves DNS. */
export const POST: APIRoute = async ({ request }) => {
const b = await request.json().catch(() => ({} as any));
let host = (b.host || '').trim();
let port = Number(b.port) || 0;
if (!host) {
const s = await one<{ mirror_host: string | null; mirror_port: number | null; mirror_protocol: string | null }>(
'SELECT mirror_host, mirror_port, mirror_protocol FROM settings WHERE id=1');
host = (s?.mirror_host || '').trim();
port = s?.mirror_port || (s?.mirror_protocol === 'ftps' ? 21 : 22);
}
if (!host) return json({ error: 'No host given.' }, 400);
if (!port) port = 22;
const out: any = { host, port };
// DNS: only if this is not a plain IP.
const isIp = net.isIP(host) !== 0;
if (!isIp) {
try { const a = await dns.lookup(host); out.resolve = { ok: true, address: a.address }; }
catch (e: any) { out.resolve = { ok: false, error: e?.code || 'DNS lookup failed' }; }
} else {
out.resolve = { ok: true, address: host, note: 'IP address' };
}
out.tcp = await tcpProbe(host, port);
out.reachable = out.tcp.ok;
return json(out);
};
+36
View File
@@ -0,0 +1,36 @@
import type { APIRoute } from 'astro';
import { query } from '../../../lib/db';
import { deleteObject } from '../../../lib/storage';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** Resets the content: deletes all images, jobs and folders plus their files.
* Users, settings, models and preset recipes are kept.
* Requires { confirm: 'RESET' }. */
export const POST: APIRoute = async ({ request, locals }) => {
if (locals.user?.role !== 'admin') return new Response('Forbidden', { status: 403 });
const b = await request.json().catch(() => ({} as any));
if (b.confirm !== 'RESET') return json({ error: 'Confirmation is missing (confirm=RESET).' }, 400);
// Remove every object from storage first.
const items = await query<any>('SELECT source_path, source_paths, result_path, thumb_path FROM items');
let objects = 0;
for (const it of items) {
const keys = [it.source_path, it.result_path, it.thumb_path,
...((it.source_paths as string[]) || [])].filter(Boolean);
for (const k of keys) { await deleteObject(k).catch(() => {}); objects++; }
}
// Empty the content tables (the configuration stays).
await query('DELETE FROM print_job_items');
await query('DELETE FROM print_jobs');
await query('DELETE FROM share_links');
await query('DELETE FROM telegram_drafts');
await query('DELETE FROM items');
await query('DELETE FROM jobs');
await query('DELETE FROM folders');
return json({ ok: true, deleted_items: items.length, deleted_objects: objects });
};
+77
View File
@@ -0,0 +1,77 @@
import type { APIRoute } from 'astro';
import { randomBytes } from 'node:crypto';
import { one, query } from '../../../lib/db';
import { encrypt, maskSecret, decrypt } from '../../../lib/crypto';
import { loadMailSource } from '../../../lib/mail';
export const prerender = false;
const randomToken = () => 'klb_' + randomBytes(24).toString('hex');
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async () => {
const s = await one<any>('SELECT * FROM settings WHERE id=1');
const mail = await loadMailSource();
if (!s) return json({ settings: null });
let orMask = '';
if (s.openrouter_key_enc) { try { orMask = maskSecret(decrypt(s.openrouter_key_enc)); } catch { orMask = 'set'; } }
return json({ settings: {
openrouter_key_masked: orMask, openrouter_key_set: !!s.openrouter_key_enc,
delivery_host: s.delivery_host, delivery_protocol: s.delivery_protocol, delivery_port: s.delivery_port,
delivery_user: s.delivery_user, delivery_base_path: s.delivery_base_path,
delivery_default_folder: s.delivery_default_folder,
delivery_password_set: !!s.delivery_password_enc,
default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency,
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
// Storage housekeeping
keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days,
metadata_sidecar: s.metadata_sidecar, api_token: s.api_token,
delivery_metadata_sidecar: s.delivery_metadata_sidecar, mirror_metadata_sidecar: s.mirror_metadata_sidecar,
output_ext: s.output_ext,
// Permissions / privacy / NSFW
library_visibility: s.library_visibility, anonymous_generations: s.anonymous_generations,
private_allowed: s.private_allowed, allow_nsfw: s.allow_nsfw,
// Backup mirror
mirror_enabled: s.mirror_enabled, mirror_host: s.mirror_host, mirror_protocol: s.mirror_protocol, mirror_port: s.mirror_port,
mirror_user: s.mirror_user, mirror_base_path: s.mirror_base_path, mirror_password_set: !!s.mirror_password_enc,
// Outgoing mail (the password is never returned, only whether it is set)
smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure,
smtp_user: s.smtp_user, smtp_from: s.smtp_from, smtp_password_set: !!s.smtp_password,
// Where the credentials in effect come from: 'database' | 'environment' | null
smtp_source: mail?.source ?? null,
smtp_effective: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null,
// Sharing
share_default_days: s.share_default_days,
} });
};
export const PATCH: APIRoute = async ({ request }) => {
const b = await request.json();
const sets: string[] = []; const args: any[] = [];
const set = (col: string, val: any) => { args.push(val); sets.push(`${col}=$${args.length}`); };
if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key)));
if (b.delivery_password) set('delivery_password_enc', encrypt(String(b.delivery_password)));
if (b.mirror_password) set('mirror_password_enc', encrypt(String(b.mirror_password)));
if (b.smtp_password) set('smtp_password', encrypt(String(b.smtp_password)));
if (b.generate_api_token) set('api_token', randomToken());
const boolCols = ['keep_sources', 'make_thumbnails', 'mirror_enabled', 'metadata_sidecar',
'delivery_metadata_sidecar', 'mirror_metadata_sidecar', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
'smtp_secure'];
for (const col of ['delivery_host', 'delivery_protocol', 'delivery_port', 'delivery_user', 'delivery_base_path',
'delivery_default_folder', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url',
'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token',
'delivery_metadata_sidecar', 'mirror_metadata_sidecar', 'output_ext',
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
'mirror_enabled', 'mirror_host', 'mirror_protocol', 'mirror_port', 'mirror_user', 'mirror_base_path',
'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_from', 'share_default_days']) {
if (col in b) {
const raw = b[col];
const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
set(col, val);
}
}
if (!sets.length) return json({ ok: true });
await query(`UPDATE settings SET ${sets.join(',')} WHERE id=1`, args);
return json({ ok: true });
};
+25
View File
@@ -0,0 +1,25 @@
import type { APIRoute } from 'astro';
import { query, one } from '../../../lib/db';
export const prerender = false;
export const GET: APIRoute = async () => {
const total = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='done'`);
const failed = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='failed'`);
const cost = async (days: number | null) => (await one<{ c: string }>(
`SELECT COALESCE(sum(cost),0)::text c FROM items WHERE cost IS NOT NULL` +
(days ? ` AND created_at > now() - interval '${days} days'` : ''))
)?.c || '0';
const byModel = await query(`SELECT COALESCE(model_used,'—') m, count(*)::int n, COALESCE(sum(cost),0)::float c
FROM items WHERE status='done' GROUP BY 1 ORDER BY n DESC`);
const last = await one<{ t: string }>(`SELECT max(created_at)::text t FROM items`);
const totalN = Number(total?.n || 0), failedN = Number(failed?.n || 0);
return new Response(JSON.stringify({
total: totalN, failed: failedN,
error_rate: totalN + failedN ? Math.round((failedN / (totalN + failedN)) * 100) : 0,
cost_today: Number(await cost(1)), cost_7: Number(await cost(7)),
cost_30: Number(await cost(30)), cost_all: Number(await cost(null)),
by_model: byModel, last_activity: last?.t || null,
}), { headers: { 'Content-Type': 'application/json' } });
};
+28
View File
@@ -0,0 +1,28 @@
import type { APIRoute } from 'astro';
import { storageStats, runRetention, purgeSources, mirrorAllToBackup, rebuildThumbs } from '../../../lib/maintenance';
import { renameLibrary, redeliverAll } from '../../../lib/renames';
import { one } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async () => json({ stats: await storageStats() });
// POST { action: 'retention' | 'purge_sources' | 'rename_preview' | 'rename_apply' | 'redeliver_all' | … }
export const POST: APIRoute = async ({ request }) => {
const b = await request.json().catch(() => ({}));
if (b.action === 'purge_sources') return json(await purgeSources());
if (b.action === 'mirror_all') return json(await mirrorAllToBackup());
if (b.action === 'rebuild_thumbs') return json(await rebuildThumbs());
// Unique file names: first show what would happen, then apply it.
if (b.action === 'rename_preview') return json(await renameLibrary(true));
if (b.action === 'rename_apply') return json(await renameLibrary(false));
if (b.action === 'redeliver_all') return json(await redeliverAll());
if (b.action === 'retention') {
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
if (!s?.retention_days) return json({ error: 'No retention period is set.' }, 400);
return json(await runRetention(s.retention_days));
}
return json({ error: 'Unknown action.' }, 400);
};
+22
View File
@@ -0,0 +1,22 @@
import type { APIRoute } from 'astro';
import { query } from '../../../../lib/db';
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' } });
export const GET: APIRoute = async () => {
if (!has('telegram')) return moduleOff('telegram');
const links = await query(`SELECT l.chat_id, l.active, l.linked_at, u.display_name AS user_name
FROM telegram_links l LEFT JOIN users u ON u.id=l.user_id ORDER BY l.linked_at DESC`);
return json({ links });
};
export const DELETE: APIRoute = async ({ request }) => {
if (!has('telegram')) return moduleOff('telegram');
const b = await request.json().catch(() => ({}));
if (!b.chat_id) return json({ error: 'chat_id is required.' }, 400);
await query('DELETE FROM telegram_links WHERE chat_id=$1', [b.chat_id]);
return json({ ok: true });
};
@@ -0,0 +1,22 @@
import type { APIRoute } from 'astro';
import { one } from '../../../../lib/db';
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' } });
// Creates a six-digit pairing code (valid for 15 minutes) for a user.
export const POST: APIRoute = async ({ request, locals }) => {
if (!has('telegram')) return moduleOff('telegram');
const b = await request.json().catch(() => ({}));
const userId = b.userId || locals.user?.uid;
if (!userId) return json({ error: 'userId is required.' }, 400);
// Six-digit code (Math.random is fine here — no cryptographic secret, and short-lived)
const code = String(Math.floor(100000 + Math.random() * 900000));
await one(`INSERT INTO telegram_pairing_codes (code, user_id, expires_at)
VALUES ($1,$2, now() + interval '15 minutes')
ON CONFLICT (code) DO UPDATE SET user_id=$2, expires_at=now()+interval '15 minutes', used_at=NULL`,
[code, userId]);
return json({ code, expires_minutes: 15 });
};
+14
View File
@@ -0,0 +1,14 @@
import type { APIRoute } from 'astro';
import { loadConfig, testConnection } from '../../../lib/remotetarget';
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' } });
export const POST: APIRoute = async () => {
if (!has('delivery')) return moduleOff('delivery');
const cfg = await loadConfig();
if (!cfg) return json({ ok: false, message: 'The delivery target is not fully configured.' });
return json(await testConnection(cfg));
};
+36
View File
@@ -0,0 +1,36 @@
import type { APIRoute } from 'astro';
import { testMail, sendMail, mailLayout, isEmail } from '../../../lib/mail';
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' } });
/**
* Without `to`: only check the connection (nothing is sent).
* With `to`: send a real test message — only that proves the mail actually
* arrives and does not end up in the spam folder.
*/
export const POST: APIRoute = async ({ request }) => {
if (!has('mail')) return moduleOff('mail');
const b = await request.json().catch(() => ({}));
const to = String(b.to || '').trim();
if (!to) return json(await testMail());
if (!isEmail(to)) return json({ ok: false, message: 'That is not a valid address.' }, 400);
try {
await sendMail({
to,
subject: 'Klarbild — test message',
text: 'If you are reading this, sending mail from Klarbild works.\n\n'
+ 'Which means these now work: password resets, notices about finished jobs, and share links by mail.',
html: mailLayout('Sending works', [
'If you are reading this, mail from your Klarbild installation arrives.',
'Which means these now work: resetting a password, notices about finished jobs, and share links by mail.',
]),
});
return json({ ok: true, message: `Test message sent to ${to}. Check the spam folder too.` });
} catch (e: any) {
console.error('[test-mail]', e?.message || e);
return json({ ok: false, message: 'Sending failed — check the credentials and the port.' });
}
};
+14
View File
@@ -0,0 +1,14 @@
import type { APIRoute } from 'astro';
import { loadMirrorConfig, testMirror } from '../../../lib/mirror';
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' } });
export const POST: APIRoute = async () => {
if (!has('mirror')) return moduleOff('mirror');
const cfg = await loadMirrorConfig();
if (!cfg) return json({ ok: false, message: 'The backup mirror is not fully configured (or switched off).' });
return json(await testMirror(cfg));
};
+41
View File
@@ -0,0 +1,41 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
import { hashPassword, bumpSessionEpoch } from '../../../lib/auth';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async () => {
const users = await query('SELECT id, username, role, display_name, private_forced, created_at FROM users ORDER BY username');
return json({ users });
};
// POST: create a new user OR set a password/attribute (when username or id already exists).
export const POST: APIRoute = async ({ request }) => {
const b = await request.json();
if (b.id && b.password) {
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [b.id, await hashPassword(b.password)]);
// A new password means old sessions no longer count.
await bumpSessionEpoch(b.id);
return json({ ok: true });
}
if (b.id && 'private_forced' in b) {
await query('UPDATE users SET private_forced=$2 WHERE id=$1', [b.id, !!b.private_forced]);
return json({ ok: true });
}
if (b.id && b.role) {
await query(`UPDATE users SET role=$2 WHERE id=$1`, [b.id, b.role === 'admin' ? 'admin' : 'user']);
// Role changed, so invalidate the session: otherwise a demoted admin would
// keep their rights until the cookie expires.
await bumpSessionEpoch(b.id);
return json({ ok: true });
}
if (!b.username || !b.password) return json({ error: 'username and password are required.' }, 400);
const exists = await one('SELECT id FROM users WHERE username=$1', [b.username.trim()]);
if (exists) return json({ error: 'That user name is taken.' }, 400);
const row = await one(`INSERT INTO users (username, role, display_name, password_hash)
VALUES ($1,$2,$3,$4) RETURNING id, username, role, display_name`,
[b.username.trim(), b.role === 'admin' ? 'admin' : 'user', b.display_name || b.username.trim(), await hashPassword(b.password)]);
return json({ user: row });
};
+44
View File
@@ -0,0 +1,44 @@
import type { APIRoute } from 'astro';
import { login, makeSessionCookie, rateLimited, noteFailure, clearFailures } from '../../../lib/auth';
import { realIp, tooOften } from '../../../lib/limit';
export const prerender = false;
export const POST: APIRoute = async ({ request, clientAddress }) => {
// Not `clientAddress` directly: that is the **first** value out of
// X-Forwarded-For and therefore set by the caller itself — the lock after
// five failed attempts could otherwise be sidestepped with a changing header.
const ip = realIp(request, clientAddress);
if (rateLimited(ip)) {
return new Response(JSON.stringify({ error: 'Too many attempts. Please try again in 15 minutes.' }),
{ status: 429, headers: { 'Content-Type': 'application/json' } });
}
let username = '', password = '';
const ct = request.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const b = await request.json().catch(() => ({}));
username = b.username || ''; password = b.password || '';
} else {
const f = await request.formData();
username = String(f.get('username') || ''); password = String(f.get('password') || '');
}
// Count per user name as well — otherwise the per-IP lock does nothing when
// the attacker comes from many networks.
if (tooOften('login-user:' + username.trim().toLowerCase(), 10, 15 * 60_000)) {
return new Response(JSON.stringify({ error: 'Too many attempts. Please try again in 15 minutes.' }),
{ status: 429, headers: { 'Content-Type': 'application/json' } });
}
const session = await login(username.trim(), password);
if (!session) {
noteFailure(ip);
return new Response(JSON.stringify({ error: 'Wrong user name or password.' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } });
}
clearFailures(ip);
return new Response(JSON.stringify({ ok: true, role: session.user.role }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Set-Cookie': makeSessionCookie(session.user, session.epoch) },
});
};
+10
View File
@@ -0,0 +1,10 @@
import type { APIRoute } from 'astro';
import { clearSessionCookie } from '../../../lib/auth';
export const prerender = false;
export const POST: APIRoute = async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Set-Cookie': clearSessionCookie() },
});
+94
View File
@@ -0,0 +1,94 @@
import type { APIRoute } from 'astro';
import { randomBytes, createHash } from 'node:crypto';
import { one, query } from '../../../lib/db';
import { hashPassword, bumpSessionEpoch } from '../../../lib/auth';
import { sendMail, mailLayout, mailReady, isEmail, esc } from '../../../lib/mail';
import { tooOften, realIp } 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 VALID_MINUTES = 60;
const hashToken = (t: string) => createHash('sha256').update(t).digest('hex');
const baseUrl = () => (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
/**
* Request a reset link (`{ email }`) or set a new password
* (`{ token, password }`).
*
* Important: when a link is requested the route **always answers the same way**,
* whether or not the address exists. Otherwise it would be possible to read off
* who has an account.
*/
export const POST: APIRoute = async ({ request, clientAddress }) => {
if (!has('mail')) return moduleOff('mail');
const ip = realIp(request, clientAddress);
const b = await request.json().catch(() => ({}));
// --- Set a new password ----------------------------------------------------
if (b.token) {
const token = String(b.token);
const pw = String(b.password || '');
if (pw.length < 10) return json({ error: 'The password must be at least 10 characters long.' }, 400);
if (tooOften('reset-redeem:' + ip, 20, 15 * 60_000)) return json({ error: 'Too many attempts. Please try again later.' }, 429);
const r = await one<{ user_id: string }>(
`SELECT user_id FROM password_resets
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()`, [hashToken(token)]);
if (!r) return json({ error: 'This link has expired or has already been used.' }, 400);
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [r.user_id, await hashPassword(pw)]);
// Single use, and every other open link for the same account expires with it.
await query('UPDATE password_resets SET used_at = now() WHERE user_id=$1 AND used_at IS NULL', [r.user_id]);
// The decisive part: **invalidate every existing session**. Someone who
// resets their password usually does so because someone else got in — without
// this step that person's cookie would stay valid for up to 30 days and the
// whole exercise would have no effect.
await bumpSessionEpoch(r.user_id);
return json({ ok: true });
}
// --- Request a reset link --------------------------------------------------
const email = String(b.email || '').trim().toLowerCase();
if (!isEmail(email)) return json({ error: 'Please enter a valid email address.' }, 400);
// Two limits: per sender **and** per target address. Without the second one a
// particular mailbox could be flooded from changing addresses — at the cost of
// your own domain's deliverability.
if (tooOften('reset-ip:' + ip, 5, 15 * 60_000)) return json({ ok: true });
if (tooOften('reset-mail:' + email, 3, 60 * 60_000)) return json({ ok: true });
if (!(await mailReady())) return json({ error: 'This server has no outgoing mail configured.' }, 503);
const u = await one<{ id: string; display_name: string | null; username: string }>(
'SELECT id, display_name, username FROM users WHERE lower(email) = $1', [email]);
if (u) {
const token = randomBytes(32).toString('base64url');
await query(
`INSERT INTO password_resets (token_hash, user_id, expires_at)
VALUES ($1, $2, now() + ($3 || ' minutes')::interval)`,
[hashToken(token), u.id, String(VALID_MINUTES)]);
const url = `${baseUrl()}/password?token=${token}`;
const name = u.display_name || u.username;
// Do not await: an SMTP send takes hundreds of milliseconds. If the response
// waited for it, the timing alone would reveal whether the account exists —
// and the identical answer below would be worthless.
void sendMail({
to: email,
subject: 'Reset your Klarbild password',
text: `Hello ${name},\n\nuse the link below to set a new password for Klarbild. `
+ `The link is valid for ${VALID_MINUTES} minutes and can only be used once.\n\n${url}\n\n`
+ `If you did not ask for this, you can ignore this message — nothing will change.`,
html: mailLayout('Set a new password', [
`Hello ${esc(name)},`,
`use the button below to set a new password for Klarbild. The link is valid for <b>${VALID_MINUTES} minutes</b> and can only be used <b>once</b>.`,
`If you did not ask for this, you can ignore this message — nothing will change.`,
], { text: 'Set a new password', url }),
}).catch((e) => console.error('[reset] mail failed:', e?.message || e));
}
// Always the same answer — otherwise it would reveal which addresses have an account.
return json({ ok: true });
};
+20
View File
@@ -0,0 +1,20 @@
import type { APIRoute } from 'astro';
import { deliverItem } from '../../lib/delivery';
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' } });
// Body: { itemIds: [...] } — send a selection to the delivery target after the fact.
export const POST: APIRoute = async ({ request, locals }) => {
if (!has('delivery')) return moduleOff('delivery');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
const ids: string[] = b.itemIds || [];
if (!ids.length) return json({ error: 'Nothing selected.' }, 400);
const results = [];
for (const id of ids) results.push({ id, ...(await deliverItem(id)) });
const ok = results.filter((r) => r.ok).length;
return json({ delivered: ok, total: ids.length, results });
};
+15
View File
@@ -0,0 +1,15 @@
import type { APIRoute } from 'astro';
import { query } from '../../lib/db';
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' } });
// Names of the extra targets for the studio dropdown (no credentials).
export const GET: APIRoute = async ({ locals }) => {
if (!has('delivery')) return moduleOff('delivery');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const rows = await query('SELECT id, name FROM delivery_targets ORDER BY name');
return json({ targets: rows });
};
+20
View File
@@ -0,0 +1,20 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const PATCH: APIRoute = async ({ params, request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
const row = await one(`UPDATE folders SET name=COALESCE($2,name), delivery_folder=$3 WHERE id=$1 RETURNING *`,
[params.id, b.name?.trim() || null, b.delivery_folder ?? null]);
return json({ folder: row });
};
export const DELETE: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
await query('DELETE FROM folders WHERE id=$1', [params.id]);
return json({ ok: true });
};
+46
View File
@@ -0,0 +1,46 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const rows = await query(`SELECT f.*, (SELECT count(*) FROM items WHERE folder_id=f.id)::int AS count
FROM folders f ORDER BY f.name`);
return json({ folders: rows });
};
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
if (!b.name?.trim()) return json({ error: 'Name is missing.' }, 400);
const row = await one(`INSERT INTO folders (name, delivery_folder) VALUES ($1,$2) RETURNING *`,
[b.name.trim(), b.delivery_folder || null]);
return json({ folder: row });
};
// Rename a folder / change the folder on the target. Body { id, name?, delivery_folder? }
export const PATCH: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
if (!b.id) return json({ error: 'No id.' }, 400);
const sets: string[] = []; const args: any[] = [];
if (typeof b.name === 'string' && b.name.trim()) { args.push(b.name.trim()); sets.push(`name=$${args.length}`); }
if ('delivery_folder' in b) { args.push(b.delivery_folder?.trim() || null); sets.push(`delivery_folder=$${args.length}`); }
if (!sets.length) return json({ error: 'Nothing to change.' }, 400);
args.push(b.id);
const row = await one(`UPDATE folders SET ${sets.join(',')} WHERE id=$${args.length} RETURNING *`, args);
return json({ folder: row });
};
// Delete a folder (the images stay, they only lose the assignment). ?id=…
export const DELETE: APIRoute = async ({ request, url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
let id = url.searchParams.get('id');
if (!id) { try { id = (await request.json())?.id; } catch { /* no matter */ } }
if (!id) return json({ error: 'No id.' }, 400);
await query('DELETE FROM folders WHERE id=$1', [id]);
return json({ ok: true });
};
+22
View File
@@ -0,0 +1,22 @@
import type { APIRoute } from 'astro';
import { pool } from '../../lib/db';
import { storageOk } from '../../lib/storage';
export const prerender = false;
export const GET: APIRoute = async () => {
const out: Record<string, string> = { service: 'klarbild' };
let ok = true;
try { await pool.query('SELECT 1'); out.db = 'ok'; }
catch { out.db = 'error'; ok = false; }
out.storage = (await storageOk()) ? 'ok' : 'error';
if (out.storage !== 'ok') ok = false;
out.status = ok ? 'ok' : 'degraded';
return new Response(JSON.stringify(out), {
status: ok ? 200 : 503,
headers: { 'Content-Type': 'application/json' },
});
};
+31
View File
@@ -0,0 +1,31 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
import { deleteObject } from '../../../lib/storage';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const PATCH: APIRoute = async ({ params, request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
const sets: string[] = []; const args: any[] = [];
if (typeof b.filename === 'string') { args.push(b.filename.trim()); sets.push(`filename=$${args.length}`); }
if ('folder_id' in b) { args.push(b.folder_id || null); sets.push(`folder_id=$${args.length}`); }
if ('color_tag' in b) {
const t = ['red', 'orange', 'green', 'final'].includes(b.color_tag) ? b.color_tag : null;
args.push(t); sets.push(`color_tag=$${args.length}`);
}
if (!sets.length) return json({ error: 'Nothing to change.' }, 400);
args.push(params.id);
const row = await one(`UPDATE items SET ${sets.join(',')} WHERE id=$${args.length} RETURNING id, filename, folder_id, color_tag`, args);
return json({ item: row });
};
export const DELETE: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const it = await one<{ result_path: string | null }>('SELECT result_path FROM items WHERE id=$1', [params.id]);
if (it?.result_path) await deleteObject(it.result_path).catch(() => {});
await query('DELETE FROM items WHERE id=$1', [params.id]);
return json({ ok: true });
};
+31
View File
@@ -0,0 +1,31 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../../lib/db';
import { enqueue } from '../../../../lib/queue';
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' } });
/** Creates another take (an alternative) from the same source, with the same preset recipe. */
export const POST: APIRoute = async ({ params, locals }) => {
if (!has('ai')) return moduleOff('ai');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const item = await one<any>(
`SELECT id, job_id, source_path, source_paths, filename, variant_of FROM items WHERE id=$1`, [params.id]);
if (!item) return json({ error: 'Not found.' }, 404);
// Without a source and without a model call there is nothing to vary.
const root = item.variant_of || item.id;
const it = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, source_path, source_paths, filename, source_quality, variant_of)
VALUES ($1, (SELECT COALESCE(max(position),0)+1 FROM items WHERE job_id=$1), 'queued', $2, $3, $4, 'original', $5)
RETURNING id`,
[item.job_id, item.source_path, item.source_paths ? JSON.stringify(item.source_paths) : null,
item.filename, root]);
// The job counts one more position and starts running again.
await query(`UPDATE jobs SET total=total+1, status='queued', finished_at=NULL
WHERE id=$1 AND status IN ('done','paused')`, [item.job_id]);
await enqueue({ itemId: it!.id, jobId: item.job_id });
return json({ ok: true, itemId: it!.id });
};
+56
View File
@@ -0,0 +1,56 @@
import type { APIRoute } from 'astro';
import sharp from 'sharp';
import { one } from '../../../../lib/db';
import { getObject } from '../../../../lib/storage';
export const prerender = false;
// Streams the result (or source) image out of object storage.
export const GET: APIRoute = async ({ params, url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const q = url.searchParams;
const wantThumb = q.get('thumb') === '1';
const wantSrc = q.get('src') === '1';
const item = await one<any>(
`SELECT i.source_path, i.source_paths, i.result_path, i.thumb_path, i.filename, i.has_alpha,
j.created_by, j.private FROM items i JOIN jobs j ON j.id=i.job_id WHERE i.id=$1`, [params.id]);
if (item) {
const isAdmin = locals.user.role === 'admin';
const own = item.created_by === locals.user.uid;
if (!isAdmin && !own) {
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
if (item.private || s?.library_visibility !== 'shared') return new Response('Forbidden', { status: 403 });
}
}
// Source: the single source, otherwise the first combine source (for before/after).
const firstSource = item?.source_path || (Array.isArray(item?.source_paths) ? item.source_paths[0] : null);
const path = wantSrc ? firstSource
: wantThumb ? (item?.thumb_path || item?.result_path)
: item?.result_path;
if (!path) return new Response('Not found', { status: 404 });
try {
let buf = await getObject(path);
const download = q.get('download') === '1';
// Derive the content type from the magic bytes — results can be PNG or JPG.
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);
// Fast full-screen preview: non-transparent results as JPEG (much smaller and
// faster on a phone) — enough for "save to photos". The PNG download is unchanged.
if (q.get('preview') === '1' && !download && !item?.has_alpha) {
try { buf = await sharp(buf, { failOn: 'none' }).jpeg({ quality: 90, mozjpeg: true }).toBuffer(); contentType = 'image/jpeg'; }
catch { /* fall back to the original */ }
}
return new Response(new Uint8Array(buf), {
headers: {
'Content-Type': contentType,
'Cache-Control': 'private, max-age=600',
...(download ? { 'Content-Disposition': `attachment; filename="${item.filename || 'klarbild.png'}"` } : {}),
},
});
} catch {
return new Response('File not available', { status: 404 });
}
};
+20
View File
@@ -0,0 +1,20 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../../lib/db';
import { enqueue } from '../../../../lib/queue';
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' } });
export const POST: APIRoute = async ({ params, locals }) => {
if (!has('ai')) return moduleOff('ai');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const item = await one<{ id: string; job_id: string }>(
'SELECT id, job_id FROM items WHERE id=$1', [params.id]);
if (!item) return json({ error: 'Not found.' }, 404);
await query(`UPDATE items SET status='queued', error_message=NULL WHERE id=$1`, [item.id]);
await query(`UPDATE jobs SET status='queued' WHERE id=$1 AND status IN ('done','paused')`, [item.job_id]);
await enqueue({ itemId: item.id, jobId: item.job_id });
return json({ ok: true });
};
+26
View File
@@ -0,0 +1,26 @@
import type { APIRoute } from 'astro';
import { randomUUID } from 'node:crypto';
import { one } from '../../../../lib/db';
import { getObject, putObject, sourceKey } from '../../../../lib/storage';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** Copies an item's result into a new source, so it can be worked on further in
* the studio as a starting point. */
export const POST: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const it = await one<{ result_path: string | null; filename: string | null }>(
'SELECT result_path, filename FROM items WHERE id=$1', [params.id]);
if (!it?.result_path) return json({ error: 'There is no result.' }, 404);
try {
const buf = await getObject(it.result_path);
const key = sourceKey(randomUUID(), 'png');
await putObject(key, buf, 'image/png');
const name = (it.filename || 'image').replace(/\.[^.]+$/, '') + '.png';
return json({ source_path: key, filename: name });
} catch (e: any) {
return json({ error: e?.message || 'Could not load the result.' }, 500);
}
};
+42
View File
@@ -0,0 +1,42 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
// The library with filters: ?task=&format=&folder=&delivery=&by=
export const GET: APIRoute = async ({ url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const q = url.searchParams;
const s = await one<any>('SELECT library_visibility, anonymous_generations FROM settings WHERE id=1');
const isAdmin = locals.user.role === 'admin';
const shared = s?.library_visibility === 'shared';
// Private jobs never show up in the library; visibility follows the setting.
const where: string[] = [`i.status='done'`, `j.private=false`];
const args: any[] = [];
const add = (cond: string, val: any) => { args.push(val); where.push(cond.replace('?', `$${args.length}`)); };
if (!isAdmin && !shared) add('j.created_by = ?', locals.user.uid);
if (q.get('folder')) add('i.folder_id = ?', q.get('folder'));
if (q.get('tag')) add('i.color_tag = ?', q.get('tag'));
if (q.get('delivery')) add('i.delivery_status = ?', q.get('delivery'));
if (q.get('by')) add('j.created_by = ?', q.get('by'));
if (q.get('format')) add('i.output_px = ?', q.get('format'));
if (q.get('task')) {
args.push(JSON.stringify([q.get('task')]));
where.push(`j.recipe_snapshot->'tasks' @> $${args.length}::jsonb`);
}
const rows = await query(
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.thumb_path, i.folder_id, i.color_tag,
i.delivery_status, i.mirror_status, i.prompt_used, i.created_at, i.model_used, i.variant_of, j.mode,
${(s?.anonymous_generations && !isAdmin) ? 'NULL' : 'u.display_name'} AS by_name,
j.recipe_snapshot->'tasks' AS tasks
FROM items i
JOIN jobs j ON j.id = i.job_id
LEFT JOIN users u ON u.id = j.created_by
WHERE ${where.join(' AND ')}
ORDER BY i.created_at DESC LIMIT 400`, args);
return json({ items: rows });
};
+52
View File
@@ -0,0 +1,52 @@
import type { APIRoute } from 'astro';
import { buildSelectionZipName } from '../../../lib/naming';
import archiver from 'archiver';
import { query } from '../../../lib/db';
import { getObject } from '../../../lib/storage';
export const prerender = false;
/** Downloads several results as a ZIP. Body { itemIds: [...] } */
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json().catch(() => ({} as any));
const ids: string[] = b.itemIds || [];
if (!ids.length) return new Response('Nothing selected.', { status: 400 });
const rows = await query<{ id: string; result_path: string | null; filename: string | null }>(
`SELECT id, result_path, filename FROM items WHERE id = ANY($1) AND result_path IS NOT NULL`, [ids]);
if (!rows.length) return new Response('No results.', { status: 404 });
const archive = archiver('zip', { zlib: { level: 6 } });
const seen: Record<string, number> = {};
for (const r of rows) {
try {
const buf = await getObject(r.result_path!);
let name = r.filename || `${r.id}.png`;
if (!/\.[a-z0-9]+$/i.test(name)) name += '.png';
// Avoid name collisions
if (seen[name] != null) { const n = ++seen[name]; name = name.replace(/(\.[^.]+)$/, `_${n}$1`); }
else seen[name] = 0;
archive.append(buf, { name });
} catch { /* skip the file */ }
}
const done = archive.finalize();
// Turn the archiver stream (Node) into a web ReadableStream.
const stream = new ReadableStream({
start(controller) {
archive.on('data', (c: Buffer) => controller.enqueue(new Uint8Array(c)));
archive.on('end', () => controller.close());
archive.on('error', (e: any) => controller.error(e));
done.catch(() => {});
},
});
// Unique rather than only dated: two selections on the same day had the same name.
const name = buildSelectionZipName(rows.length);
return new Response(stream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${name}"`,
},
});
};
+26
View File
@@ -0,0 +1,26 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
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' } });
export const GET: APIRoute = async ({ params, locals }) => {
if (!has('ai')) return moduleOff('ai');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const job = await one<any>('SELECT * FROM jobs WHERE id=$1', [params.id]);
if (!job) return json({ error: 'Not found.' }, 404);
// Access: your own job, an admin, or a shared (non-private) one.
const isAdmin = locals.user.role === 'admin';
const own = job.created_by === locals.user.uid;
if (!isAdmin && !own) {
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
if (job.private || s?.library_visibility !== 'shared') return new Response('Forbidden', { status: 403 });
}
const items = await query(
`SELECT id, position, status, filename, output_px, has_alpha, error_message,
result_path, delivery_status, cost FROM items WHERE job_id=$1 ORDER BY position`,
[params.id]);
return json({ job, items });
};
+56
View File
@@ -0,0 +1,56 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../../lib/db';
import { enqueue } from '../../../../lib/queue';
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' } });
export const POST: APIRoute = async ({ params, locals }) => {
if (!has('ai')) return moduleOff('ai');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const id = params.id!;
const job = await one<{ id: string }>('SELECT id FROM jobs WHERE id=$1', [id]);
if (!job) return json({ error: 'Not found.' }, 404);
switch (params.action) {
case 'pause':
await query(`UPDATE jobs SET status='paused' WHERE id=$1`, [id]);
break;
case 'resume': {
await query(`UPDATE jobs SET status='queued' WHERE id=$1`, [id]);
const items = await query<{ id: string }>(
`SELECT id FROM items WHERE job_id=$1 AND status IN ('queued','running')`, [id]);
for (const it of items) await enqueue({ itemId: it.id, jobId: id });
break;
}
case 'cancel':
await query(`UPDATE jobs SET status='cancelled', finished_at=now() WHERE id=$1`, [id]);
await query(`UPDATE items SET status='skipped' WHERE job_id=$1 AND status IN ('queued','running')`, [id]);
break;
case 'retry-failed': {
const items = await query<{ id: string }>(
`UPDATE items SET status='queued', error_message=NULL WHERE job_id=$1 AND status='failed' RETURNING id`, [id]);
await query(`UPDATE jobs SET status='queued', failed_count=0 WHERE id=$1`, [id]);
for (const it of items) await enqueue({ itemId: it.id, jobId: id });
break;
}
case 'delete': {
// Remove the job with its positions and their objects (for old or failed jobs).
const { deleteObject } = await import('../../../../lib/storage');
const items = await query<any>(
`SELECT source_path, source_paths, result_path, thumb_path FROM items WHERE job_id=$1`, [id]);
for (const it of items) {
const keys = [it.source_path, it.result_path, it.thumb_path, ...((it.source_paths as string[]) || [])].filter(Boolean);
for (const k of keys) await deleteObject(k).catch(() => {});
}
await query(`DELETE FROM items WHERE job_id=$1`, [id]);
await query(`DELETE FROM jobs WHERE id=$1`, [id]);
break;
}
default:
return json({ error: 'Unknown action.' }, 400);
}
return json({ ok: true });
};
+99
View File
@@ -0,0 +1,99 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
import { enqueue } from '../../../lib/queue';
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' } });
export const GET: APIRoute = async ({ locals }) => {
if (!has('ai')) return moduleOff('ai');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const s = await one<any>('SELECT library_visibility, anonymous_generations FROM settings WHERE id=1');
const isAdmin = locals.user.role === 'admin';
const shared = s?.library_visibility === 'shared';
const where: string[] = ['j.private=false']; const args: any[] = [];
if (!isAdmin && !shared) { args.push(locals.user.uid); where.push(`j.created_by=$${args.length}`); }
const nameCol = (s?.anonymous_generations && !isAdmin) ? 'NULL' : 'u.display_name';
const rows = await query(
`SELECT j.*, ${nameCol} AS by_name FROM jobs j
LEFT JOIN users u ON u.id=j.created_by
WHERE ${where.join(' AND ')} ORDER BY j.created_at DESC LIMIT 50`, args);
return json({ jobs: rows });
};
// Body: { recipeId?, recipe?, sources:[{source_path, filename, source_quality?}],
// mode?: 'each'|'compose'|'generate', prompt_text?, origin? }
export const POST: APIRoute = async ({ request, locals }) => {
if (!has('ai')) return moduleOff('ai');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
const mode: 'each' | 'compose' | 'generate' = ['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each';
const sources: any[] = b.sources || [];
const promptText: string = (b.prompt_text || '').trim();
if (mode === 'each' && !sources.length) return json({ error: 'No images.' }, 400);
if (mode === 'compose' && sources.length < 1) return json({ error: 'At least 1 image is required.' }, 400);
if (mode === 'compose' && !promptText) return json({ error: 'Please describe what should come out.' }, 400);
if (mode === 'generate' && !promptText) return json({ error: 'Please enter some text.' }, 400);
let snapshot: any = b.recipe;
if (b.recipeId) {
const r = await one('SELECT * FROM recipes WHERE id=$1', [b.recipeId]);
if (!r) return json({ error: 'Preset recipe not found.' }, 404);
snapshot = r;
}
snapshot = snapshot || {};
// Freeze the preset recipe as a snapshot
const snap = {
tasks: snapshot.tasks || (mode === 'each' ? [] : []),
output_format: snapshot.output_format,
orientation: snapshot.orientation,
crop_mode: snapshot.crop_mode || 'crop',
dpi: snapshot.dpi || 300,
contour_mm: snapshot.contour_mm ?? null,
model_key: snapshot.model_key ?? null,
custom_instruction: snapshot.custom_instruction ?? null,
prompt_text: promptText || null,
delivery: snapshot.delivery || 'library',
delivery_folder: snapshot.delivery_folder ?? null,
delivery_target_id: snapshot.delivery_target_id ?? null,
output_ext: (snapshot.output_ext === 'jpg' || snapshot.output_ext === 'png') ? snapshot.output_ext : null,
};
// Private session: only if allowed globally or forced for the user.
const acc = await one<any>(`SELECT s.private_allowed, u.private_forced
FROM settings s, users u WHERE s.id=1 AND u.id=$1`, [locals.user.uid]);
const isPrivate = !!(acc?.private_forced || (acc?.private_allowed && b.private));
if (isPrivate) snap.delivery = 'library'; // no external delivery for private jobs
const total = (mode === 'each') ? sources.length : 1;
const job = await one<{ id: string }>(
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, private)
VALUES ($1,$2,$3,$4,'queued',$5,$6) RETURNING id`,
[locals.user.uid, b.origin || 'web', mode, JSON.stringify(snap), total, isPrivate]);
if (mode === 'each') {
for (let i = 0; i < sources.length; i++) {
const s = sources[i];
const item = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, source_path, filename, source_quality)
VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`,
[job!.id, i, s.source_path, s.filename || null, s.source_quality || 'original']);
await enqueue({ itemId: item!.id, jobId: job!.id });
}
} else {
const srcPaths = sources.map((s) => s.source_path).filter(Boolean);
// File name stem, also written by src/lib/telegram.ts — kept identical there.
const base = mode === 'generate' ? 'new' : 'combined';
const item = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, source_paths, filename, source_quality)
VALUES ($1,0,'queued',$2,$3,'original') RETURNING id`,
[job!.id, srcPaths.length ? JSON.stringify(srcPaths) : null, base]);
await enqueue({ itemId: item!.id, jobId: job!.id });
}
return json({ jobId: job!.id });
};
+59
View File
@@ -0,0 +1,59 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../lib/db';
import { isEmail, mailReady } from '../../lib/mail';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const s = await one<{ private_allowed: boolean; share_default_days: number }>(
'SELECT private_allowed, share_default_days FROM settings WHERE id=1');
const u = await one<{ private_forced: boolean; email: string | null; notify_jobs: boolean; display_name: string | null; username: string }>(
'SELECT private_forced, email, notify_jobs, display_name, username FROM users WHERE id=$1', [locals.user.uid]);
return json({
role: locals.user.role,
private_allowed: !!s?.private_allowed,
private_forced: !!u?.private_forced,
// My account: email address and notifications
username: u?.username ?? null,
display_name: u?.display_name ?? null,
email: u?.email ?? null,
notify_jobs: !!u?.notify_jobs,
mail_ready: await mailReady(),
share_default_days: s?.share_default_days ?? 30,
});
};
/** Maintain your own account: store an email address and switch notifications. */
export const PATCH: APIRoute = async ({ request, locals }) => {
if (!locals.user?.uid) return new Response('Unauthorized', { status: 401 });
const b = await request.json().catch(() => ({}));
if ('email' in b) {
const raw = String(b.email ?? '').trim().toLowerCase();
if (raw && !isEmail(raw)) return json({ error: 'That does not look like a valid address.' }, 400);
// The address is the way to reset a password — so it must not be handed out
// twice.
if (raw) {
const taken = await one<{ id: string }>(
'SELECT id FROM users WHERE lower(email)=$1 AND id<>$2', [raw, locals.user.uid]);
if (taken) return json({ error: 'That address already belongs to another account.' }, 409);
}
try {
await query('UPDATE users SET email=$2 WHERE id=$1', [locals.user.uid, raw || null]);
} catch (e: any) {
// Two simultaneous requests can slip past the check above — the unique
// index catches it. Then give the same understandable answer as above
// instead of an HTML error page.
if (e?.code === '23505') return json({ error: 'That address already belongs to another account.' }, 409);
throw e;
}
}
if ('notify_jobs' in b) {
await query('UPDATE users SET notify_jobs=$2 WHERE id=$1', [locals.user.uid, !!b.notify_jobs]);
}
return json({ ok: true });
};
+17
View File
@@ -0,0 +1,17 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../lib/db';
import { has, moduleOff } from '../../lib/modules';
export const prerender = false;
// Active models with friendly names (for the studio picker and the result view).
// NSFW-capable models only if allow_nsfw is switched on in the settings.
export const GET: APIRoute = async ({ locals }) => {
if (!has('ai')) return moduleOff('ai');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const s = await one<{ allow_nsfw: boolean }>('SELECT allow_nsfw FROM settings WHERE id=1');
const models = await query(
`SELECT model_id, label, description, is_default, supports_alpha, nsfw FROM models
WHERE active AND (nsfw=false OR $1=true) ORDER BY sort, label`, [!!s?.allow_nsfw]);
return new Response(JSON.stringify({ models }), { headers: { 'Content-Type': 'application/json' } });
};
+51
View File
@@ -0,0 +1,51 @@
import type { APIRoute } from 'astro';
import { query, one } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** Sheet presets: paper, marks, image sizes and quantities — without the images. */
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const s = await one<any>('SELECT anonymous_generations FROM settings WHERE id=1');
const hideName = !!s?.anonymous_generations && locals.user.role !== 'admin';
const rows = await query(
`SELECT p.id, p.name, p.config, p.created_at, p.created_by,
${hideName ? 'NULL' : 'u.display_name'} AS by_name
FROM print_presets p LEFT JOIN users u ON u.id = p.created_by
ORDER BY p.name ASC LIMIT 200`);
return json({ presets: rows });
};
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const body = await request.json().catch(() => null) as any;
const name = String(body?.name || '').trim();
if (!name) return json({ error: 'Name is missing.' }, 400);
if (!body?.config || typeof body.config !== 'object') return json({ error: 'Configuration is missing.' }, 400);
// Presets are served to every user — hence a hard size limit.
const cfg = JSON.stringify(body.config);
if (cfg.length > 20000) return json({ error: 'Preset is too large.' }, 400);
const mine = await one<{ n: string }>(
`SELECT count(*)::text AS n FROM print_presets WHERE created_by IS NOT DISTINCT FROM $1`, [locals.user.uid]);
if (Number(mine?.n || 0) >= 100) return json({ error: 'Too many presets — please clean up first.' }, 400);
const row = await one(
`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3) RETURNING id, name, config`,
[name.slice(0, 80), locals.user.uid, cfg]);
return json({ preset: row });
};
export const DELETE: APIRoute = async ({ url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const id = url.searchParams.get('id');
if (!id) return json({ error: 'id is missing.' }, 400);
// Only your own presets (admins may do anything) — otherwise anyone could
// delete someone else's nursery set.
const isAdmin = locals.user.role === 'admin';
const del = await query(
`DELETE FROM print_presets WHERE id=$1 AND ($2::bool OR created_by IS NOT DISTINCT FROM $3) RETURNING id`,
[id, isAdmin, locals.user.uid]);
if (!del.length) return json({ error: 'Preset not found, or not yours.' }, 404);
return json({ ok: true });
};
+23
View File
@@ -0,0 +1,23 @@
import type { APIRoute } from 'astro';
import { listRuns, deleteRun } from '../../../lib/printruns';
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 prints — the settings and the sources, never the PDF itself. */
export const GET: APIRoute = async ({ locals, url }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const limit = Number(url.searchParams.get('limit')) || 30;
const runs = await listRuns(locals.user.uid, locals.user.role === 'admin', limit);
return json({ runs });
};
export const DELETE: APIRoute = async ({ locals, url }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const id = url.searchParams.get('id') || '';
if (!UUID.test(id)) return json({ error: 'Unknown entry.' }, 404);
const ok = await deleteRun(id, locals.user.uid, locals.user.role === 'admin');
return ok ? json({ ok: true }) : json({ error: 'Unknown entry.' }, 404);
};
+322
View File
@@ -0,0 +1,322 @@
import type { APIRoute } from 'astro';
import { randomUUID } from 'node:crypto';
import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode, type Page, type Line } from '../../../lib/printlayout';
import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset, type Oversize } from '../../../lib/printoversize';
import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf, type PageExtras, type FooterPos } from '../../../lib/printrender';
import { loadSource, type PrintSource } from '../../../lib/printsource';
import { buildSheetFilename } from '../../../lib/naming';
import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
import { cfgForKey } from '../../../lib/delivery';
import { uploadBuffer } from '../../../lib/remotetarget';
import { one } from '../../../lib/db';
import { recordRun, summarise } from '../../../lib/printruns';
export const prerender = false;
const MAX_CELLS = 40; // different images per sheet
const MAX_COPIES = 200; // copies per image
const MAX_PAGES = 60; // poster printing needs more pages than a passport photo sheet
const MAX_PIECES = 500; // sum of all copies — keeps the packer from degenerating
interface CellIn {
id: string;
src: PrintSource;
crop?: CropRel | null;
wMm?: number; hMm?: number;
size?: string; // free text ("12x15", "35x45mm") as an alternative to wMm/hMm
count?: number;
allowRotate?: boolean;
landscape?: boolean; // image size landscape instead of portrait
fit?: 'cover' | 'contain';// differing aspect ratio: crop or letterbox
bg?: string; // letterbox colour
oversize?: Oversize; // larger than the sheet: report, let it run over, or tile
overlapMm?: number; // glue flap when tiling
}
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
let body: any;
try { body = await request.json(); } catch { return json({ error: 'Invalid request.' }, 400); }
try {
const sheet = readSheet(body);
const marksMode: MarkMode = ['none', 'corner', 'grid'].includes(body?.marks?.mode) ? body.marks.mode : 'corner';
const dpi = clamp(Number(body?.dpi) || 300, 72, 1200);
const wantExt: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
const cellsIn: CellIn[] = Array.isArray(body?.cells) ? body.cells.slice(0, MAX_CELLS) : [];
if (!cellsIn.length) return json({ error: 'No images given.' }, 400);
const specs: PlaceSpec[] = [];
const resolved: { cell: CellIn; wMm: number; hMm: number }[] = [];
// Cells that do not fit on the sheet and take their own route instead.
const big: { cell: CellIn; wMm: number; hMm: number; count: number; mode: Oversize }[] = [];
for (const [i, c] of cellsIn.entries()) {
const size = cellSize(c);
if (!size) return json({ error: `Image ${i + 1}: size is missing or invalid.` }, 400);
const id = String(c.id || `c${i}`);
const count = clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES);
const allowRotate = c.allowRotate !== false;
const mode: Oversize = c.oversize === 'overflow' || c.oversize === 'tile' ? c.oversize : 'fit';
if (mode !== 'fit' && !fitsOnSheet(size.w, size.h, sheet, allowRotate)) {
big.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h, count, mode });
continue;
}
specs.push({ id, wMm: size.w, hMm: size.h, count, allowRotate });
resolved.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h });
}
if (!specs.length && !big.length) return json({ error: 'No images given.' }, 400);
const totalPieces = specs.reduce((n, s) => n + s.count, 0) + big.reduce((n, b) => n + b.count, 0);
if (totalPieces > MAX_PIECES) return json({ error: `Too many individual images (${totalPieces}). At most ${MAX_PIECES} per sheet job.` }, 422);
const plan = specs.length ? layout(specs, sheet) : { pages: [] as Page[], unplaced: [], perSheet: 0 };
if (!plan.pages.length && !big.length) {
return json({ error: 'Nothing could be placed — the image size is larger than the usable area of the sheet.', unplaced: plan.unplaced }, 422);
}
// Render each image exactly once — even for 24 copies.
// Rotated placements need their own take.
// The image key is deliberately NOT derived from the cell id — a cell named
// "x::rot" would otherwise overwrite the rotated take of "x".
const keyOf = (id: string, rot: boolean) => `${specs.findIndex((s) => s.id === id)}${rot ? 'r' : 'p'}`;
const images: Record<string, SheetCellImage> = {};
const needRotated = new Set<string>();
const needPlain = new Set<string>();
for (const pg of plan.pages) for (const p of pg.placements) (p.rotated ? needRotated : needPlain).add(p.specId);
for (const r of resolved) {
const buf = await loadSource(r.cell.src, locals.user as any);
if (needPlain.has(r.cell.id)) {
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: false, fit: fitOf(r.cell), background: bgOf(r.cell) });
images[keyOf(r.cell.id, false)] = { bytes: out.buffer, ext: out.ext };
}
if (needRotated.has(r.cell.id)) {
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: true, fit: fitOf(r.cell), background: bgOf(r.cell) });
images[keyOf(r.cell.id, true)] = { bytes: out.buffer, ext: out.ext };
}
}
// Point the placements at the matching take of the image.
const pages = plan.pages.map((pg) => ({
placements: pg.placements.map((p) => ({ ...p, specId: keyOf(p.specId, p.rotated) })),
}));
const marks = plan.pages.map((pg) =>
cutMarks(pg, sheet, {
mode: marksMode,
lengthMm: numOr(body?.marks?.lengthMm, 4, 1, 20),
offsetMm: body?.marks?.offsetMm != null ? numOr(body.marks.offsetMm, 3, 0, 20) : undefined,
bleedMm: sheet.bleedMm,
}));
const extras: PageExtras[] = plan.pages.map(() => ({}));
// --- Oversize images: let them run over, or tile them across several sheets ---
const notes: string[] = [];
for (const [bi, b] of big.entries()) {
const buf = await loadSource(b.cell.src, locals.user as any);
const opts = { ext: wantExt, fit: fitOf(b.cell), background: bgOf(b.cell) };
if (b.mode === 'overflow') {
const info = overflowPlacement(b.cell.id, 1, b.wMm, b.hMm, sheet, b.cell.allowRotate !== false);
const key = `o${bi}`;
const out = await renderCell(buf, b.cell.crop ?? null, b.wMm, b.hMm, dpi, { ...opts, rotate: info.rotated });
images[key] = { bytes: out.buffer, ext: out.ext };
for (let c = 0; c < b.count; c++) {
pages.push({ placements: [{ ...info.placement, specId: key, copy: c + 1 }] });
marks.push([]); // marks would be pointless here, the edges lie outside the sheet
extras.push({});
}
if (info.lostMm > 0.05)
notes.push(`${labelMm(b.wMm, b.hMm)} runs over the sheet: ${fmtMm(info.lostLeft + info.lostRight)} is missing in width and ${fmtMm(info.lostTop + info.lostBottom)} in height.`);
} else {
const noteMm = noteInset(sheet);
const tp = tilePlan(b.wMm, b.hMm, tileSheet(sheet), numOr(b.cell.overlapMm, 10, 0, 40));
if (tp.tiles.length * b.count > MAX_PAGES)
return json({ error: `The poster print would need ${tp.tiles.length * b.count} sheets — please choose a smaller size or larger paper.` }, 422);
const inset = sheet.marginMm || 0;
for (const [ti, t] of tp.tiles.entries()) {
const key = `t${bi}_${ti}`;
const crop = tileCrop(b.cell.crop ?? { x: 0, y: 0, w: 1, h: 1 }, t, b.wMm, b.hMm);
const out = await renderCell(buf, crop, t.wMm, t.hMm, dpi, { ...opts, rotate: false });
images[key] = { bytes: out.buffer, ext: out.ext };
// Make the glue flap visible: dashed where the neighbouring sheet joins.
const dashed: Line[] = [];
const top = inset + noteMm;
if (t.glueRight) { const x = inset + t.wMm - tp.overlapMm; dashed.push({ x1: x, y1: top, x2: x, y2: top + t.hMm }); }
if (t.glueBottom) { const y = top + t.hMm - tp.overlapMm; dashed.push({ x1: inset, y1: y, x2: inset + t.wMm, y2: y }); }
for (let c = 0; c < b.count; c++) {
pages.push({ placements: [{ specId: key, copy: c + 1, x: inset, y: inset + noteMm, w: t.wMm, h: t.hMm, rotated: false }] });
marks.push([]);
extras.push({
note: `Sheet ${ti + 1}/${tp.tiles.length} · row ${t.row + 1}, column ${t.col + 1}` +
(tp.overlapMm > 0 ? ` · ${fmtMm(tp.overlapMm)} glue flap (dashed)` : ''),
dashed,
});
}
}
notes.push(`${labelMm(b.wMm, b.hMm)} spread over ${tp.cols} × ${tp.rows} sheets, ${fmtMm(tp.overlapMm)} overlap for gluing.`);
}
}
if (pages.length > MAX_PAGES) return json({ error: `Too many sheets (${pages.length}). Please reduce the quantity.` }, 422);
const kinds = [
...resolved.map((r) => `${labelMm(r.wMm, r.hMm)}${specs.find((s) => s.id === r.cell.id)!.count > 1 ? ` ×${specs.find((s) => s.id === r.cell.id)!.count}` : ''}`),
...big.map((b) => `${labelMm(b.wMm, b.hMm)}${b.count > 1 ? ` ×${b.count}` : ''}${b.mode === 'tile' ? ' (poster)' : ' (oversize)'}`),
];
const footer = readFooter(body, sheet, kinds, dpi);
const bytes = await buildSheetPdf({
sheet, pages, marksPerPage: marks, images, extrasPerPage: extras,
title: String(body?.title || 'Klarbild print sheet'),
footer,
markWidthPt: numOr(body?.marks?.widthPt, 0.25, 0.1, 2),
});
// The id of the history entry is created **before** the file name so that both
// carry the same short id: whoever holds a PDF file can find the matching
// entry again under "Last printed".
const runId = randomUUID();
const filename = buildSheetFilename({
name: body?.name, paperId: typeof body?.paper === 'string' ? body.paper : body?.paper?.id,
wMm: sheet.wMm, hMm: sheet.hMm, pieces: totalPieces, pages: pages.length, runId,
});
// Optional: also push the finished sheet to the usual target (the default
// delivery target, the backup mirror or an extra target) — as with the images.
let delivered = '0', deliveryMsg = '';
if (body?.deliver) {
const key = body.deliver.target === '' || body.deliver.target == null ? 'remote' : String(body.deliver.target);
try {
// Check the folder name first: no slashes, no "..", otherwise posixpath.join
// could be used to break out of the target's base folder.
const wish = String(body.deliver.gallery || '').trim();
if (wish && (wish.includes('..') || !/^[\p{L}\p{N} _.\-]{1,64}$/u.test(wish)))
throw new Error('Invalid folder name.');
const cfg = await cfgForKey(key);
if (!cfg) throw new Error('Target is not configured.');
const s = await one<{ delivery_default_folder: string | null }>('SELECT delivery_default_folder FROM settings WHERE id=1');
const gallery = wish || s?.delivery_default_folder || 'Gallery A';
await uploadBuffer(cfg, gallery, filename, Buffer.from(bytes));
delivered = '1';
deliveryMsg = `Sheet delivered to "${gallery}".`;
} catch (e: any) {
deliveryMsg = `Delivery failed: ${e?.message || e}`;
}
}
// Record the print — only the instructions, not the PDF. A failure here must
// never cost the sheet: it is finished and about to go out.
try {
await recordRun({
id: runId,
userId: locals.user.uid ?? null,
origin: String(body?.origin || 'web'),
summary: summarise(kinds),
paper: `${labelMm(sheet.wMm, sheet.hMm)}`,
pages: pages.length,
pieces: totalPieces,
bytes: bytes.length,
config: body,
itemIds: [...cellsIn, ...big.map((b) => b.cell)]
.map((c: any) => (c?.src?.kind === 'item' ? String(c.src.id) : null))
.filter(Boolean) as string[],
});
} catch (e: any) {
console.error('[print] print history not saved:', e?.message || e);
}
return new Response(Buffer.from(bytes), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'X-Klarbild-Pages': String(pages.length),
'X-Klarbild-Per-Sheet': String(plan.perSheet),
'X-Klarbild-Delivered': delivered,
...(notes.length ? { 'X-Klarbild-Notes': encodeURIComponent(notes.join(' ')) } : {}),
...(deliveryMsg ? { 'X-Klarbild-Delivery-Msg': encodeURIComponent(deliveryMsg) } : {}),
},
});
} catch (e: any) {
// Internal details (paths, host names of delivery targets) stay in the log.
console.error('[print/sheet]', e?.message || e);
return json({ error: userMessage(e) }, 400);
}
};
/** Only pass on messages we wrote ourselves, nothing from deeper down. */
const SAFE = /^(Paper size|Image \d|Size|Invalid|Unknown|Image not|No access|Source|Crop|Target image|Too many)/;
function userMessage(e: any): string {
const m = String(e?.message || '');
return SAFE.test(m) ? m : 'The print sheet could not be created.';
}
function readSheet(body: any): SheetSpec {
let w: number | null = null, h: number | null = null;
const p = body?.paper;
if (typeof p === 'string') { const pp = paperById(p); if (pp) { w = pp.w; h = pp.h; } }
else if (p && typeof p === 'object') {
if (p.id) { const pp = paperById(String(p.id)); if (pp) { w = pp.w; h = pp.h; } }
if (p.wMm && p.hMm) { w = Number(p.wMm); h = Number(p.hMm); }
if (!w && p.size) { const s = parseSizeMm(String(p.size)); if (s) { w = s.w; h = s.h; } }
}
if (!w || !h) throw new Error('Paper size is missing or unknown.');
if (body?.landscape) [w, h] = [h, w];
if (w < 20 || h < 20 || w > 2000 || h > 2000) throw new Error('Paper size is outside the allowed range.');
const bleedMm = numOr(body?.bleedMm, 0, 0, 10);
const sheet: SheetSpec = {
wMm: w, hMm: h,
marginMm: numOr(body?.marginMm, 5, 0, 50),
gapMm: numOr(body?.gapMm, 4, 0, 100),
bleedMm,
center: body?.center !== false,
};
sheet.gapMm = effectiveGap(sheet);
return sheet;
}
function cellSize(c: CellIn): { w: number; h: number } | null {
let s: { w: number; h: number } | null = null;
if (Number(c.wMm) > 0 && Number(c.hMm) > 0) s = { w: Number(c.wMm), h: Number(c.hMm) };
else if (c.size) s = parseSizeMm(String(c.size));
if (!s || s.w < 5 || s.h < 5 || s.w > 2000 || s.h > 2000) return null;
return c.landscape ? { w: s.h, h: s.w } : s;
}
/** Millimetres in human-readable form: "3 mm", "1.5 mm", "0 mm". */
const fmtMm = (n: number) => `${Math.round(n * 10) / 10} mm`;
const FOOTER_POS: FooterPos[] = ['bottom-left', 'bottom-center', 'bottom-right', 'top-left', 'top-center', 'top-rechts'];
/** Caption: off, automatic, or your own text at a position you choose. */
function readFooter(body: any, sheet: SheetSpec, kinds: string[], dpi: number) {
const f = body?.footer;
if (f === false || f === null) return null;
const auto = `Klarbild · ${labelMm(sheet.wMm, sheet.hMm)} · ${kinds.join(' · ')} · ${dpi} dpi · print at 100% (not "fit to page")`;
if (f === true || f == null) return { text: auto, position: 'bottom-left' as FooterPos, sizePt: 6 };
const text = typeof f.text === 'string' && f.text.trim() ? f.text.trim().slice(0, 200) : auto;
const position = FOOTER_POS.includes(f.position) ? f.position as FooterPos : 'bottom-left';
return { text, position, sizePt: numOr(f.sizePt, 6, 4, 14) };
}
const fitOf = (c: CellIn): 'cover' | 'contain' => (c.fit === 'contain' ? 'contain' : 'cover');
/** Only allow a safe hex value as the letterbox colour. */
const bgOf = (c: CellIn): string => (/^#[0-9a-f]{6}$/i.test(String(c.bg || '')) ? String(c.bg) : '#ffffff');
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
const numOr = (v: any, def: number, lo: number, hi: number) => {
const n = Number(v);
return Number.isFinite(n) ? clamp(n, lo, hi) : def;
};
+72
View File
@@ -0,0 +1,72 @@
import type { APIRoute } from 'astro';
import { renderCell, type CropRel } from '../../../lib/printrender';
import { loadSourceNamed, type PrintSource } from '../../../lib/printsource';
import { buildSingleFilename } from '../../../lib/naming';
import { parseSizeMm, mmToPx, labelMm } from '../../../lib/paper';
import { dpiCheck } from '../../../lib/printlayout';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/**
* Bring a single image to an exact size — no AI, no sheet.
* Response: the finished image file (PNG/JPG) with dpi metadata.
*/
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
let body: any;
try { body = await request.json(); } catch { return json({ error: 'Invalid request.' }, 400); }
try {
const src: PrintSource = body?.src;
const crop: CropRel | null = body?.crop ?? null;
let size = (Number(body?.wMm) > 0 && Number(body?.hMm) > 0)
? { w: Number(body.wMm), h: Number(body.hMm) }
: parseSizeMm(String(body?.size || ''));
if (!size) return json({ error: 'Size is missing or invalid.' }, 400);
if (body?.landscape) size = { w: size.h, h: size.w };
if (size.w < 5 || size.h < 5 || size.w > 2000 || size.h > 2000) return json({ error: 'Size is outside the allowed range.' }, 400);
const dpi = Math.min(1200, Math.max(72, Number(body?.dpi) || 300));
const ext: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
const source = await loadSourceNamed(src, locals.user as any);
const buf = source.buffer;
const fit = body?.fit === 'contain' ? 'contain' : 'cover';
const background = /^#[0-9a-f]{6}$/i.test(String(body?.bg || '')) ? String(body.bg) : '#ffffff';
const out = await renderCell(buf, crop, size.w, size.h, dpi,
{ ext, bleedMm: Math.min(10, Math.max(0, Number(body?.bleedMm) || 0)), fit, background });
// A hint in case the source is too small for real 300 dpi.
const cropW = (crop?.w ?? 1) * out.srcPx[0];
const check = dpiCheck(cropW, size.w, dpi);
return new Response(new Uint8Array(out.buffer), {
headers: {
'Content-Type': out.ext === 'png' ? 'image/png' : 'image/jpeg',
'Content-Disposition': `attachment; filename="${buildSingleFilename({
name: body?.name, sourceName: source.filename,
wMm: size.w, hMm: size.h, ext: out.ext, id: source.itemId,
})}"`,
'X-Klarbild-Px': `${out.width}x${out.height}`,
'X-Klarbild-Real-Dpi': String(check.dpi),
'X-Klarbild-Dpi-Ok': check.ok ? '1' : '0',
},
});
} catch (e: any) {
console.error('[print/single]', e?.message || e);
const m = String(e?.message || '');
const safe = /^(Size|Invalid|Unknown|Image not|No access|Source|Crop|Target image)/.test(m);
return json({ error: safe ? m : 'The image could not be created.' }, 400);
}
};
/** A small helper for scripts and MCP: size → pixels at a given dpi. */
export const GET: APIRoute = async ({ url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const size = parseSizeMm(url.searchParams.get('size') || '');
const dpi = Math.min(1200, Math.max(72, Number(url.searchParams.get('dpi')) || 300));
if (!size) return json({ error: 'The size parameter is missing (e.g. 12x15 or 35x45mm).' }, 400);
return json({ mm: size, dpi, px: { w: mmToPx(size.w, dpi), h: mmToPx(size.h, dpi) }, label: labelMm(size.w, size.h) });
};
+30
View File
@@ -0,0 +1,30 @@
import type { APIRoute } from 'astro';
import { query, one } from '../../lib/db';
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' } });
export const GET: APIRoute = async ({ locals }) => {
if (!has('ai')) return moduleOff('ai');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const rows = await query('SELECT * FROM recipes ORDER BY is_default DESC, name');
return json({ recipes: rows });
};
export const POST: APIRoute = async ({ request, locals }) => {
if (!has('ai')) return moduleOff('ai');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
const row = await one(
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, contour_mm,
model_key, delivery, delivery_folder, delivery_target_id, custom_instruction, mode, output_ext, is_default, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,false,$15) RETURNING *`,
[b.name, JSON.stringify(b.tasks || []), b.output_format, b.orientation, b.crop_mode || 'crop',
b.dpi || 300, b.contour_mm ?? null, b.model_key ?? null, b.delivery || 'library',
b.delivery_folder ?? null, b.delivery_target_id ?? null, b.custom_instruction ?? null,
['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each',
(b.output_ext === 'jpg' || b.output_ext === 'png') ? b.output_ext : null, locals.user.uid]);
return json({ recipe: row });
};
+32
View File
@@ -0,0 +1,32 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
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 COLS = ['name', 'tasks', 'output_format', 'orientation', 'crop_mode', 'dpi', 'contour_mm',
'model_key', 'delivery', 'delivery_folder', 'delivery_target_id', 'custom_instruction', 'mode', 'output_ext'];
export const PATCH: APIRoute = async ({ params, request, locals }) => {
if (!has('ai')) return moduleOff('ai');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
const sets: string[] = []; const args: any[] = [];
const set = (c: string, v: any) => { args.push(v); sets.push(`${c}=$${args.length}`); };
for (const c of COLS) {
if (c in b) set(c, c === 'tasks' ? JSON.stringify(b[c] || []) : (b[c] === '' ? null : b[c]));
}
if (!sets.length) return json({ error: 'Nothing to change.' }, 400);
args.push(params.id);
const row = await one(`UPDATE recipes SET ${sets.join(',')} WHERE id=$${args.length} RETURNING *`, args);
return json({ recipe: row });
};
export const DELETE: APIRoute = async ({ params, locals }) => {
if (!has('ai')) return moduleOff('ai');
if (!locals.user) return new Response('Unauthorized', { status: 401 });
await query('DELETE FROM recipes WHERE id=$1', [params.id]);
return json({ ok: true });
};
+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',
},
});
};
+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 });
};
+22
View File
@@ -0,0 +1,22 @@
import type { APIRoute } from 'astro';
import { webhookCallback } from 'grammy';
import { getBot, getWebhookSecret } from '../../../lib/telegram';
import { has, moduleOff } from '../../../lib/modules';
export const prerender = false;
let handler: ((req: Request) => Promise<Response>) | null = null;
export const POST: APIRoute = async ({ request }) => {
if (!has('telegram')) return moduleOff('telegram');
const bot = await getBot();
if (!bot) return new Response('Bot inactive', { status: 503 });
if (!handler) {
handler = webhookCallback(bot, 'std/http', { secretToken: getWebhookSecret() || undefined });
}
try {
return await handler(request);
} catch (e) {
console.error('[telegram] webhook', e);
return new Response('ok'); // do not flood Telegram with 5xx
}
};
+54
View File
@@ -0,0 +1,54 @@
import type { APIRoute } from 'astro';
import { randomUUID } from 'node:crypto';
import sharp from 'sharp';
import { putObject, sourceKey } from '../../lib/storage';
import { heicToPng } from '../../lib/pipeline';
export const prerender = false;
const MAX_BYTES = 25 * 1024 * 1024;
const OK_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/heic', 'image/heif'];
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const form = await request.formData();
const files = form.getAll('files').filter((f): f is File => f instanceof File);
if (!files.length) return json({ error: 'No files.' }, 400);
const out: any[] = [];
for (const file of files) {
if (file.size > MAX_BYTES) { out.push({ filename: file.name, error: 'Larger than 25 MB.' }); continue; }
const isImg = OK_TYPES.includes(file.type) || /\.(png|jpe?g|webp|heic|heif)$/i.test(file.name);
if (!isImg) { out.push({ filename: file.name, error: 'Unsupported format.' }); continue; }
try {
let buf: Buffer = Buffer.from(await file.arrayBuffer());
let ext = 'png';
const heic = /heic|heif/i.test(file.type) || /\.(heic|heif)$/i.test(file.name);
if (heic) buf = await heicToPng(buf);
else {
const meta = await sharp(buf, { failOn: 'none' }).metadata();
ext = meta.format === 'jpeg' ? 'jpg' : (meta.format || 'png');
}
const meta = await sharp(buf, { failOn: 'none' }).metadata();
// Take the EXIF rotation into account: the browser shows the image oriented,
// so the reported dimensions have to be oriented too (cropping in the print module).
const turned = (meta.orientation ?? 1) >= 5;
const key = sourceKey(randomUUID(), ext);
await putObject(key, buf, `image/${ext === 'jpg' ? 'jpeg' : ext}`);
out.push({
source_path: key, filename: file.name,
width: turned ? meta.height : meta.width,
height: turned ? meta.width : meta.height,
source_quality: 'original',
});
} catch (e: any) {
out.push({ filename: file.name, error: 'Could not be read.' });
}
}
return json({ files: out });
};
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
+159
View File
@@ -0,0 +1,159 @@
---
import Base from '../layouts/Base.astro';
import { tFor, dictFor } from '../lib/i18n.server';
const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
---
<Base title={t('Changelog · Klarbild')}>
<div class="clog">
<div class="chead">
<h1>{t('Changelog')}</h1>
<a class="back" href="/help">{t('← Back to help')}</a>
</div>
<p class="lead">{t('What is new in Klarbild. Newest changes at the top.')}</p>
<section>
<div class="ver"><span class="tag">{t('1 Sep 2026')}</span><h2>{t('Print: images from the library, and what you printed last')}</h2></div>
<ul>
<li set:html={t('<b>Images from the library into the print area.</b> Until now you had to upload a file even when Klarbild had it already. The print area now offers <b>“Choose from the library”</b> — with search, by folder, and several at once.')} />
<li set:html={t('<b>Printed recently.</b> At the very bottom of the print area you can see what you printed before: sizes, quantity, the images. <b>Print again</b> produces exactly the same sheet a second time, without you setting anything up again. <b>Reuse settings</b> brings back only the paper, the marks and the margins — for “like last time, but with different photos”.')} />
<li set:html={t('<b>Without filling up the disk:</b> what is kept is not the PDFs — an A3 sheet is quickly 20 to 80 MB — but only the settings, a few kilobytes. Because the print area computes without AI, the same job yields exactly the same sheet again. That is why “Print again” only appears when all the images came from the library and are still there; otherwise the entry says what is in the way. The last 100 prints are kept.')} />
<li set:html={t('<b>On a phone</b> the four cards now come in their own order — the preview used to slide to the top, and you scrolled past everything before you could add an image.')} />
<li set:html={t('<b>Email runs by itself.</b> The account for the sending mailbox is configured in place; nothing has to be entered under Admin → Email any more. That page now also shows which account is actually in use.')} />
<li set:html={t('<b>Fixed: print files were all called the same.</b> Two sheets on the same day were both <code>2026-09-01_print-sheet.pdf</code> — in the same folder the second overwrote the first. Now it is <code>2026-09-01_143207_print-9-pieces_a4_4eca.pdf</code>: date, time to the second, what is on the sheet, the paper and a short id. The short id belongs to the history entry — so you can see which print was which file. Single images are now named after their subject.')} />
<li set:html={t('<b>Fixed:</b> the same went for ZIPs from the library — every one of them was called <code>klarbild_2026-09-01.zip</code>.')} />
</ul>
</section>
<section>
<div class="ver"><span class="tag">{t('1 Sep 2026')}</span><h2>{t('Sharing images, email, forgotten passwords')}</h2></div>
<ul>
<li set:html={t('<b>Share links.</b> In the library, tap <b>Share</b> — for one image or for several selected ones at once. The result is a link that someone <b>without a Klarbild account</b> can open too; from there the image can be downloaded at full resolution.')} />
<li set:html={t('<b>Pass it on via WhatsApp</b> (opens WhatsApp with the text ready), through your own mail program, or by email straight out of Klarbild to several recipients at once.')} />
<li set:html={t('<b>Safe by default:</b> every link <b>expires by itself</b> (30 days by default, changeable in Admin), can be <b>switched off</b> and released again at any time, can be protected with a <b>passphrase</b> if you want, and downloading can be blocked entirely. Search engines never get to see the pages. Images from private sessions cannot be shared at all.')} />
<li set:html={t('<b>All images at once:</b> with more than one image there is a button above the page, <b>“Download all N images”</b> — a ZIP at full resolution. With twenty images the recipient previously had to tap twenty times.')} />
<li set:html={t('<b>An overview under <a href="/shares">Shared links</a>:</b> views, downloads and the number of visitors per link. What is recorded is <b>when</b> a link was opened and with what kind of device — not <b>who</b> opened it. It is deleted after 90 days.')} />
<li set:html={t('<b>Sending email</b> through your own mailbox (Admin → Email). That makes these work: <b>forgotten passwords</b>, a <b>notice when a job is finished</b> (switch it on under <a href="/account">My account</a>) and sending share links.')} />
<li set:html={t('<b>Forgot your password?</b> At the bottom of the sign-in page. The link from the mail is valid for one hour and can only be used once. It needs an email address stored under <b>My account</b>.')} />
<li set:html={t('<b>Fixed:</b> a new password now also ends every sign-in that is still open. An existing session used to stay valid for up to 30 days — so resetting your password because somebody else had got in did nothing for you.')} />
<li set:html={t('<b>Fixed:</b> “Block downloads” had no effect on cut-out subjects.')} />
<li set:html={t('<b>AI images now carry a machine-readable marker</b> (IPTC/XMP) including the model used — right inside the image file.')} />
<li>{t('Small things: the colour flag no longer sits on top of the selection circle; the top bar fits on a tablet again; the admin area can be operated on a phone.')}</li>
</ul>
</section>
<section>
<div class="ver"><span class="tag">{t('21 Aug 2026')}</span><h2>{t('Unique file names — images no longer overwrite each other')}</h2></div>
<ul>
<li set:html={t('<b>Fixed: images overwrote each other on the delivery target.</b> Generating the same subject in the same format twice on one day — two badges, say — produced the same file name twice (<code>2026-07-23_portrait_the-frame.png</code>). On delivery the second image then silently replaced the first. That can no longer happen.')} />
<li set:html={t("<b>The new name:</b> <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code> — date, time to the second, subject, format and a short id. Because the timestamp comes first, <b>every file listing sorts chronologically by itself</b> — on the delivery target, in the file manager, on the backup mirror. The short id comes from the image's id and is therefore guaranteed to be unique.")} />
<li set:html={t("<b>The time is local time</b>, not UTC. An image made at 00:30 now carries the right date instead of the previous day's.")} />
<li set:html={t('<b>Retroactively for the whole library:</b> under <b>Admin → File names</b>, “Check library” first shows what would change (including the number of names currently used twice); “Rename now” carries it out. The image files themselves are left untouched. A second run changes nothing.')} />
<li set:html={t("<b>“Deliver again”</b> uploads every already delivered image once more under its new name. The old files stay behind on the target — you clear those away there yourself, so that nothing disappears from the client's view by accident.")} />
</ul>
</section>
<section>
<div class="ver"><span class="tag">{t('20 Aug 2026')}</span><h2>{t('Oversize images, duplicating an image, free caption & a new layout')}</h2></div>
<ul>
<li set:html={t('<b>20 × 30 on DIN A4 paper — now that works.</b> When the size you want is larger than the sheet, the tool no longer just says “does not fit”, it offers two ways out: <b>Oversize</b> places the image centred on the whole sheet — whatever hangs over is missing afterwards, and how much that is is stated beforehand (for 20 × 30 cm on A4 it is exactly 3 mm in height). <b>Poster print</b> spreads the image over several sheets; the tiles overlap by a glue flap that is marked with a dashed line, and every sheet carries its number with row and column. 30 × 40 cm on A4 gives 2 × 2 sheets.')} />
<li set:html={t('<b>Duplicate an image.</b> One button copies the same image so it can go onto the same sheet at a second size — a portrait once at 10 × 15 and once as a passport photo on one sheet of photo paper, say. No more renaming and uploading again.')} />
<li set:html={t('<b>Decide the caption yourself.</b> The small line under the sheet can be switched off, given your own text and placed in one of six spots (top or bottom, left, centre or right). If there is no room free at the chosen edge it is dropped automatically — nothing is ever printed over an image.')} />
<li set:html={t('<b>A new layout on the desktop:</b> work on the left, the preview on the right, following along as you scroll. The setting groups sit next to each other collapsed and open across the full width. No more endless column beside an empty one.')} />
<li set:html={t('<b>Like an app on a phone:</b> the preview is at the top, “Create print PDF” sits as a fixed bar above the navigation, every image row shows preview and sliders side by side, and every button is thumb-sized. Tablet widths were checked separately.')} />
<li set:html={t('<b>A public tour page</b> at <code>/tour</code> — it describes the print module without a login and without any controls, meant for showing the tool and for a possible open-source release.')} />
</ul>
</section>
<section>
<div class="ver"><span class="tag">{t('19 Aug 2026')}</span><h2>{t('“Passport photos” becomes “Print” — 86 image sizes, crop repaired')}</h2></div>
<ul>
<li set:html={t('<b>The area is now called “Print”</b> (it was “Passport photos”) — it does far more than passport photos, after all. Old links redirect automatically.')} />
<li set:html={t('<b>86 image sizes in 10 groups:</b> passport, small, photo, inches (US), instant, poster &amp; frame, square, DIN, cards and plain aspect ratios — plus 19 paper sizes and a custom size at any time.')} />
<li set:html={t('<b>Fixed: the crop showed the image distorted.</b> The frame in the crop window did not have the target aspect ratio, which squeezed the image into it (measured: 8.3% too wide). Frame and image now both compute from the same ratio.')} />
<li set:html={t('<b>Fixed: “I cannot move the image.”</b> Technically you could, but with the frame fully filled the crop sits against the stop in one direction — dragging did nothing there. It now says which way the image can still move; double-tap zooms, the arrow keys nudge, and the zoom slider shows its factor.')} />
</ul>
</section>
<section>
<div class="ver"><span class="tag">{t('18 Aug 2026')}</span><h2>{t('New: Print — bring images to exact sizes, entirely without AI')}</h2></div>
<ul>
<li set:html={t('<b>Exact sizes instead of “roughly”.</b> A passport photo is 35 × 45 mm, a frame is 13 × 18 cm. The tool computes in millimetres and produces a PDF whose page is exactly the sheet size and in which every image is exactly its target size. When printing, choose “Actual size / 100%” — not “fit to page”.')} />
<li set:html={t('<b>Several images on one sheet.</b> Images of the same size go into an exact grid; with mixed formats a packer arranges them so that as few sheets as possible are needed. One 13 × 18, one 9 × 13 and six passport photos fit together on a single DIN A4 sheet.')} />
<li set:html={t('<b>Crop marks as in prepress:</b> fine corner marks outside each image, or continuous lines across the whole sheet — broken where another subject lies. Length, offset and bleed can be set.')} />
<li set:html={t('<b>Crop or letterbox.</b> If the aspect ratio does not fit, you decide: crop until the format is filled, or fit the whole image and leave a border colour around it. Nothing is ever distorted. The crop can be moved and zoomed in the crop window; the rotation of phone photos (EXIF) is taken into account beforehand.')} />
<li set:html={t('<b>Any paper you like:</b> DIN A7 to A0, photo paper 9 × 13 to 20 × 30, US sizes — or a paper size of your own. Borderless, 5 mm or 10 mm margin.')} />
<li set:html={t('<b>Presets</b> for recurring sheets (school photo set, small prints, passport sheet, 2 × 10×7.5 on photo paper) — and your own can be saved.')} />
<li set:html={t('<b>Reachable through Telegram and the MCP server</b> as well, and the finished sheet can be delivered straight to the delivery target or to a backup target.')} />
<li set:html={t('<b>No AI, no cost:</b> plain geometry, in house. No model, no key, no images that leave the server.')} />
</ul>
</section>
<section>
<div class="ver"><span class="tag">{t('24 Jul 2026')}</span><h2>{t('JPG delivery, custom sizes, failed jobs & AI-readable docs')}</h2></div>
<ul>
<li set:html={t('<b>The delivery target gets exactly your format:</b> what is delivered is now exactly the file format you set — globally in the admin settings <i>or</i> per job in the studio (PNG or JPG). No more forced conversion. Cut-out subjects always stay PNG. (The images that had “disappeared” in the meantime were there the whole time, by the way — it was the sort order in the target’s web interface.)')} />
<li set:html={t('<b>Telegram & MCP keep up:</b> the Telegram bot and the MCP server (for assistants) now know the selectable file format and the custom sizes too — whichever way you start a job.')} />
<li set:html={t('<b>Delete a job</b> straight from the queue (for old or failed jobs). Missing sources now report a clear notice instead of a cryptic technical error.')} />
<li set:html={t('<b>AI-readable docs</b> at <code>/llms.txt</code> — describing purpose, formats, API and MCP tools for assistants.')} />
<li set:html={t('<b>The file format can be chosen:</b> globally in the admin settings (default file format PNG/JPG) <i>and</i> per conversion in the studio (default / PNG / JPG). JPG only without transparency.')} />
<li set:html={t('<b>Custom sizes:</b> in the studio, “Custom size (cm)” — just type <code>25x35</code>, say; for stickers one number is enough (<code>5</code> = 5×5 cm). Preset recipes store that too.')} />
<li set:html={t('<b>Failed jobs are now red</b> in the overview (they used to be green) and carry the reason. Fixed: the “Sticker 5 cm” preset recipe led nowhere (“Unknown format: sticker5”).')} />
<li set:html={t('<b>Finish time in the job:</b> the queue and the job show when it was done.')} />
<li set:html={t('<b>Clean file names:</b> no more doubled date and UUID chains in the name.')} />
<li set:html={t('<b>Metadata sidecar per target:</b> whether the <b>.md</b> file is sent along can be switched separately for the delivery target, the backup mirror and every extra target.')} />
<li set:html={t('<b>Faster loading:</b> the full view and the preview come as a small JPG (about 5× smaller), tiles load a thumbnail first; missing thumbnails can be generated afterwards with a button.')} />
<li set:html={t('<b>Delivery-target tools in Admin:</b> “Show folders” (what sits where over SFTP) and “Clean up leftovers” (remove .tmp files left behind).')} />
</ul>
</section>
<section>
<div class="ver"><span class="tag">{t('23 Jul 2026')}</span><h2>{t('Combine, Generate & a second backup')}</h2></div>
<ul>
<li set:html={t('<b>Combine:</b> several images plus a description into one new image (for example “… with our dog”).')} />
<li set:html={t('<b>Generate:</b> images from text alone, with no source image at all.')} />
<li set:html={t('<b>The Frame “Extend”:</b> generate more scene around the subject (outpainting) instead of only cropping.')} />
<li set:html={t('<b>Keep editing:</b> take a finished image as a new source and edit it again.')} />
<li set:html={t('<b>Alternatives:</b> generate further versions of the same image — grouped, with a switcher.')} />
<li set:html={t('<b>Folders:</b> filter, rename and delete in the library; each folder can have its own folder on the target.')} />
<li set:html={t('<b>Bulk actions:</b> download several images as a ZIP and move them into a folder in one go.')} />
<li set:html={t('<b>Thumbnails</b> for the library (fast & frugal).')} />
<li set:html={t('<b>Models:</b> quality levels including open source (FLUX.2); the model used is shown.')} />
<li set:html={t('<b>Colour flags</b> like the ones on the delivery target (red / orange / green / final): set them per image and filter by them afterwards — the flag is also in the accompanying .md file.')} />
<li set:html={t('<b>“Download” opens large:</b> a tap on “Download” shows the image filling the screen — on an iPhone or iPad, press and hold, then <b>“Save to Photos”</b> (the classic file download is there as well).')} />
<li set:html={t('<b>Permissions & privacy:</b> visibility can be set (everyone sees only their own images, or “everyone sees everything”), generations optionally anonymous, the admin sees everything.')} />
<li set:html={t('<b>Private session:</b> generate only, then forget — no library entry, no delivery or backup, no stored prompt. The admin can allow it globally or enforce it per user.')} />
<li set:html={t('<b>NSFW switch:</b> models marked NSFW are only selectable and visible when the admin setting is on.')} />
<li set:html={t('<b>Convert (one image + text):</b> convert a single image with a description — “photo → oil painting”, say — and bring it to size right away. No more having to upload two images.')} />
<li set:html={t('<b>Several output targets:</b> any number of further FTP/SFTP targets besides the standard delivery target; under “Where to?” in the studio the <b>backup mirror</b> and individual FTP targets can be chosen too; a preset recipe or folder can point at one target permanently.')} />
<li set:html={t('<b>Backup to every target:</b> not just the mirror — any target can be marked as “backup”; “Back up all images” mirrors to every backup target.')} />
<li set:html={t('<b>Metadata sidecar:</b> optionally an <b>.md file</b> (prompt, model, format …) alongside every image on the delivery target, the mirror or FTP.')} />
<li set:html={t('<b>Prompt display:</b> every generated image shows its prompt in the library (with a copy button).')} />
<li set:html={t('<b>Manage preset recipes yourself:</b> save the current settings as a recipe, load it, delete it.')} />
<li set:html={t('<b>API token & MCP server:</b> external access — hand images from a folder or from iMessage to Klarbild through an assistant.')} />
<li set:html={t('<b>The Frame</b> now delivers automatically into the folder <b>“TheFrame-Backgrounds”</b> on the target.')} />
<li set:html={t('<b>A second backup (the mirror):</b> every result additionally goes to the backup mirror over SFTP/FTPS (Tailscale works too) — with a “Mirror ✓” marker and “Back up all images to the mirror”.')} />
<li set:html={t('<b>Storage management:</b> thumbnails, deleting sources, an automatic retention period.')} />
<li set:html={t('<b>Telegram entirely button-driven:</b> a permanent keyboard & menu — Edit, Combine, “✨ New image”, Keep editing, with no commands at all.')} />
<li set:html={t('<b>Reset:</b> empty the library & jobs cleanly (with a confirmation).')} />
<li set:html={t('<b>A help page</b> and this changelog.')} />
<li>{t('Under the hood: a real cost cap, reliable job completion, a reachability check for the mirror.')}</li>
</ul>
</section>
</div>
<style>
.clog{max-width:760px;margin:0 auto;}
.chead{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
.clog h1{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:6px 0 4px;}
.back{color:var(--accent);text-decoration:none;font-size:.9rem;}
.lead{color:var(--soft);margin:4px 0 20px;}
.clog section{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;}
.ver{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
.ver h2{font-size:1.05rem;margin:0;}
.tag{font-family:var(--font-mono);font-size:10px;letter-spacing:.08em;background:var(--accent-bg);color:var(--accent);padding:3px 9px;border-radius:20px;}
.clog ul{margin:0;padding-left:20px;line-height:1.65;}
.clog li{margin-bottom:6px;}
</style>
</Base>
+147
View File
@@ -0,0 +1,147 @@
---
import Base from '../layouts/Base.astro';
import { tFor, dictFor } from '../lib/i18n.server';
const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
---
<Base title={t('Help · Klarbild')}>
<div class="howto">
<div class="hhead">
<h1>{t('How Klarbild works')}</h1>
<div class="hhead-actions">
<a class="print-btn" href="/changelog">{t('Changelog')}</a>
<button class="print-btn" onclick="window.print()">{t('Save as PDF / print')}</button>
</div>
</div>
<p class="lead">{t('Screenshots and things you found turn into print-ready images — cleaned up, cut out, in the right format, optionally delivered straight to the delivery target. Everything works through the website and through Telegram.')}</p>
<section>
<h2>{t('1 · Edit an image')}</h2>
<ol>
<li set:html={t('Choose <b>Edit</b> at the top of the <b>studio</b>.')} />
<li>{t('Upload screenshots — drag them in, paste with ⌘/Ctrl + V or tap (up to 100 at once).')}</li>
<li set:html={t('Choose what should happen: <b>Clean up</b> (frame, wall, shop interface gone), <b>Cut out</b> (subject free, background transparent), <b>Format</b> (30 × 40 cm or The Frame, for instance), <b>Contour</b> (white sticker edge).')} />
<li set:html={t('For the format, choose “<b>Crop</b>” or “<b>Extend</b>” — extending generates more scene around the subject (outpainting), which is ideal for The Frame.')} />
<li set:html={t('Choose the <b>quality</b> and where it goes (<b>library</b>, <b>delivery target</b> or both), then <b>Start</b>.')} />
</ol>
</section>
<section>
<h2>{t('2 · Combine several images')}</h2>
<p set:html={t('<b>Combine</b> mode: upload at least two images and describe what should come out of them — “the pool picture, but with our dog at the edge”, say. The first image is the lead scene, the others supply people or objects. The result is a new, composed image.')} />
</section>
<section>
<h2>{t('3 · A new image from text')}</h2>
<p set:html={t('<b>Generate</b> mode: no source image at all, just a description. The more precise the text, the better the result.')} />
</section>
<section>
<h2>{t('4 · Library, folders, alternatives & keep editing')}</h2>
<p set:html={t('Every result lands in the <b>library</b>. The folder bar at the top filters by folder; each folder can be given its own folder on the delivery target (an arrow on the folder means it has one).')} />
<ul>
<li set:html={t('<b>+ Alternative</b>: generates another version of the same image. The versions sit together as a group and can be switched with the thumbnail strip (badge “Version 2/3”).')} />
<li set:html={t('<b>Keep editing</b>: takes a finished image into the studio as a new source — edit it again there, bring it to the next format, or mix it with further images in Combine mode.')} />
<li set:html={t('<b>Before/after</b>: shows the comparison source ↔ result (not available for images made from text alone).')} />
<li set:html={t('<b>Manage folders</b>: tap the folder chip, then ✎ to rename it (including its folder on the target) or ✕ to delete it (the images are kept).')} />
<li set:html={t('<b>Several at once</b>: select images, then <b>download them as a ZIP</b> or move them <b>into a folder</b> together.')} />
</ul>
<p class="notice" set:html={t("<b>File names are unique.</b> Every result is called <code>YYYY-MM-DD_HHMMSS_subject_format_shortid</code>, for example <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code>. Because date and time come first, every file listing — the delivery target, the backup mirror, the file manager — sorts chronologically by itself. The short id at the end comes from the image's id: <b>two images can no longer overwrite each other on delivery</b>, even if you generate the same subject in the same format twice on one day. (Before 21 Aug 2026 that was different — the first image was lost in such a case. Under <b>Admin → File names</b> the existing library can be renamed after the fact.)")} />
</section>
<section>
<h2>{t('5 · Print — exact sizes and crop marks (without AI)')}</h2>
<p class="lead" set:html={t('The <b>Print</b> tab is the photo-lab part of Klarbild: no model, no cost, no waiting. Only cropping, scaling and geometry — from the biometric passport photo to the 30 × 40 poster.')} />
<ol>
<li set:html={t('Upload images (drag them in, ⌘/Ctrl + V) — or <b>Choose from the library</b>: everything Klarbild has already produced, with search, by folder and several at once. No more detour through another upload.')} />
<li set:html={t('Choose the <b>final size</b> for each image. <b>Every format from AI generation</b> is here — biometric passport photo 35 × 45 mm, 2 × 3 to 6 × 9 cm, 9 × 13 to 24 × 30, poster and frame sizes 30 × 40 to 70 × 100, squares, DIN A6–A2 and the aspect ratios (16:9 “The Frame”, 9:16, 3:2, 4:3, 5:4) — or a <b>custom size</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) or <code>4:3/15</code> (ratio 4:3, longer edge 15 cm).')} />
<li set:html={t('<b>If the image does not fit the format</b>, you decide for each image: <b>Crop</b> (the format is filled, something is missing at the edges — the default) or <b>Letterbox</b> (the whole image stays visible, with the <b>border colour</b> around it — white, black, paper-coloured or freely chosen). <b>Nothing is ever distorted.</b>')} />
<li set:html={t('Tap the preview image → <b>Crop</b>: pan and zoom. The aspect ratio stays locked to the target format, and nothing is ever distorted.')} />
<li set:html={t('Set the <b>quantity</b> per image — that is how the passport-photo or school photo set comes about.')} />
<li set:html={t('<b>The same image at two sizes?</b> Tap <b>Duplicate</b> — the image appears a second time in the list and gets its own size there. No more renaming and uploading again.')} />
<li set:html={t('Choose the <b>paper size</b>: DIN A6–A2 including <b>A3+ (329 × 483 mm)</b>, photo paper cuts 9 × 13 to 20 × 30, US Letter/Legal — or a paper size of your own.')} />
<li set:html={t('<b>Cutting guides</b>: <b>corner marks</b> (fine marks outside the final format, as in Photoshop and InDesign) or <b>continuous lines</b> across the whole sheet for the cutting ruler. The continuous lines never run through another subject.')} />
<li set:html={t('<b>If the final size is larger than the sheet</b> — 20 × 30 cm on DIN A4 photo paper, say — the question of how to go on appears right at the image: <b>Oversize</b> places the image centred on the whole sheet and what hangs over is cut off (for 20 × 30 on A4 exactly 3 mm in height are missing, and that is exactly what it says), or <b>Poster print</b> spreads it across several sheets to be glued together.')} />
<li set:html={t('<b>Create the print PDF</b>. The PDF page is exactly the sheet size.')} />
</ol>
<p set:html={t('<b>Printed recently.</b> At the very bottom you can see what you printed before — with sizes, quantity and the images. <b>Print again</b> produces exactly the same sheet a second time, without you setting anything up again; <b>Reuse settings</b> brings back only the paper, the marks and the margins, for “like last time, but with different photos”.')} />
<p class="mini" set:html={t('What is kept is <b>not the PDFs</b> — those are huge, an A3 sheet is quickly 20 to 80 MB — but only the settings, a few kilobytes. Because the print module computes without AI, the same job yields exactly the same sheet again. That is why “Print again” only appears when all the images came from the library and are still there; with uploaded files the source is gone once it has been cleaned up, and the entry says so.')} />
<p class="notice" set:html={t('<b>Important when printing:</b> in the print dialog choose “Actual size” or “100%” — <b>not</b> “fit to page”. Otherwise the size no longer holds. If you can print borderless, set the margin to 0; otherwise 3–5 mm, or the printer cuts the marks away.')} />
<ul>
<li set:html={t('<b>Bleed</b> (0–10 mm): the image extends past the cut edge so that a slightly crooked cut leaves no white border. The gap between two images grows with it automatically.')} />
<li set:html={t('<b>Poster print</b>: when the image is spread across several sheets, the tiles overlap by a <b>glue flap</b> (10 mm by default, freely adjustable). The flap is printed on both neighbouring sheets and marked with a dashed line — when assembling, you lay one sheet onto the other along that line. Every sheet carries its number with row and column at the top. 30 × 40 cm on A4 gives 2 × 2 sheets.')} />
<li set:html={t('<b>Caption</b>: by default a small line under the sheet carries the sizes, the dpi and the printing note. It can be <b>switched off</b>, given <b>your own text</b> (“Ada · August 2026”, say) and placed in one of six spots — top or bottom, each of them left, centre or right. If there is no room free at the chosen edge it is dropped automatically; it is never printed over an image.')} />
<li set:html={t('<b>Download singly</b>: one image at exactly its size as PNG/JPG with dpi metadata — without a sheet.')} />
<li set:html={t('<b>Resolution warning</b>: if the source is not enough for the chosen dpi, it says so in red under the image.')} />
<li set:html={t('<b>Presets</b>: save the whole sheet setup (paper, marks, formats, quantities). Bundled are <b>School photo set (A4)</b> (1 × 13 × 18, 2 × 9 × 13, 8 × passport photo — deliberately more than fits on one sheet; the preview tells you how many sheets it becomes), <b>Small prints (A4)</b>, <b>Passport sheet 35 × 45 (A4)</b> and <b>2 × 10×7.5 on 10×15 photo paper</b>. If you load a multi-format preset with only <i>one</i> image, that image is taken over for every format automatically — one click, a finished set.')} />
<li set:html={t('<b>Margin profiles</b>: <b>Borderless</b> (0 mm), <b>Standard</b> (5 mm) or <b>Safe</b> (10 mm) at the press of a button; the exact value stays freely editable.')} />
<li set:html={t('<b>Where to?</b>: the finished sheet can also be put on the delivery target, on the backup mirror or on an extra target — like the images from the studio.')} />
<li set:html={t('<b>Automatic arrangement</b>: images of the same size make a clean grid; with mixed sizes the small ones are placed automatically into the space left beside the large ones.')} />
</ul>
<p class="mini" set:html={t('Through Telegram as well: send an image → “🖨 Print” → tap size and quantity → the PDF comes back. On a phone the preview is at the top and “Create print PDF” sits within reach as a fixed bar above the navigation; on a computer you work on the left and see on the right what comes out of it. To show someone the tool without anything being uploadable, send them the link to <a href="/tour">the tour page</a>.')} />
</section>
<section>
<h2>{t('6 · Everything through Telegram — without commands')}</h2>
<p>{t('Pair it once: generate a code in Admin and enter it in the chat with the bot. After that everything runs through the buttons at the bottom of the chat — no need to type commands.')}</p>
<ul>
<li set:html={t('<b>Edit:</b> send the image or images (best as a <i>file</i> for full quality) → tap the preset recipe at the bottom.')} />
<li set:html={t('<b>Combine:</b> send two or more images, put a description such as “with our dog” in the image caption field — then tap “🔀 Combine”. (Without a description the bot asks for one.)')} />
<li set:html={t('<b>Generate:</b> tap “✨ New image” at the bottom and describe what should come out.')} />
<li set:html={t('<b>Keep editing:</b> simply send a finished image from the bot back to it.')} />
<li set:html={t('<b>Print sheet (without AI):</b> send an image → “📐 Print sheet” → tap the final size and the quantity. Back comes an A4 PDF with crop marks at 100% size.')} />
<li set:html={t('“❓ Help” gives the short guide, “📁 Choose recipe” sets the default preset recipe.')} />
</ul>
<p class="mini">{t('For power users there is also the “/” command menu (/new, /presets, /status) — it is not needed.')}</p>
</section>
<section>
<h2>{t('7 · Backup & a clean restart (Admin)')}</h2>
<p set:html={t('In the admin area a <b>second backup to the mirror</b> can be set up (SFTP/FTPS, Tailscale works too) — every finished image is then mirrored as well and marked <b>“Mirror ✓”</b> in the library. <b>“Back up all existing images to the mirror”</b> catches up on older images.')} />
<p set:html={t('Under <b>“File names”</b> the existing library can be brought onto the unique naming scheme. <b>“Check library”</b> first shows what would change and how many names are currently used twice; <b>“Rename now”</b> carries it out. The image files themselves are left untouched — they sit in storage under their id, not under their name. A second run changes nothing. <b>“Deliver again”</b> then uploads every already delivered image under its new name; the old files stay on the delivery target and you clear them away there yourself.')} />
<p set:html={t('For clearing out there is <b>“Reset library &amp; jobs”</b>: it deletes all images, jobs and folders (users, settings, models and preset recipes remain) — with a confirmation. Best to back up to the mirror once beforehand.')} />
</section>
<section>
<h2>{t('My account, sharing & email')}</h2>
<ul>
<li set:html={t('<b><a href="/account">My account</a></b>: store an email address — that lets you have a new password sent to you if you forget yours. You can also switch on a short mail as soon as a job is finished.')} />
<li set:html={t('<b>Share</b>: in the library, tap <b>Share</b> — for one image or for several selected ones at once. The result is a link that someone <b>without a Klarbild account</b> can open too; from there the image can be downloaded at full resolution.')} />
<li set:html={t('<b>Pass it on</b> via <b>WhatsApp</b> (opens WhatsApp with the text ready), through your own mail program, or by email straight out of Klarbild to several recipients.')} />
<li set:html={t('<b>All at once</b>: if a link holds several images, the top of the page offers <b>“Download all N images”</b> — a ZIP at full resolution. Very large collections are cut off at 500 MB; a note about that is then in the package.')} />
<li set:html={t('<b>Staying safe</b>: every link expires by itself (30 days by default), can be <b>switched off</b> at any time and protected with a <b>passphrase</b> if you want. Send the passphrase separately from the link — otherwise it is of no use.')} />
<li set:html={t('<b><a href="/shares">Shared links</a></b>: an overview of everything that is open right now — with views and downloads. What is recorded is <b>when</b> and <b>with what</b> a link was opened, not <b>who</b> opened it: the IP address is only kept hashed and cannot be computed back.')} />
<li set:html={t('<b>Forgot your password?</b> At the bottom of the sign-in page, on “Forgot your password?” — the link from the mail is valid for one hour and can only be used once.')} />
</ul>
<p class="mini" set:html={t('Images from a <b>private session</b> deliberately cannot be shared and do not trigger a notification either.')} />
</section>
<section>
<h2>{t('Tips')}</h2>
<ul>
<li>{t('For printing always choose 300 dpi and a cm format — the pixel dimensions are right there next to it.')}</li>
<li set:html={t('In Telegram send images as a <i>file</i>, not as a photo — otherwise Telegram compresses them.')} />
<li>{t('Stickers: choose Cut out + Contour together.')}</li>
</ul>
</section>
</div>
<style>
.howto{max-width:760px;margin:0 auto;}
.hhead{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
.howto h1{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:6px 0 4px;}
.hhead-actions{display:flex;gap:8px;align-items:center;}
.print-btn{background:var(--card);border:1px solid var(--line);border-radius:999px;padding:8px 15px;cursor:pointer;font-family:inherit;font-size:.85rem;color:var(--ink);text-decoration:none;}
.lead{color:var(--soft);line-height:1.6;margin:4px 0 20px;}
.howto section{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;}
.howto h2{font-size:1.05rem;margin:0 0 10px;}
.howto ol,.howto ul{margin:0;padding-left:20px;line-height:1.65;}
.howto li{margin-bottom:6px;}
.howto code{font-family:var(--font-mono);font-size:.85em;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
.howto .mini{font-size:.82rem;color:var(--soft);margin:8px 0 0;}
.howto .notice{background:var(--accent-bg);border-left:3px solid var(--accent);padding:10px 13px;margin:12px 0;font-size:.92rem;line-height:1.55;}
.howto code{font-family:var(--font-mono);font-size:.85em;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
@media print{.topbar,.tabbar,.print-btn{display:none !important;}.howto section{break-inside:avoid;}}
</style>
</Base>
+15
View File
@@ -0,0 +1,15 @@
---
import Base from '../layouts/Base.astro';
import StudioApp from '../components/StudioApp.tsx';
import { query } from '../lib/db';
import { has } from '../lib/modules';
// Without the AI module there is no studio, and the print studio is the product.
if (!has('ai')) return Astro.redirect('/print', 302);
const locale = Astro.locals.locale ?? 'en';
const recipes = await query('SELECT * FROM recipes ORDER BY is_default DESC, name').catch(() => []);
---
<Base title="Studio · Klarbild">
<StudioApp client:load recipes={recipes} locale={locale} dict={dict} />
</Base>
+13
View File
@@ -0,0 +1,13 @@
---
import Base from '../layouts/Base.astro';
import LibraryApp from '../components/LibraryApp.tsx';
import { tFor, dictFor } from '../lib/i18n.server';
const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
---
<Base title={t('Library · Klarbild')}>
<h1 class="page-title">{t('Library')}</h1>
<LibraryApp client:load locale={locale} dict={dict} />
<style>.page-title{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 16px;letter-spacing:-.02em;}</style>
</Base>
+213
View File
@@ -0,0 +1,213 @@
import type { APIRoute } from 'astro';
export const prerender = false;
/**
* Machine- and AI-readable short documentation at /llms.txt (public, no secrets).
* Describes the purpose, the capabilities and the HTTP/MCP interface of Klarbild,
* so that an AI agent can operate the tool without the web interface.
*/
const BODY = `# Klarbild
> Self-hosted image tool: turns screenshots and finds into clean, print-ready
> images (clean up, cut out, exact photo sizes, The-Frame landscape, stickers
> with a contour), delivers them to a remote target (SFTP/FTPS) and mirrors them
> to any backup target. On top of that a print module without AI (/print): bring
> images to exact physical sizes and place several of them at 100% with crop
> marks on a print sheet (PDF).
> Operated from the web, a Telegram bot and the HTTP API/MCP.
Base URL: https://klarbild.example.com
Public tour of the print module (no sign-in, description only): /tour
Public share links: /s/<slug> (no sign-in, only what the link releases)
Interface language: English; a German translation ships with it.
Stack: Astro 5 (SSR) · Postgres · sharp · pg-boss.
## Optional modules
Only the print studio is always on. Everything that needs an outside account is a
module switched on with \`KLARBILD_MODULES\` (comma separated, or \`all\`):
\`ai\`, \`delivery\`, \`mirror\`, \`share\`, \`mail\`, \`telegram\`.
Routes belonging to a module that is off answer **404** with
\`{ "error": "Module \\"<id>\\" is not enabled on this instance." }\` — to the outside
world such a module does not exist.
## Authentication (for agents)
- Header: \`Authorization: Bearer <API token>\` (Admin → "Automation / MCP access").
- The token is valid for \`/api/*\` — NOT for \`/api/admin/*\` (sign-in session only).
- Without a token or session, API routes answer 401.
## Core terms
- Mode (mode): \`each\` (clean up / cut out / format a screenshot),
\`compose\` (convert one image OR combine several, with a text description),
\`generate\` (a new image from text alone).
- Tasks (tasks, only with each): \`clean\`, \`cutout\`, \`format\`, \`contour\`.
- Preset recipe (recipe): a stored set of settings. Job (job): one batch; item (item): one image.
- Job status: queued, running, paused, done, failed, cancelled. \`failed\` = every item failed.
## Formats (output_format)
- Fixed keys: 9x13,10x15,13x18,15x20,20x30,30x40,30x45,40x50,40x60,50x70,60x90,
A4,A3,A2,20x20,30x30, theframe (3840x2160), portrait916 (2160x3840), keep (keep the original).
- Custom sizes without a preset: \`WIDTHxHEIGHT\` in cm (e.g. \`25x35\`) or \`sticker<N>\`
for N×N cm (e.g. \`sticker5\` = 5×5 cm). Limit 300 cm. Exact pixels: round(cm/2.54*dpi).
- File format (output_ext): \`png\` (lossless, transparency) or \`jpg\` (small, easy on the
delivery target). JPG only without transparency; cut-out motifs always stay PNG.
Global (admin) or per preset recipe.
## HTTP API (a selection, JSON)
- GET /api/health — status (public).
- GET /api/me — the current user and their rights.
- GET /api/models — available image models (quality levels).
- GET /api/recipes — list preset recipes.
- POST /api/recipes — create a preset recipe. Body among others: name, mode, tasks[], output_format,
orientation(portrait|landscape), crop_mode(crop|extend), dpi, contour_mm, output_ext(png|jpg|null),
delivery(library|remote|both), delivery_folder, delivery_target_id.
- DELETE /api/recipes/:id — delete a preset recipe.
- GET /api/delivery-targets — delivery and backup targets.
- POST /api/jobs — start a job. Body: { recipe, mode, delivery, private,
prompt_text?(compose/generate), sources:[{source_path, filename}] }. Response: { jobId }.
- GET /api/jobs — the most recent jobs. GET /api/jobs/:id — job plus items (incl. error_message, finished_at).
- POST /api/jobs/:id/{pause|resume|cancel|retry-failed|delete}.
- POST /api/items/:id/{retry|reuse|alternative}.
- GET /api/items/:id/file — the result image. Query: ?thumb=1 (preview), ?preview=1 (small JPG for full screen),
?download=1 (as a file), ?src=1 (the source). The content type is taken from the magic bytes (PNG/JPG/WEBP).
## Printing — exact sizes and print sheets (no AI, /print)
Purely local geometry: cropping (sharp) plus PDF (pdf-lib). No model, no cost.
- Size notation: \`12x15\` = 12×15 cm · \`35x45mm\` · \`5\` = 5×5 cm · \`4:3/15\` = ratio 4:3, longer edge 15 cm.
- Paper sizes (id): A6,A5,A4,A3,A3plus(329×483 mm),A2, F9x13,F10x15,F13x18,F15x20,F20x30, Letter, Legal — or a custom size.
- Image sizes: 86 presets in 10 groups (passport photo, small, photo, inch (US), instant photo,
poster and frame, square, DIN, cards, aspect ratio) — or a custom size. The ids:
P35x45 (biometric), P50x50 · K20x30,K30x40,K40x50,K45x60,K60x80,K60x90 ·
S9x13,S10x15,S11x15,S12x15,S13x18,S15x20,S18x24,S20x25,S20x30,S24x30 ·
R30x40,R30x45,R40x50,R40x60,R50x70,R60x80,R60x90,R70x100 (poster and frame) ·
Q10x10,Q13x13,Q20x20,Q30x30 · DA6,DA5,DA4,DA3,DA2 ·
W16x9,W9x16,W3x2,W4x3,W5x4 (aspect ratios as a print size, The Frame among them).
So every format the AI generation offers (src/lib/format.ts) can be printed here too.
- If the image does not match the size (fit, per cell): \`cover\` = crop until it is filled (the default,
nothing stays empty, something is lost at the edges) or \`contain\` = letterbox the whole image, the
letterbox colour \`bg\` (#rrggbb, default #ffffff) stays around it. Nothing is ever distorted.
- Crop marks (marks.mode): \`none\` · \`corner\` (corner marks outside the trimmed size, 4 mm long by
default, 3 mm offset, 0.25 pt hairline) · \`grid\` (continuous lines across the sheet, never running
through a motif).
- Bleed (bleedMm): the image extends beyond each edge; the trim box stays exactly the chosen crop.
The gap between two images is always ≥ 2 × bleed.
- Placement: images of equal size → an exact grid; mixed sizes → a MaxRects packer with a fixed
orientation per size (it tries every combination of orientations and takes the one using the fewest sheets).
- The PDF page has exactly the sheet size. When printing choose "Actual size / 100%" — not "fit to page".
- **Larger than the sheet** (e.g. 20 × 30 cm on A4 photo paper) — per cell via \`oversize\`:
\`fit\` = report that it does not fit (the default) · \`overflow\` = centre it on the whole sheet,
the oversize part is cut off when printing (20 × 30 on A4: exactly 3 mm in height) ·
\`tile\` = poster print across several sheets with a glue flap (\`overlapMm\`, default 10 mm, marked
dashed; every sheet carries its number, row and column). 30 × 40 cm on A4 makes 2 × 2 sheets.
- **Caption** (\`footer\`): \`false\` = off · \`true\` = automatic (sizes, dpi, printing hint) ·
\`{ text, position, sizePt }\` = your own text. Position: \`unten-links|unten-mitte|unten-rechts|
oben-links|oben-mitte|oben-rechts\` (bottom left/centre/right, top left/centre/right). The line is
dropped automatically when there is no free space at the chosen edge — it is never printed over an image.
- The same image may appear several times in the \`cells\` field (different \`id\`, same \`src\`), to place it
in several sizes on one sheet. In the web interface the "Duplicate" button does that.
- POST /api/print/single — one image at an exact size. Body: { src:{kind:'upload',path}|{kind:'item',id},
crop?:{x,y,w,h} (relative 0..1; without it a centred crop), size|wMm+hMm, landscape?, dpi?, ext?(jpg|png), bleedMm? }.
Response: the image file. Headers: X-Klarbild-Px, X-Klarbild-Real-Dpi, X-Klarbild-Dpi-Ok.
- GET /api/print/single?size=12x15&dpi=300 — calculation only: mm → pixels.
- POST /api/print/sheet — a print sheet as PDF. Body: { paper:{id}|{size}|{wMm,hMm}, landscape?, marginMm?,
gapMm?, bleedMm?, center?, dpi?, ext?, footer?, marks:{mode,lengthMm?,offsetMm?},
cells:[{ id, src, crop?, size|wMm+hMm, landscape?, count?, allowRotate?, fit?, bg?,
oversize?(fit|overflow|tile), overlapMm? }] }.
Response: application/pdf. Headers: X-Klarbild-Pages, X-Klarbild-Per-Sheet.
Limits: 40 cells, 200 copies per cell, 500 individual images and 60 sheets per job.
- GET/POST/DELETE /api/print/presets — sheet presets (paper, marks, sizes, quantities).
Example presets: "Nursery set (A4)", "Small school set (A4)", "Passport photo sheet 35×45 (A4)",
"2 × 10×7.5 on 10×15 photo paper". A preset with several sizes and only one image clones
that image into all of the sizes.
- Delivering a sheet: \`deliver: { target: ''|'mirror'|<uuid>, gallery? }\` in /api/print/sheet also puts
the PDF into the target. The response is still the PDF; headers X-Klarbild-Delivered (0|1) and
X-Klarbild-Delivery-Msg (URI encoded).
## MCP server (for assistants)
The \`klarbild\` tool (mcp/klarbild-mcp.mjs) uses the same API token:
- list_recipes — the available preset recipes.
- process_images — have images (a local folder or URLs) processed with a preset recipe.
- job_status — ask for the progress or result of a job.
- exact_size — NO AI: bring an image to an exact size and save the file locally.
- print_sheet — NO AI: save a print sheet (several images × quantity, crop marks) as a local PDF.
## Share links (public, no account)
Make an image or a collection reachable at \`/s/<slug>\` — the recipient needs no account.
- The defaults are deliberately cautious: **expiry** (default 30 days, \`settings.share_default_days\`),
**revocable** at any time and releasable again, an optional **passphrase** (argon2),
**downloading can be switched off**, and pages and files carry \`X-Robots-Tag: noindex\`.
- Access hangs on the **link**, not on the image: someone else's \`item\` id on a valid link
returns 404. Images from private sessions cannot be shared at all.
- States: 200 (valid) · 401 (passphrase required or wrong) · 404 (unknown) · 410 (expired or
revoked). The passphrase goes by **POST**, after which a short-lived cookie per link acknowledges it.
- \`GET /api/s/<slug>/file?item=<uuid>\` — the viewing take (scaled down to 2000 px).
\`&download=1\` delivers the original; with downloading blocked, 403. \`&thumb=1\` for the overview.
- \`GET /api/s/<slug>/zip\` — **all** images of the link as a ZIP (at full resolution).
Deliberately GET, so that it is a plain link without JavaScript. It respects the download block
(403) and the passphrase (401). Limits: 200 files, 500 MB unpacked (after that the archive closes
with a \`NOTE.txt\`), 5 bundles per link and address in 10 minutes. Counted as **one** download.
One file is read and passed on after another, not everything into memory at once.
- \`GET|POST /api/shares\` — list or create your own links. Body: \`{ itemIds[], title?, note?,
days?, allowDownload?, password? }\`. \`days\` has to be a number from 0–3650 (0 = no expiry);
anything else is **rejected**, not read as "no expiry".
- \`GET|PATCH|DELETE /api/shares/<id>\` — view accesses, change (\`action: 'revoke'|'unrevoke'\`,
\`title\`, \`note\`, \`days\`, \`allowDownload\`, \`password\`), delete.
- \`POST /api/shares/send\` — send a link by mail: \`{ shareId, to, message? }\`, at most 10
recipients per request, 50 mails per hour and sender. **No attachment** — a link can be
withdrawn, an attachment cannot.
- WhatsApp goes through a \`wa.me\` link in the browser (no server-side sending; the WhatsApp
Business API would need a Meta account, approved templates and fees).
- What is counted is **when** and with what kind of device something was opened, not **who**:
from the IP only a hash, salted with a server secret **and** the link id (so it cannot be
correlated across links), at most one row per visitor and hour, deleted after 90 days.
## Email
Sending goes through your own mailbox (SMTP, credentials encrypted in \`settings\`; Admin → Email).
Needed for: resetting a password, a notice when a job is finished (switchable per user under
\`/account\`), and sending share links. Without mail configured, the routes concerned answer 503.
- \`POST /api/auth/reset { email }\` — request a reset link. Answers **always the same way**, whether
or not the address exists. \`POST /api/auth/reset { token, password }\` — set a new password; the
token is valid for 60 minutes, exactly once, and invalidates every open session of the account.
- \`GET|PATCH /api/me\` — your own account: \`email\`, \`notify_jobs\`.
## AI labelling
Results carry IPTC \`DigitalSourceType\` as XMP inside the file:
\`trainedAlgorithmicMedia\` (created from text) or \`compositeWithTrainedAlgorithmicMedia\`
(an existing photo changed), plus \`xmp:CreatorTool\` with the model used.
Readable with exiftool. The print module creates nothing with AI and therefore labels nothing.
## File names of the results
Scheme: \`YYYY-MM-DD_HHMMSS_<motif>_<format>_<short id>.<ext>\` — e.g. \`2026-08-20_143207_team-badge_the-frame_a3f9.jpg\`.
- The timestamp comes first, so any file listing (the delivery target, the backup mirror, a file
manager) sorts chronologically by itself.
- The four-character short id comes from the item id. That way two images are **never** named the
same — not even with the same motif, the same format and the same day. (Before 2026-08-21 the time
and the short id were missing; similar images from the same day overwrote each other on delivery.)
- Time zone: \`KLARBILD_TZ\`, default \`Europe/Berlin\`.
- The motif is peeled out of the original name (dates, UUIDs and our own stamp are dropped);
generic names such as \`IMG\`, \`photo\`, \`telegram\` become \`klarbild\`.
- Renaming the whole library: Admin → "File names", or
\`POST /api/admin/storage { action: 'rename_preview' | 'rename_apply' | 'redeliver_all' }\` (sign-in
session only, not with an API token). Idempotent — the stamp comes from \`created_at\`, the short id
from the item id.
## Delivery
- A delivery target is an SFTP/FTPS server; the folder on the target is a subfolder under the base path.
The full path is base folder + folder (only assembled when delivering). The default folder is
"Gallery A"; "The Frame" → "TheFrame-Backgrounds".
- What is delivered is the result in the chosen file format (output_ext, global or per job) — PNG or
JPG. Cut-out motifs stay PNG.
- An optional .md metadata sidecar file can be switched on per target (delivery target, backup mirror,
extra target).
## Privacy
- Private session (job.private=true): no library entry, no delivery and no backup, no stored prompt;
the result is deleted after a short while.
- Visibility is switchable globally (only your own / everyone sees everything); an admin sees everything.
Further documentation (for humans): in-app /help and /changelog.
`;
export const GET: APIRoute = async () =>
new Response(BODY, { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' } });
+53
View File
@@ -0,0 +1,53 @@
---
import Base from '../layouts/Base.astro';
import { tFor, dictFor } from '../lib/i18n.server';
import { has } from '../lib/modules';
if (Astro.locals.user) return Astro.redirect('/');
const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
// The claim should describe the instance, not the maintainer's install.
const claim = has('ai')
? t('Screenshot in, clean image out.')
: t('Pictures on paper, at exactly the size you asked for.');
---
<Base title={t('Sign in · Klarbild')}>
<section class="login">
<h1 class="display">Klarbild</h1>
<p class="claim">{claim}</p>
<form id="f" class="card" data-failed={t('Sign-in failed.')}>
<label>{t('Username')}<input name="username" autocomplete="username" required autofocus /></label>
<label>{t('Password')}<input name="password" type="password" autocomplete="current-password" required /></label>
<button type="submit">{t('Sign in')}</button>
<p id="err" class="err" hidden></p>
<a class="forgot" href="/password">{t('Forgot your password?')}</a>
</form>
</section>
<style>
.login { max-width: 360px; margin: 8vh auto 0; text-align: center; }
.display { font-family: var(--font-display); font-size: 2.4rem; margin: 0 0 4px; letter-spacing: -0.02em; }
.claim { color: var(--ink-mute); margin: 0 0 28px; }
.card { display: grid; gap: 14px; text-align: left; background: var(--surface);
border: 1px solid var(--line); border-radius: var(--radius); padding: 22px; box-shadow: var(--shadow); }
label { display: grid; gap: 6px; font-size: 0.9rem; color: var(--ink-soft); }
input { padding: 11px 12px; border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--paper); color: var(--ink); font-size: 1rem; }
button { margin-top: 4px; padding: 11px; border: none; border-radius: var(--radius-sm);
background: var(--accent); color: white; font-size: 1rem; font-weight: 600; cursor: pointer; }
.err { color: var(--err); font-size: 0.9rem; margin: 4px 0 0; }
.forgot { font-size: 0.85rem; color: var(--ink-soft); text-align: center; text-decoration: none; padding: 4px; }
.forgot:hover { color: var(--accent); }
</style>
<script>
const f = document.getElementById('f'); const err = document.getElementById('err');
f.addEventListener('submit', async (e) => {
e.preventDefault(); err.hidden = true;
const data = Object.fromEntries(new FormData(f));
const res = await fetch('/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
});
if (res.ok) { location.href = '/'; }
else { const j = await res.json().catch(() => ({})); err.textContent = j.error || f.dataset.failed; err.hidden = false; }
});
</script>
</Base>
+104
View File
@@ -0,0 +1,104 @@
---
// “Forgot your password” — reachable without signing in.
// Two states in one page: without a token ask for the address, with a token set
// the new password.
import Base from '../layouts/Base.astro';
import { tFor, dictFor } from '../lib/i18n.server';
const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
const token = Astro.url.searchParams.get('token') || '';
---
<Base title={t('Password · Klarbild')}>
<main class="pw">
<h1 class="display">Klarbild</h1>
{token ? (
<form id="setnew" class="card" data-token={token}
data-mismatch={t('The two entries do not match.')}
data-saved={t('Password saved. Taking you to the sign-in page …')}
data-failed={t('That did not work.')}>
<h2>{t('Set a new password')}</h2>
<label>{t('New password')}
<input name="password" type="password" autocomplete="new-password" required minlength="10"
placeholder={t('at least 10 characters')} autofocus />
</label>
<label>{t('Once more')}
<input name="password2" type="password" autocomplete="new-password" required minlength="10" />
</label>
<button type="submit">{t('Save password')}</button>
<p class="hint">{t('A long sentence is safer, and easier to remember, than a short piece of gibberish.')}</p>
<p id="msg" class="msg" hidden></p>
</form>
) : (
<form id="request" class="card"
data-sent={t('If there is an account for this address, the message is on its way. Have a look in the spam folder too.')}>
<h2>{t('Forgot your password')}</h2>
<p class="lead">{t('Enter your email address. If there is an account for it, a link will arrive shortly that lets you set a new password.')}</p>
<label>{t('Email address')}
<input name="email" type="email" autocomplete="email" required autofocus placeholder="you@example.com" />
</label>
<button type="submit">{t('Send the link')}</button>
<p id="msg" class="msg" hidden></p>
</form>
)}
<p class="back"><a href="/login">{t('← To the sign-in page')}</a></p>
</main>
<style>
.pw { max-width: 400px; margin: 8vh auto 0; padding: 0 18px 60px; text-align: center; }
.display { font-family: var(--font-display); font-size: 2.2rem; margin: 0 0 26px; letter-spacing: -0.02em; }
.card { display: grid; gap: 14px; text-align: left; background: var(--surface);
border: 1px solid var(--line); border-radius: var(--radius); padding: 22px; box-shadow: var(--shadow); }
.card h2 { font-size: 1.1rem; margin: 0; }
.lead { margin: 0; font-size: 0.9rem; color: var(--ink-soft); line-height: 1.6; }
label { display: grid; gap: 6px; font-size: 0.9rem; color: var(--ink-soft); }
input { padding: 11px 12px; border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--paper); color: var(--ink); font-size: 16px; font-family: inherit; }
button { margin-top: 4px; padding: 12px; border: none; border-radius: var(--radius-sm);
background: var(--accent); color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; font-family: inherit; }
button:disabled { background: var(--paper); color: var(--soft); border: 1px solid var(--line); cursor: not-allowed; }
.hint { margin: 0; font-size: 0.8rem; color: var(--soft); line-height: 1.5; }
.msg { margin: 4px 0 0; font-size: 0.9rem; line-height: 1.5; }
.msg.ok { color: var(--ok); }
.msg.err { color: var(--err); }
.back { margin-top: 22px; font-size: 0.9rem; }
</style>
<script>
const show = (el: HTMLElement, text: string, ok: boolean) => {
el.textContent = text; el.className = 'msg ' + (ok ? 'ok' : 'err'); (el as HTMLElement).hidden = false;
};
const post = (body: unknown) =>
fetch('/api/auth/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) }).then((r) => r.json().catch(() => ({})));
const request = document.getElementById('request') as HTMLFormElement | null;
request?.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = document.getElementById('msg')!;
const btn = request.querySelector('button')!;
btn.disabled = true;
const email = (new FormData(request).get('email') || '').toString();
const r = await post({ email });
btn.disabled = false;
if (r.error) show(msg, r.error, false);
else show(msg, request.dataset.sent!, true);
});
const setnew = document.getElementById('setnew') as HTMLFormElement | null;
setnew?.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = document.getElementById('msg')!;
const f = new FormData(setnew);
const pw = (f.get('password') || '').toString();
if (pw !== (f.get('password2') || '').toString()) { show(msg, setnew.dataset.mismatch!, false); return; }
const btn = setnew.querySelector('button')!;
btn.disabled = true;
const r = await post({ token: setnew.dataset.token, password: pw });
if (r.ok) { show(msg, setnew.dataset.saved!, true); setTimeout(() => (location.href = '/login'), 1400); }
else { btn.disabled = false; show(msg, r.error || setnew.dataset.failed!, false); }
});
</script>
</Base>
+20
View File
@@ -0,0 +1,20 @@
---
import Base from '../layouts/Base.astro';
import PrintApp from '../components/PrintApp.tsx';
import { tFor, dictFor } from '../lib/i18n.server';
const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
---
<Base title={t('Print · Klarbild')}>
<h1 class="page-title">{t('Print')}</h1>
<p class="page-lead">
{t('No AI: bring images to exact sizes and place several of them at 100% size with crop marks on one sheet — from the biometric passport photo through the school photo set to the 30 × 40 poster.')}
</p>
<PrintApp client:load locale={locale} dict={dict} />
<style>
.page-title{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
.page-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}
@media(max-width:560px){.page-title{font-size:1.35rem;}.page-lead{font-size:12.5px;}}
</style>
</Base>
+14
View File
@@ -0,0 +1,14 @@
---
import Base from '../layouts/Base.astro';
import QueueApp from '../components/QueueApp.tsx';
import { tFor, dictFor } from '../lib/i18n.server';
const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
const jobId = Astro.url.searchParams.get('job');
---
<Base title={t('Queue · Klarbild')}>
<h1 class="page-title">{t('Queue')}</h1>
<QueueApp client:load jobId={jobId} locale={locale} dict={dict} />
<style>.page-title{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 16px;letter-spacing:-.02em;}</style>
</Base>
+204
View File
@@ -0,0 +1,204 @@
---
// Public view of a share link — reachable without signing in.
//
// Deliberately its own slim page instead of the app: whoever gets the link
// should be able to see the images and download them, and nothing else. No
// navigation, no way into the library, no hint of what else exists here.
import Base from '../../layouts/Base.astro';
import { resolveShare, shareItems, trackShare, proofBuild, proofValid, proofFromCookie, proofName } from '../../lib/shares';
import { tooOften, realIp } from '../../lib/limit';
import { tFor, dictFor } from '../../lib/i18n.server';
const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
// Day before month, like the rest of the tool's metric framing; German gets the
// dotted form it expects.
const dateLocale = locale === 'de' ? 'de-DE' : 'en-GB';
const slug = Astro.params.slug!;
// The passphrase arrives by POST (not as `?pw=` in the address — that would end
// up in the history, in proxy logs and in every forwarded link). Once it has
// been checked, a short-lived cookie limited to this one link remembers the
// result: `argon2.verify` costs 130 ms and 64 MiB and otherwise ran again on
// **every** image request.
let entry: string | null = null;
let wrong = false;
let blocked = false;
if (Astro.request.method === 'POST') {
// Limit guessing: a passphrase you read out over the phone is short — without
// a brake, guessing would have decent odds, and every check costs 130 ms and
// 64 MiB.
if (tooOften(`pass:${slug}:${realIp(Astro.request, Astro.clientAddress)}`, 10, 15 * 60_000)) {
blocked = true;
} else {
const f = await Astro.request.formData().catch(() => null);
entry = String(f?.get('pw') || '') || null;
}
}
const cookieHeader = Astro.request.headers.get('cookie');
let { status, share } = await resolveShare(slug, entry);
// Already proven? Then let it through without hashing again.
if (status === 'passphrase' && share && proofValid(share.id, proofFromCookie(cookieHeader, slug))) {
status = 'ok';
}
if (status === 'ok' && share?.password_hash && entry) {
Astro.response.headers.append('Set-Cookie',
`${proofName(slug)}=${proofBuild(share.id)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=21600`);
}
if (status === 'passphrase' && entry) wrong = true;
let images: Awaited<ReturnType<typeof shareItems>> = [];
if (status === 'ok' && share) {
images = await shareItems(share.id);
await trackShare(share.id, 'view', Astro.clientAddress, Astro.request.headers.get('user-agent'));
}
// This page never belongs in a search index.
Astro.response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, noimageindex');
if (status !== 'ok') Astro.response.status = status === 'not-found' ? 404 : status === 'passphrase' ? 401 : 410;
const title = share?.title || (images.length > 1 ? t('Images') : t('Image'));
const dl = (id: string) => `/api/s/${slug}/file?item=${id}&download=1`;
const full = (id: string) => `/api/s/${slug}/file?item=${id}`;
const thumb = (id: string) => `/api/s/${slug}/file?item=${id}&thumb=1`;
const messages: Record<string, { head: string; body: string }> = {
'not-found': { head: t('There is no such link'), body: t('Maybe a character went missing while it was copied. Best to ask for it again.') },
'expired': { head: t('This link has expired'), body: t('Share links are only valid for a limited time. Whoever sent it to you can create a new one.') },
'revoked': { head: t('This link was switched off'), body: t('It was switched off on purpose. Ask again if you need the images.') },
};
---
<Base title={t('{title} · Klarbild', { title })}>
<main class="share">
{status === 'ok' && share ? (
<>
<header class="head">
<p class="brandline"><span class="regmark"></span> {t('shared via Klarbild')}</p>
<h1>{title}</h1>
{share.note && <p class="note">{share.note}</p>}
<p class="mono-label">
{images.length === 1 ? t('1 image') : t('{n} images', { n: images.length })}
{share.expires_at && <> · {t('valid until {date}', { date: new Date(share.expires_at).toLocaleDateString(dateLocale) })}</>}
</p>
</header>
{share.allow_download && images.length > 1 && (
<div class="all">
<a class="btn big" href={`/api/s/${slug}/zip`}>
{t('Download all {n} images', { n: images.length })}
</a>
<span class="all-hint">{t('as a ZIP file, at full resolution')}</span>
</div>
)}
{share.allow_download ? (
<p class="notice">
<Fragment set:html={t('<b>Download</b> saves the file at full resolution. On a phone it also works like this: press and hold an image, then save it.')} />
{images.length > 1 && <> {t('Or all of them at once, at the top.')}</>}
</p>
) : (
<p class="notice">
{t('These images are here to be looked at. If you need a file, ask the person who sent you the link.')}
</p>
)}
{images.length === 0 ? (
<p class="empty">{t('There is nothing to see here right now. Maybe the images have been removed in the meantime — best to ask the person who sent you the link.')}</p>
) : (
<div class={images.length === 1 ? 'grid single' : 'grid'}>
{images.map((b) => (
<figure class="photo">
<a href={full(b.id)} target="_blank" rel="noopener">
{/* The thumbnail in the overview — otherwise the server renders
a full image again for every tile. The large one only comes
when the tile is tapped. */}
<img src={images.length > 1 ? thumb(b.id) : full(b.id)}
alt={b.filename || t('Image')} loading="lazy" />
</a>
<figcaption>
<span class="size">{b.output_px?.replace('x', ' × ') || ''}</span>
{share.allow_download
? <a class="btn" href={dl(b.id)}>{t('Download')}</a>
: <span class="hint">{t('View only')}</span>}
</figcaption>
</figure>
))}
</div>
)}
<p class="privacy" set:html={t('Whoever sent you this link can see <b>when</b> it was opened and with what kind of device. Not <b>who</b> opened it: only an unrecognisable number is stored for your internet address, and that is deleted after 90 days.')} />
</>
) : status === 'passphrase' ? (
<section class="gate">
<h1>{t('This link is protected by a passphrase')}</h1>
<p>{t('Enter the passphrase you were given along with the link.')}</p>
<form method="post" class="gate-form">
<input type="password" name="pw" placeholder={t('Passphrase')} aria-label={t('Passphrase')} autofocus required />
<button type="submit">{t('View images')}</button>
</form>
{blocked
? <p class="error">{t('Too many attempts. Please try again in a quarter of an hour.')}</p>
: wrong && <p class="error">{t('That was not it, unfortunately.')}</p>}
</section>
) : (
<section class="gate">
<h1>{messages[status]?.head ?? t('Not available')}</h1>
<p>{messages[status]?.body ?? ''}</p>
</section>
)}
</main>
<style>
.share { max-width: 1000px; margin: 0 auto; padding: 40px 20px 72px; }
.head { margin-bottom: 26px; }
.brandline { display: flex; align-items: center; gap: 8px; margin: 0 0 10px;
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--soft); }
.head h1 { font-size: clamp(1.5rem, 4vw, 2rem); margin: 0 0 8px; letter-spacing: -0.02em; }
.note { margin: 0 0 10px; color: var(--ink-soft); max-width: 60ch; line-height: 1.6; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 18px; }
.grid.single { grid-template-columns: minmax(0, 1fr); max-width: 720px; }
.photo { margin: 0; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
/* Only the **direct** image link, not every link in the figure: `.photo a`
also caught the download button in the caption and painted it over with
paper colour — light text on a light ground, practically invisible. */
.photo > a { display: block; line-height: 0; background: var(--paper); }
.photo img { width: 100%; height: auto; display: block; }
figcaption { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; }
.size { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; color: var(--soft); }
.btn { background: var(--accent); color: var(--card); text-decoration: none; font-size: 13px; font-weight: 600;
padding: 8px 14px; border-radius: 3px; white-space: nowrap; }
/* The download-everything button sits above the images: with twenty tiles
nobody finds it at the bottom, and tapping each one is even less
appealing. */
.all { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 0 0 18px; }
.btn.big { font-size: 15px; padding: 13px 22px; border-radius: var(--radius-sm); }
.all-hint { font-size: 12.5px; color: var(--soft); }
.hint { font-size: 12px; color: var(--soft); }
.empty { color: var(--soft); }
/* Sits above the images: below twenty tiles nobody would read it. */
.notice { margin: 0 0 20px; padding: 11px 13px; border: 1px solid var(--line);
border-radius: var(--radius-sm); background: var(--card);
font-size: 13px; color: var(--ink-soft); line-height: 1.6; max-width: 62ch; }
.privacy { margin: 30px 0 0; padding-top: 16px; border-top: 1px solid var(--line);
font-size: 12px; color: var(--soft); line-height: 1.6; max-width: 62ch; }
.gate { max-width: 460px; margin: 8vh auto 0; text-align: center; }
.gate h1 { font-size: 1.4rem; margin: 0 0 12px; }
.gate p { color: var(--ink-soft); line-height: 1.6; margin: 0 0 20px; }
.gate-form { display: flex; gap: 8px; }
.gate-form input { flex: 1; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--card); font-size: 16px; font-family: inherit; color: var(--ink); }
.gate-form button { border: none; border-radius: var(--radius-sm); background: var(--accent); color: #fff;
padding: 12px 20px; font-weight: 600; font-family: inherit; font-size: 15px; cursor: pointer; }
.error { color: var(--err); margin-top: 14px; font-size: 14px; }
@media (max-width: 640px) {
.share { padding: 24px 14px 56px; }
.grid { grid-template-columns: 1fr; gap: 14px; }
.btn { padding: 11px 16px; }
}
</style>
</Base>
+24
View File
@@ -0,0 +1,24 @@
---
import Base from '../layouts/Base.astro';
import SharesApp from '../components/SharesApp';
import { tFor, dictFor } from '../lib/i18n.server';
const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
---
<Base title={t('Shared links · Klarbild')}>
<section class="page">
<header class="pagehead">
<h1>{t('Shared links')}</h1>
<p class="lead">{t('Links that let other people look at your images — without an account. Here you can see how often they were opened, and switch them off whenever you want.')}</p>
</header>
<SharesApp client:load locale={locale} dict={dict} />
</section>
<style>
.page { max-width: var(--maxw); margin: 0 auto; padding: 26px 20px 80px; }
.pagehead { margin-bottom: 20px; }
.pagehead h1 { font-size: clamp(1.4rem, 4vw, 1.9rem); margin: 0 0 6px; letter-spacing: -0.02em; }
.lead { margin: 0; color: var(--ink-soft); line-height: 1.6; max-width: 64ch; font-size: 0.95rem; }
@media (max-width: 640px) { .page { padding: 18px 14px 90px; } }
</style>
</Base>
+293
View File
@@ -0,0 +1,293 @@
---
// Public tour page for the print module.
// Deliberately without a login and without controls: it shows what the tool can
// do, but you cannot use it here. Meant as a landing page for an open-source
// release (Gitea/GitHub) and for showing the tool to someone.
import Base from '../layouts/Base.astro';
import { PHOTO_SIZES, PAPERS } from '../lib/paper';
import { layout, cutMarks, type SheetSpec } from '../lib/printlayout';
import { tFor, dictFor } from '../lib/i18n.server';
const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
const sizeCount = PHOTO_SIZES.length;
const paperCount = PAPERS.length;
const groupCount = new Set(PHOTO_SIZES.map((p) => p.group)).size;
const features = [
{
kicker: t('True to size'),
title: t('Millimetres instead of “roughly”'),
text: t('Every final size is computed in millimetres and only turned into pixels when it is rendered. The PDF has exactly the sheet size, and every image on it exactly its target size. Checking that is not guesswork but measurement: we rasterise the finished PDFs at 254 dpi and count the pixel edges.'),
proof: t('13 × 18 cm → measured 130.1 × 180.1 mm'),
},
{
kicker: t('Space'),
title: t('Several images on one sheet'),
text: t('Images of the same size go into an exact grid. With mixed formats a packer (MaxRects, “best short side fit”) looks for the arrangement that needs the fewest sheets — and tries every combination of portrait and landscape to find it. A set of passport photos and a 13 × 18 fit on the same sheet that way.'),
proof: t('School photo set: 9 pieces on one A4 sheet instead of 3'),
},
{
kicker: t('Cutting'),
title: t('Crop marks as in prepress'),
text: t('Fine corner marks outside every image, or continuous grid lines across the whole sheet. The lines break where another image lies — nothing is ever drawn across a picture. Length and offset can be set, and so can the bleed.'),
proof: t('Corner marks · continuous grid · bleed 0–10 mm'),
},
{
kicker: t('Too large'),
title: t('20 × 30 on A4 — even so'),
text: t('The everyday case: it is meant to be a 20 × 30, but the only photo paper in the house is A4. Instead of “cannot be done” there are two ways out. Oversize places the image centred on the whole sheet; whatever hangs over is missing afterwards — and how much that is stands there beforehand.'),
proof: t('20 × 30 cm on A4: exactly 3 mm are missing in height'),
},
{
kicker: t('Poster'),
title: t('One image, several sheets'),
text: t('The second way out for large sizes: the image is spread across several sheets. The tiles overlap by a glue flap, which is printed on both neighbouring sheets and marked with a dashed line. Every sheet carries its number with row and column.'),
proof: t('30 × 40 cm on A4 → 2 × 2 sheets, 10 mm glue flap'),
},
{
kicker: t('Cropping'),
title: t('The crop stays yours'),
text: t('If the aspect ratio does not fit, you decide: crop or letterbox. In the crop window you move and zoom the original inside the target frame — with the mouse, a finger, a double-tap or the arrow keys. The crop is stored relatively and survives any resolution.'),
proof: t('EXIF rotation is applied beforehand — phone photos come out upright'),
},
];
const stats = [
{ value: String(sizeCount), label: t('Image sizes'), hint: t('in {n} groups — passport photo up to 100 × 140 cm', { n: groupCount }) },
{ value: String(paperCount), label: t('Paper sizes'), hint: t('DIN A7–A0, photo paper, US sizes, custom size') },
{ value: '0', label: t('AI involved'), hint: t('plain geometry, no model, nothing uploaded anywhere') },
{ value: '1:1', label: t('Output'), hint: t('PDF at original size, 72–1200 dpi') },
];
// The sample sheet below is not drawn, it is computed — with exactly the same
// packer the tool uses. If the algorithm changes, the picture on this page
// changes with it.
const sampleSheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 8, bleedMm: 0, center: true };
const samplePlan = layout([
{ id: 'large', wMm: 130, hMm: 180, count: 1, allowRotate: true },
{ id: 'small', wMm: 90, hMm: 130, count: 1, allowRotate: true },
{ id: 'passport', wMm: 35, hMm: 45, count: 6, allowRotate: true },
], sampleSheet);
const samplePage = samplePlan.pages[0];
const sampleMarks = samplePage ? cutMarks(samplePage, sampleSheet, { mode: 'corner', lengthMm: 3, offsetMm: 1 }) : [];
const sampleColor: Record<string, string> = {
large: 'oklch(45% 0.23 267 / 0.09)', small: 'oklch(45% 0.23 267 / 0.20)', passport: 'oklch(45% 0.23 267 / 0.34)',
};
const samplePieces = samplePage ? samplePage.placements.length : 0;
/** Compact size for the little box: under 10 cm in mm, otherwise in cm. */
const shortSize = (w: number, h: number) => w < 100 && h < 100
? `${Math.round(w)}×${Math.round(h)}`
: `${Math.round(w / 10)}×${Math.round(h / 10)} cm`;
const steps = [
{ n: '01', title: t('Choose images'), text: t('Upload them, take them out of the library, or duplicate an image you already have to print it at two sizes.') },
{ n: '02', title: t('Set the sizes'), text: t('One target size per image, the quantity, portrait or landscape, crop or letterbox.') },
{ n: '03', title: t('Set up the sheet'), text: t('Paper, crop marks, margins, resolution. The preview shows straight away how many sheets it will be.') },
{ n: '04', title: t('Print'), text: t('Create the PDF and choose “Actual size / 100%” in the print dialog. Done.') },
];
---
<Base title={t('Klarbild Print — bring images to exact sizes')}>
<main class="tour">
<header class="hero">
<p class="mono-label">{t('Klarbild · Print module')}</p>
<h1>{t('Bring images to exact sizes.')}<br /><span class="no-ai">{t('Without AI.')}</span></h1>
<p class="lead">
{t('A passport photo is 35 × 45 millimetres. A picture frame is 13 × 18 centimetres. Both are measurements, not opinions — and still it is remarkably laborious to get a photo out of the printer at exactly that size. This tool turns it into one move: enter the target size, choose the crop, print the PDF. Accurate to the millimetre, with crop marks, several images on one sheet.')}
</p>
<div class="hero-marks" aria-hidden="true">
<svg viewBox="0 0 300 120" class="hero-svg">
<rect x="8" y="8" width="284" height="104" fill="#FBFBF7" stroke="#D7D6CC" />
<g stroke="#1B3BE0" stroke-width="1">
<path d="M26 22h-10M22 26v-10" /><path d="M120 22h10M124 26v-10" />
<path d="M26 98h-10M22 94v10" /><path d="M120 98h10M124 94v10" />
<path d="M156 22h-10M152 26v-10" /><path d="M282 22h-8M278 26v-10" />
<path d="M156 98h-10M152 94v10" /><path d="M282 98h-8M278 94v10" />
</g>
<rect x="26" y="22" width="94" height="76" fill="#EAEAE3" stroke="#D7D6CC" stroke-width="0.5" />
<rect x="156" y="22" width="120" height="76" fill="#EAEAE3" stroke="#D7D6CC" stroke-width="0.5" />
<text x="30" y="93" font-family="monospace" font-size="7" fill="#6A685D">35 × 45 mm</text>
<text x="160" y="93" font-family="monospace" font-size="7" fill="#6A685D">13 × 18 cm</text>
</svg>
</div>
</header>
<section class="stats" aria-label={t('Key figures')}>
{stats.map((d) => (
<div class="stat">
<b>{d.value}</b>
<span class="mono-label">{d.label}</span>
<p>{d.hint}</p>
</div>
))}
</section>
<section class="problem">
<h2>{t('The problem')}</h2>
<p>
{t('The usual route runs through a layout program or through the printer driver, and both of them mean too well: “fit to page”, “scale to fit”, “scale borderless”. What comes out is a print that is almost right. For a passport photo, “almost” is too little, and using a whole A4 sheet for a 4 × 3 snapshot, only because it would otherwise not fit on the page at 100% size, is plain waste.')}
</p>
<p>
{t('Klarbild Print computes in millimetres instead, packs as many images onto a sheet as will go, and puts crop marks where the cut is made. What comes out of the printer has the size you entered.')}
</p>
</section>
<section class="sample">
<h2>{t('One sheet, computed')}</h2>
<p class="sample-lead">
{t('One 13 × 18, one 9 × 13 and six passport photos — on a single A4 sheet, all at 100% size, with corner marks for cutting. This drawing is not a sketch: it comes from the same packer that produces the PDF.')}
</p>
<div class="sample-holder">
<svg class="sample-svg" viewBox={`-6 -6 ${sampleSheet.wMm + 12} ${sampleSheet.hMm + 12}`} role="img"
aria-label={t('A4 sheet with {n} images in three sizes', { n: samplePieces })}>
<rect x="0" y="0" width={sampleSheet.wMm} height={sampleSheet.hMm} fill="#FBFBF7" stroke="#D7D6CC" stroke-width="0.4" />
{samplePage?.placements.map((pl) => (
<g>
<rect x={pl.x} y={pl.y} width={pl.w} height={pl.h}
fill={sampleColor[pl.specId] || 'oklch(45% 0.23 267 / 0.14)'}
stroke="oklch(45% 0.23 267 / 0.45)" stroke-width="0.3" />
<text x={pl.x + pl.w / 2} y={pl.y + pl.h / 2 + 1.6} text-anchor="middle"
font-family="monospace" font-size="4" fill="#16150F" opacity="0.62">
{shortSize(pl.w, pl.h)}
</text>
</g>
))}
{sampleMarks.map((l) => (
<line x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2} stroke="#1B3BE0" stroke-width="0.25" />
))}
</svg>
<ul class="sample-list">
<li><i style="background: oklch(45% 0.23 267 / 0.09)"></i> 1 × 13 × 18 cm</li>
<li><i style="background: oklch(45% 0.23 267 / 0.20)"></i> 1 × 9 × 13 cm</li>
<li><i style="background: oklch(45% 0.23 267 / 0.34)"></i> 6 × 35 × 45 mm</li>
<li class="sample-sum">{t('{pieces} pieces on {sheets} sheets · 5 mm margin · 8 mm gap', { pieces: samplePieces, sheets: samplePlan.pages.length })}</li>
</ul>
</div>
</section>
<section class="features">
<h2>{t('What it can do')}</h2>
<div class="cards">
{features.map((k) => (
<article class="card">
<p class="mono-label">{k.kicker}</p>
<h3>{k.title}</h3>
<p class="card-text">{k.text}</p>
<p class="card-proof">{k.proof}</p>
</article>
))}
</div>
</section>
<section class="flow">
<h2>{t('In four steps')}</h2>
<ol class="steps">
{steps.map((s) => (
<li><span class="mono-label">{s.n}</span><b>{s.title}</b><p>{s.text}</p></li>
))}
</ol>
</section>
<section class="tech">
<h2>{t('Under the hood')}</h2>
<dl>
<div><dt>{t('Unit')}</dt><dd set:html={t('Millimetres. Pixels only appear at render time (<code>px = mm / 25.4 × dpi</code>), PDF points via <code>mm / 25.4 × 72</code>.')} /></div>
<div><dt>{t('Compute core')}</dt><dd>{t('Dependency-free TypeScript. The browser preview and the server PDF use literally the same code — what you see is what gets printed.')}</dd></div>
<div><dt>{t('Packing')}</dt><dd>{t('An exact grid for equal sizes, MaxRects (best short side fit) for mixed ones. Scored by fewest sheets, fullest first page, fewest rotations.')}</dd></div>
<div><dt>{t('Image')}</dt><dd>{t('sharp in a single chain: EXIF rotation, crop, scaling, border. The crop is stored relatively (0…1) and is therefore independent of resolution.')}</dd></div>
<div><dt>{t('PDF')}</dt><dd>{t('pdf-lib. Page size exactly the sheet size, images placed 1 : 1, crop marks as vector lines, glue flaps dashed.')}</dd></div>
<div><dt>{t('Proof')}</dt><dd>{t('Automated tests for the geometry plus a raster sample of the finished PDFs at 254 dpi (= 10 px/mm) with edge measurement.')}</dd></div>
</dl>
</section>
<section class="open">
<h2>{t('Run it yourself')}</h2>
<p>
{t('Klarbild runs on a server of your own: Astro with Node, PostgreSQL, a queue in the same process, files locally or on S3. The print module needs no external service at all — no model, no key, no images that leave the house. If all you want is printing, the AI parts never have to be configured.')}
</p>
<p class="hint">
{t('This page is a tour, not a user interface — nothing can be uploaded and nothing generated here.')}
</p>
</section>
<footer class="foot">
<span class="regmark"></span>
<span>{t('Klarbild · Print module')}</span>
</footer>
</main>
<style>
.tour { max-width: 980px; margin: 0 auto; padding: 0 20px 80px; }
.tour h2 { font-size: 1.5rem; letter-spacing: -0.01em; margin: 0 0 14px; }
.tour section { margin: 64px 0; }
.hero { padding: 72px 0 8px; }
.hero h1 { font-size: clamp(2.1rem, 6vw, 3.4rem); line-height: 1.08; letter-spacing: -0.03em; margin: 14px 0 20px; }
.no-ai { color: var(--accent); }
.lead { font-size: clamp(1rem, 2.2vw, 1.15rem); color: var(--ink-soft); max-width: 62ch; margin: 0; line-height: 1.65; }
.hero-marks { margin-top: 34px; }
.hero-svg { width: 100%; max-width: 420px; height: auto; display: block; }
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px;
background: var(--line); border: 1px solid var(--line); }
.stat { background: var(--card); padding: 20px 18px; }
.stat b { display: block; font-size: 2rem; letter-spacing: -0.03em; line-height: 1.1; }
.stat .mono-label { display: block; margin: 6px 0 8px; }
.stat p { margin: 0; font-size: 0.82rem; color: var(--ink-soft); line-height: 1.5; }
.problem p { max-width: 68ch; color: var(--ink-soft); margin: 0 0 14px; }
.sample-lead { max-width: 62ch; color: var(--ink-soft); margin: 0 0 24px; }
.sample-holder { display: grid; grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 32px; align-items: start; }
.sample-svg { width: 100%; height: auto; display: block; }
.sample-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px;
font-size: 0.9rem; color: var(--ink-soft); }
.sample-list li { display: flex; align-items: center; gap: 10px; }
.sample-list i { width: 22px; height: 22px; border: 1px solid oklch(45% 0.23 267 / 0.45); flex: none; }
.sample-sum { font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.08em;
color: var(--soft); padding-top: 12px; border-top: 1px solid var(--line); }
.sample-sum::before { content: none; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); gap: 16px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; }
.card h3 { font-size: 1.12rem; margin: 8px 0 10px; letter-spacing: -0.01em; }
.card-text { margin: 0; font-size: 0.92rem; color: var(--ink-soft); line-height: 1.62; }
.card-proof { margin: 14px 0 0; padding-top: 12px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 0.72rem; color: var(--accent); line-height: 1.5; }
.steps { list-style: none; padding: 0; margin: 0; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
.steps li { border-top: 2px solid var(--ink); padding-top: 12px; }
.steps b { display: block; margin: 8px 0 6px; }
.steps p { margin: 0; font-size: 0.88rem; color: var(--ink-soft); line-height: 1.6; }
.tech dl { margin: 0; display: grid; gap: 1px; background: var(--line); border: 1px solid var(--line); }
.tech dl > div { background: var(--card); padding: 14px 18px; display: grid;
grid-template-columns: 130px minmax(0, 1fr); gap: 16px; align-items: baseline; }
.tech dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--soft); }
.tech dd { margin: 0; font-size: 0.9rem; color: var(--ink-soft); line-height: 1.6; }
.tech code { font-family: var(--font-mono); font-size: 0.82em; background: var(--paper);
border: 1px solid var(--line); border-radius: 3px; padding: 1px 5px; }
.open p { max-width: 68ch; color: var(--ink-soft); margin: 0 0 14px; }
.open .hint { font-size: 0.85rem; color: var(--soft); }
.foot { display: flex; align-items: center; gap: 10px; padding-top: 28px;
border-top: 1px solid var(--line); font-family: var(--font-mono); font-size: 11px;
letter-spacing: 0.14em; text-transform: uppercase; color: var(--soft); }
@media (max-width: 860px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) {
.tour { padding: 0 16px 64px; }
.hero { padding: 40px 0 4px; }
.tour section { margin: 44px 0; }
.tech dl > div { grid-template-columns: 1fr; gap: 6px; }
.sample-holder { grid-template-columns: 1fr; gap: 20px; }
}
</style>
</Base>