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:
164 files changed
+27190
No files matched your search
@@ -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.' });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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' } });
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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.' });
|
||||
}
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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) },
|
||||
});
|
||||
};
|
||||
@@ -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() },
|
||||
});
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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' },
|
||||
});
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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}"`,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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' } });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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' } });
|
||||
}
|
||||
Reference in new issue
Block a user