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,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);
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user