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

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