fix: code-review findings in the print module

Security and robustness:
- EXIF orientation is now applied before any geometry. Phone photos carry the
  rotation only as metadata; sharp was cropping the unrotated raster, so a
  portrait shot came out of the printer sideways and wrongly framed.
- renderCell no longer materialises the padded image at source resolution.
  It is one extract-resize-extend chain now, which is also sharp's internal
  order. A panorama into a narrow contain target used to build a ~960 MB
  intermediate and then fail; it is 90 ms and a few MB now.
- Target size is capped (300 Mpx) and bleedMm is clamped in /api/print/single,
  which had no bound at all.
- The delivery gallery is validated before use - posixpath.join let a crafted
  name escape the target's base folder and create directories there.
- Sheet requests are capped at 500 pieces and the packer has a step budget, so
  a degenerate request cannot block the single-threaded server.
- Print presets: delete only your own (admins all), config size limit, count
  limit, and by_name honours anonymous_generations.
- Telegram callbacks require an active pairing, like every other path.
- Error responses no longer leak storage paths or delivery hostnames.

Correctness:
- allowRotate:undefined now means allowed, consistently with the packer.
- The many-formats shortcut no longer drops a format that only fits rotated.
- unplaced names the format that is actually missing, not the first one.
- Crop marks never sit inside the printed bleed - the offset is raised.
- capacity() computes the grid instead of probing with 200 copies.
- Image keys in the sheet cannot collide with a cell literally named x::rot.
- labelMm keeps real decimals; parseSizeMm reads a:b as width:height, so
  3:4/15 is portrait and 4:3/15 is landscape.
- The footer is skipped when there is no free space at the bottom.
- The UI warns when corner marks do not fit the margin, and when continuous
  guides are used with mixed sizes.

Tests: 21 -> 31, each finding has a regression test.
This commit is contained in:
till committed 2026-08-18 07:50:11 +00:00
1 parent 7d42782c41
commit e87c61435c
12 files changed
+303 -76

No files matched your search

+13 -1
View File
@@ -35,6 +35,10 @@ interface Cell {
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
/** Farbwerte aus fremden Vorlagen landen in style — nur echte Hex-Werte zulassen. */
const safeColor = (v: unknown): string =>
typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v) ? v : '#ffffff';
/** Größter Ausschnitt im Zielverhältnis — füllt das Format aus, schneidet ab. */
function coverCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel {
const imgA = natW / natH;
@@ -292,7 +296,7 @@ export default function PrintApp() {
const apply = (cell: Cell, f: any): Cell => {
const next = { ...cell, id: cell.id, sizeId: f.sizeId, customSize: f.customSize || '',
landscape: !!f.landscape, count: f.count || 1, allowRotate: f.allowRotate !== false,
fit: (f.fit === 'contain' ? 'contain' : 'cover') as FitMode, bg: f.bg || '#ffffff' };
fit: (f.fit === 'contain' ? 'contain' : 'cover') as FitMode, bg: safeColor(f.bg) };
const a = aspectOf(next); if (a) next.crop = baseCrop(next.natW, next.natH, a, next.fit, cell.crop);
return next;
};
@@ -405,6 +409,14 @@ export default function PrintApp() {
: marks === 'grid' ? 'Durchgehende Hilfslinien über den ganzen Bogen — für Schneidelineal und Schlagschere.'
: 'Keine Linien — Kanten selbst anlegen.'}
</div>
{marks === 'corner' && marginMm < markOff + markLen && (
<div className="fein warn">Bei {marginMm} mm Rand ist außerhalb der Bilder kein Platz für die Marken —
sie werden abgeschnitten. Rand auf mindestens {Math.ceil(markOff + markLen)} mm setzen.</div>
)}
{marks === 'grid' && new Set(ready.map((c) => { const s = sizeOfCell(c); return s ? `${s.w}x${s.h}` : ''; })).size > 1 && (
<div className="fein warn">Durchgehende Linien passen nur zu <b>einem</b> Bildmaß. Bei gemischten Größen
werden sie unterbrochen — ein Schnitt über das ganze Blatt würde andere Bilder treffen. Besser Eckmarken.</div>
)}
{marks === 'corner' && (
<div className="reihe zwei">
<label className="mini">Länge (mm)
+7 -6
View File
@@ -95,11 +95,11 @@ export function parseSizeMm(input: string): { w: number; h: number } | null {
// Seitenverhältnis mit Zielkante: „4:3 / 15" oder „4:3 15cm"
const ratio = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*(?:[/@ ]\s*(\d+(?:\.\d+)?)\s*(mm|cm)?)?$/.exec(t);
if (ratio && ratio[3]) {
// a:b wird als Breite:Höhe gelesen — „3:4/15" ist also hochkant, „4:3/15" quer.
const a = parseFloat(ratio[1]), b = parseFloat(ratio[2]);
const long = ratio[4] === 'mm' ? parseFloat(ratio[3]) : parseFloat(ratio[3]) * 10;
if (!a || !b || !long) return null;
const [lo, hi] = a >= b ? [b, a] : [a, b];
return norm(long * (lo / hi), long);
return a >= b ? norm(long, long * (b / a)) : norm(long * (a / b), long);
}
const unit = /mm\s*$/.test(t) ? 1 : 10; // ohne Einheit: cm
@@ -123,9 +123,10 @@ function norm(w: number, h: number): { w: number; h: number } | null {
export const mmToPx = (mm: number, dpi: number) => Math.round((mm / 25.4) * dpi);
export const mmToPt = (mm: number) => (mm / 25.4) * 72;
/** Hübsche Beschriftung eines Maßes für die Oberfläche. */
/** Hübsche Beschriftung eines Maßes — ohne die echten Nachkommastellen zu verlieren. */
export function labelMm(w: number, h: number): string {
const f = (n: number) => (n % 10 === 0 ? String(n / 10) : String(Math.round(n) / 10).replace('.', ','));
return w < 100 && h < 100 ? `${fmt(w)} × ${fmt(h)} mm` : `${f(w)} × ${f(h)} cm`;
const mm = (n: number) => de(Math.round(n * 10) / 10);
const cm = (n: number) => de(Math.round(n * 100) / 1000);
return w < 100 && h < 100 ? `${mm(w)} × ${mm(h)} mm` : `${cm(w)} × ${cm(h)} cm`;
}
const fmt = (n: number) => String(Math.round(n * 10) / 10).replace('.', ',');
const de = (n: number) => String(n).replace('.', ',');
+33 -9
View File
@@ -77,7 +77,7 @@ export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
const n = Math.max(0, Math.floor(s.count || 0));
if (!n) continue;
const fitsPlain = s.wMm <= availW + EPS && s.hMm <= availH + EPS;
const fitsRot = !!s.allowRotate && s.hMm <= availW + EPS && s.wMm <= availH + EPS;
const fitsRot = s.allowRotate !== false && s.hMm <= availW + EPS && s.wMm <= availH + EPS;
if (!fitsPlain && !fitsRot) {
unplaced.push({ specId: s.id, count: n, reason: 'größer als die Nutzfläche des Bogens' });
continue;
@@ -101,8 +101,14 @@ export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
return sp.allowRotate !== false && Math.abs(sp.wMm - sp.hMm) > EPS;
});
const tooBig = ids.length > 5 || units.length > 150;
// Bei sehr vielen Formaten/Stücken nicht alle Kombinationen durchprobieren —
// aber jedem Format trotzdem eine Ausrichtung geben, in der es überhaupt passt.
const variants: Record<string, boolean>[] = tooBig
? [Object.fromEntries(ids.map((id) => [id, false]))]
? [Object.fromEntries(ids.map((id) => {
const sp = specs.find((x) => x.id === id)!;
const plainFits = sp.wMm <= availW + EPS && sp.hMm <= availH + EPS;
return [id, !plainFits && sp.allowRotate !== false];
}))]
: combos(rotatable).map((set) => Object.fromEntries(ids.map((id) => [id, set.has(id)])));
let best: Page[] | null = null, bestScore = -Infinity;
@@ -125,8 +131,11 @@ export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
if (score > bestScore) { bestScore = score; best = cand; }
}
pages = best || maxRectsPack(units.map((u) => ({ ...u, rot: false })), availW, availH, gap);
const placed = pages.reduce((n, p) => n + p.placements.length, 0);
if (placed < units.length) unplaced.push({ specId: units[0].specId, count: units.length - placed, reason: 'kein Platz auf dem Bogen' });
// Fehlende Stücke je Bildformat zählen — nicht pauschal dem ersten zuschreiben.
const placedKeys = new Set(pages.flatMap((pg) => pg.placements.map((pl) => `${pl.specId}#${pl.copy}`)));
const missing = new Map<string, number>();
for (const u of units) if (!placedKeys.has(`${u.specId}#${u.copy}`)) missing.set(u.specId, (missing.get(u.specId) || 0) + 1);
for (const [id, n] of missing) unplaced.push({ specId: id, count: n, reason: 'kein Platz auf dem Bogen' });
}
// Auf dem Bogen ausrichten: Blockmitte oder linke obere Ecke.
@@ -192,6 +201,10 @@ function maxRectsPack(units: { specId: string; copy: number; w: number; h: numbe
const W = availW + gap, H = availH + gap; // aufgeblasene Fläche
const pages: Page[] = [];
// Notbremse gegen entartete Eingaben (viele Formate × viele Stück): der
// Packer läuft synchron im Serverprozess und darf ihn nicht blockieren.
let steps = 0;
const MAX_STEPS = 2_000_000;
while (todo.length) {
let free: FreeRect[] = [{ x: 0, y: 0, w: W, h: H }];
@@ -204,6 +217,7 @@ function maxRectsPack(units: { specId: string; copy: number; w: number; h: numbe
for (let i = 0; i < todo.length; i++) {
const u = todo[i];
const cw = u.w + gap, ch = u.h + gap;
if ((steps += free.length) > MAX_STEPS) break;
for (const r of free) {
if (cw > r.w + EPS || ch > r.h + EPS) continue;
const short = Math.min(r.w - cw, r.h - ch);
@@ -215,7 +229,7 @@ function maxRectsPack(units: { specId: string; copy: number; w: number; h: numbe
}
}
}
if (bestIdx < 0 || !bestRect) break;
if (bestIdx < 0 || !bestRect || steps > MAX_STEPS) break;
const u = todo.splice(bestIdx, 1)[0];
placements.push({ specId: u.specId, copy: u.copy, x: round(bestRect.x), y: round(bestRect.y), w: u.w, h: u.h, rotated: u.rot });
@@ -225,7 +239,7 @@ function maxRectsPack(units: { specId: string; copy: number; w: number; h: numbe
if (!placements.length) break; // nichts platzierbar → Abbruch
placements.sort((a, b) => a.y - b.y || a.x - b.x);
pages.push({ placements });
if (pages.length > 200) break; // Notbremse
if (pages.length > 200 || steps > MAX_STEPS) break; // Notbremse
}
return pages;
}
@@ -263,8 +277,16 @@ function splitFree(free: FreeRect[], used: FreeRect): FreeRect[] {
/** Wie viele Endformate passen maximal auf einen Bogen? (Kennzahl für die UI.) */
export function capacity(spec: Omit<PlaceSpec, 'count'>, sheet: SheetSpec): number {
const probe = layout([{ ...spec, count: 200 }], sheet);
return probe.pages[0]?.placements.length || 0;
// Direkt rechnen statt zu packen — sonst deckelt die Probe die Antwort.
const gap = effectiveGap(sheet);
const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0);
const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset;
const grid = (w: number, h: number) =>
Math.max(0, Math.floor((availW + gap + EPS) / (w + gap))) *
Math.max(0, Math.floor((availH + gap + EPS) / (h + gap)));
const plain = grid(spec.wMm, spec.hMm);
const rot = spec.allowRotate !== false ? grid(spec.hMm, spec.wMm) : 0;
return Math.max(plain, rot);
}
export interface MarkOptions {
@@ -283,7 +305,9 @@ export interface MarkOptions {
export function cutMarks(page: Page, sheet: SheetSpec, opt: MarkOptions): Line[] {
if (opt.mode === 'none') return [];
const len = opt.lengthMm ?? 4;
const off = opt.offsetMm ?? Math.max(2, (opt.bleedMm ?? 0) + 1);
// Der Versatz muss den gedruckten Beschnitt überspringen — sonst landet die
// Marke auf dem Motiv. Ein zu kleiner Wunschwert wird deshalb angehoben.
const off = Math.max(opt.offsetMm ?? 0, (opt.bleedMm ?? 0) + 1, 2);
const lines: Line[] = [];
if (opt.mode === 'corner') {
+68 -46
View File
@@ -53,79 +53,97 @@ export async function renderCell(
opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string; fit?: FitMode } = {},
): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> {
const bleed = Math.max(0, opt.bleedMm || 0);
const meta = await sharp(input, { failOn: 'none' }).metadata();
const fit: FitMode = opt.fit === 'contain' ? 'contain' : 'cover';
const bg = /^#[0-9a-fA-F]{6}$/.test(opt.background || '') ? opt.background! : '#ffffff';
// EXIF-Ausrichtung anwenden, BEVOR gerechnet wird. Handyfotos tragen die
// Drehung nur als Metadatum; Browser und iPhone zeigen sie gedreht, sharp
// rechnet ohne diesen Schritt auf dem ungedrehten Raster — dann kommt das
// Bild quer und mit falschem Ausschnitt aus dem Drucker.
const meta0 = await sharp(input, { failOn: 'none' }).metadata();
const src = (meta0.orientation ?? 1) > 1
? await sharp(input, { failOn: 'none' }).rotate().toBuffer()
: input;
const meta = (meta0.orientation ?? 1) > 1 ? await sharp(src, { failOn: 'none' }).metadata() : meta0;
const nw = meta.width || 0, nh = meta.height || 0;
if (!nw || !nh) throw new Error('Bildmaße unbekannt.');
// Ohne Vorgabe: passend zur gewählten Regel mittig ausrichten — nie verzerren,
// das ist bei Druckmaßen die häufigste Falle.
const fit: FitMode = opt.fit === 'contain' ? 'contain' : 'cover';
const bg = opt.background || '#ffffff';
const c = crop ? normCrop(crop, fit) : (fit === 'contain'
? containCrop(nw, nh, wMm / hMm)
: coverCrop(nw, nh, wMm / hMm));
// Zuschnitt um die Beschnittzugabe erweitern (mittig), damit die Trimmbox
// exakt das bleibt, was in der Vorschau gerahmt wurde.
// Beschnittzugabe: Ausschnitt mittig erweitern, damit die Trimmbox exakt
// der gerahmte Bereich bleibt.
const fx = (wMm + 2 * bleed) / wMm;
const fy = (hMm + 2 * bleed) / hMm;
const cw = c.w * fx, ch = c.h * fy;
const cx = c.x - (cw - c.w) / 2, cy = c.y - (ch - c.h) / 2;
const want = { left: cx * nw, top: cy * nh, width: cw * nw, height: ch * nh };
const left = Math.round(want.left), top = Math.round(want.top);
const width = Math.max(1, Math.round(want.width)), height = Math.max(1, Math.round(want.height));
const ex = {
left: Math.min(Math.max(0, left), nw - 1),
top: Math.min(Math.max(0, top), nh - 1),
};
const exW = Math.max(1, Math.min(width + Math.min(0, left), nw - ex.left));
const exH = Math.max(1, Math.min(height + Math.min(0, top), nh - ex.top));
const padLeft = Math.max(0, ex.left - left);
const padTop = Math.max(0, ex.top - top);
const padRight = Math.max(0, width - exW - padLeft);
const padBottom = Math.max(0, height - exH - padTop);
const targetW = mmToPx(wMm + 2 * bleed, dpi);
const targetH = mmToPx(hMm + 2 * bleed, dpi);
if (targetW * targetH > MAX_TARGET_PX)
throw new Error(`Zielbild zu groß (${targetW}×${targetH} px). Bitte Maß oder Auflösung verringern.`);
// ACHTUNG: sharp führt `extend` intern NACH `resize` aus. Zuschnitt und
// Auffüllen müssen deshalb in einem eigenen Durchgang passieren, sonst wird
// das Ergebnis um die Randbreite zu groß (Rand-Modus, große Beschnittzugabe).
let staged = sharp(input, { failOn: 'none' })
.extract({ left: ex.left, top: ex.top, width: exW, height: exH });
if (padLeft || padTop || padRight || padBottom) {
// Beim Einpassen wird der fehlende Bereich zur Randfarbe; beim Zuschneiden
// (nur Beschnittzugabe) wird die Bildkante fortgeschrieben, damit kein
// weißer Streifen am Schnitt entsteht.
staged = staged.extend(fit === 'contain'
? { left: padLeft, top: padTop, right: padRight, bottom: padBottom, background: bg }
: { left: padLeft, top: padTop, right: padRight, bottom: padBottom, extendWith: 'copy' });
// Sichtbarer Teil des Ausschnitts (der Rest wird gefüllt, nicht gerendert).
const vx0 = Math.max(cx, 0), vy0 = Math.max(cy, 0);
const vx1 = Math.min(cx + cw, 1), vy1 = Math.min(cy + ch, 1);
if (vx1 - vx0 <= 0 || vy1 - vy0 <= 0) throw new Error('Ausschnitt liegt außerhalb des Bildes.');
const L = clampInt(Math.round(vx0 * nw), 0, nw - 1);
const T = clampInt(Math.round(vy0 * nh), 0, nh - 1);
const W = clampInt(Math.round((vx1 - vx0) * nw), 1, nw - L);
const H = clampInt(Math.round((vy1 - vy0) * nh), 1, nh - T);
// Abbildung Ausschnitt → Zielbild (Maßstab in px je Ausschnittsanteil).
const sx = targetW / (cw * nw), sy = targetH / (ch * nh);
let dx = clampInt(Math.round((vx0 - cx) * nw * sx), 0, targetW - 1);
let dy = clampInt(Math.round((vy0 - cy) * nh * sy), 0, targetH - 1);
const dw = clampInt(Math.round(W * sx), 1, targetW - dx);
const dh = clampInt(Math.round(H * sy), 1, targetH - dy);
const right = targetW - dx - dw, bottom = targetH - dy - dh;
// Eine einzige sharp-Kette: extract → resize → extend. Diese Reihenfolge ist
// sharps interne Reihenfolge, deshalb stimmen die Zahlen. Wichtig: erst nach
// dem Verkleinern auffüllen, sonst wächst das Zwischenbild ins Unermessliche.
let img = sharp(src, { failOn: 'none' })
.extract({ left: L, top: T, width: W, height: H })
.resize(dw, dh, { fit: 'fill' });
if (dx || dy || right || bottom) {
img = img.extend(fit === 'contain'
? { top: dy, left: dx, bottom, right, background: bg }
: { top: dy, left: dx, bottom, right, extendWith: 'copy' });
}
const stagedBuf = await staged.png({ compressionLevel: 0 }).toBuffer();
let img = sharp(stagedBuf, { failOn: 'none' }).resize(targetW, targetH, { fit: 'fill' });
if (opt.rotate) img = img.rotate(90);
const hasAlpha = !!meta.hasAlpha;
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
const flat = hasAlpha && ext === 'jpg';
if (flat) img = img.flatten({ background: bg });
if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg });
const out = await (ext === 'png'
? img.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
: img.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true })
).toBuffer({ resolveWithObject: true });
const encode = (pipe: sharp.Sharp) => (ext === 'png'
? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
: pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }));
// Drehen erst im zweiten Durchgang: sharp würde eine Drehung sonst vor dem
// Auffüllen anwenden und die Ränder auf die falschen Seiten legen.
let out = await (opt.rotate ? img.png({ compressionLevel: 0 }) : encode(img))
.toBuffer({ resolveWithObject: true });
if (opt.rotate) out = await encode(sharp(out.data, { failOn: 'none' }).rotate(90))
.toBuffer({ resolveWithObject: true });
return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] };
}
/** Obergrenze fürs Zielbild — schützt vor Speicherexplosion durch extreme Maß/dpi-Kombinationen. */
const MAX_TARGET_PX = 300_000_000;
const clampInt = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n | 0));
function normCrop(c: CropRel | null, fit: FitMode = 'cover'): CropRel {
if (!c) return FULL_CROP;
const num = (v: number, d = 0) => (Number.isFinite(v) ? v : d);
// Beim Einpassen darf der Ausschnitt größer als das Bild sein (dort entsteht der Rand),
// beim Zuschneiden muss er innerhalb des Bildes liegen.
const maxSide = fit === 'contain' ? 40 : 1;
const maxSide = fit === 'contain' ? 8 : 1;
const w = Math.min(maxSide, Math.max(0.001, num(c.w, 1)));
const h = Math.min(maxSide, Math.max(0.001, num(c.h, 1)));
if (fit === 'contain') {
@@ -191,7 +209,11 @@ export async function buildSheetPdf(input: SheetPdfInput): Promise<Uint8Array> {
color: rgb(0, 0, 0),
});
}
if (font && input.footer) {
// Fußzeile nur, wenn unten wirklich Platz ist — sonst stünde sie im Motiv.
const freeBottom = pg.placements.length
? sheet.hMm - Math.max(...pg.placements.map((p) => p.y + p.h + bleed))
: sheet.hMm;
if (font && input.footer && freeBottom >= 6) {
page.drawText(input.footer, {
x: mmToPt(4), y: mmToPt(3), size: 6, font, color: rgb(0.45, 0.45, 0.42),
rotate: degrees(0),
+3 -1
View File
@@ -15,7 +15,9 @@ export async function loadSource(src: PrintSource, user: SessionUser): Promise<B
if (src.kind === 'upload') {
// Nur der Upload-Bereich ist erreichbar — kein Weg zu results/ oder /etc.
const p = String(src.path || '');
if (!/^sources\/[0-9]{4}\/[A-Za-z0-9_-]+\.[A-Za-z0-9]{2,5}$/.test(p)) throw new Error('Ungültige Quelle.');
// \n bewusst ausschließen: JS-„$" matcht auch vor einem abschließenden Zeilenumbruch.
if (/[\r\n]/.test(p) || !/^sources\/[0-9]{4}\/[A-Za-z0-9_-]+\.[A-Za-z0-9]{2,5}$/.test(p))
throw new Error('Ungültige Quelle.');
return getObject(p);
}
+2
View File
@@ -438,6 +438,8 @@ function register(b: Bot) {
b.on('callback_query:data', async (ctx) => {
const [kind, recipeId, extra] = ctx.callbackQuery.data.split(':');
await ctx.answerCallbackQuery();
// Auch Knöpfe brauchen eine aktive Kopplung — sonst wirkt ein alter Chat weiter.
if (!(await linkedUser(ctx.chat!.id))) return ctx.editMessageText('⛔️ Nicht (mehr) gekoppelt. Bitte neuen Kopplungscode aus dem Admin eingeben.');
if (kind === 'p') { // Druckbogen ohne KI
const draft = await one<{ id: string }>(
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status IN ('awaiting_recipe','awaiting_print')
+18 -4
View File
@@ -8,10 +8,13 @@ const json = (b: unknown, s = 200) =>
/** Bogen-Vorlagen: Papier, Marken, Bildformate und Stückzahlen — ohne Bilder. */
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, u.display_name AS by_name
`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`);
ORDER BY p.name ASC LIMIT 200`);
return json({ presets: rows });
};
@@ -21,9 +24,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
const name = String(body?.name || '').trim();
if (!name) return json({ error: 'Name fehlt.' }, 400);
if (!body?.config || typeof body.config !== 'object') return json({ error: 'Konfiguration fehlt.' }, 400);
// Vorlagen werden allen Nutzern ausgeliefert — deshalb eine harte Größengrenze.
const cfg = JSON.stringify(body.config);
if (cfg.length > 20000) return json({ error: 'Vorlage zu groß.' }, 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: 'Zu viele Vorlagen — bitte zuerst aufräumen.' }, 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, JSON.stringify(body.config)]);
[name.slice(0, 80), locals.user.uid, cfg]);
return json({ preset: row });
};
@@ -31,6 +40,11 @@ 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 fehlt.' }, 400);
await query('DELETE FROM print_presets WHERE id=$1', [id]);
// Nur eigene Vorlagen (Admins dürfen alles) — sonst löscht jeder jedem den Kita-Satz.
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: 'Vorlage nicht gefunden oder nicht deine.' }, 404);
return json({ ok: true });
};
+26 -5
View File
@@ -12,6 +12,7 @@ export const prerender = false;
const MAX_CELLS = 40; // verschiedene Bilder je Bogen
const MAX_COPIES = 200; // Exemplare je Bild
const MAX_PAGES = 20;
const MAX_PIECES = 500; // Summe aller Exemplare — schützt den Packer vor Entartung
interface CellIn {
id: string;
@@ -57,6 +58,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
resolved.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h });
}
const totalPieces = specs.reduce((n, s) => n + s.count, 0);
if (totalPieces > MAX_PIECES) return json({ error: `Zu viele Einzelbilder (${totalPieces}). Höchstens ${MAX_PIECES} je Bogen-Auftrag.` }, 422);
const plan = layout(specs, sheet);
if (!plan.pages.length) {
return json({ error: 'Nichts platzierbar — Bildmaß größer als die Nutzfläche des Bogens.', unplaced: plan.unplaced }, 422);
@@ -65,6 +69,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Für jedes Bild genau einmal rendern — auch bei 24 Exemplaren.
// Gedrehte Platzierungen brauchen eine eigene Fassung.
// Bild-Schlüssel bewusst NICHT aus der Zell-ID ableiten — eine Zelle namens
// „x::rot" würde sonst die gedrehte Fassung von „x" überschreiben.
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>();
@@ -75,18 +82,18 @@ export const POST: APIRoute = async ({ request, locals }) => {
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[r.cell.id] = { bytes: out.buffer, ext: out.ext };
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[`${r.cell.id}::rot`] = { bytes: out.buffer, ext: out.ext };
images[keyOf(r.cell.id, true)] = { bytes: out.buffer, ext: out.ext };
}
}
// Platzierungen auf die passende Bildfassung umbiegen.
const pages = plan.pages.map((pg) => ({
placements: pg.placements.map((p) => ({ ...p, specId: p.rotated ? `${p.specId}::rot` : p.specId })),
placements: pg.placements.map((p) => ({ ...p, specId: keyOf(p.specId, p.rotated) })),
}));
const marks = plan.pages.map((pg) =>
@@ -116,10 +123,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
if (body?.deliver) {
const key = body.deliver.target === '' || body.deliver.target == null ? 'picdrop' : String(body.deliver.target);
try {
// Zuerst den Galerienamen prüfen: keine Schrägstriche, kein „..", sonst
// ließe sich per posixpath.join aus dem Basisordner des Ziels ausbrechen.
const wish = String(body.deliver.gallery || '').trim();
if (wish && (wish.includes('..') || !/^[\p{L}\p{N} _.\-]{1,64}$/u.test(wish)))
throw new Error('Ungültiger Galeriename.');
const cfg = await cfgForKey(key);
if (!cfg) throw new Error('Ziel nicht konfiguriert.');
const s = await one<{ picdrop_default_gallery: string | null }>('SELECT picdrop_default_gallery FROM settings WHERE id=1');
const gallery = String(body.deliver.gallery || '').trim() || s?.picdrop_default_gallery || 'POSTER LEA';
const gallery = wish || s?.picdrop_default_gallery || 'POSTER LEA';
await uploadBuffer(cfg, gallery, filename, Buffer.from(bytes));
delivered = '1';
deliveryMsg = `Bogen nach „${gallery}" ausgeliefert.`;
@@ -139,10 +151,19 @@ export const POST: APIRoute = async ({ request, locals }) => {
},
});
} catch (e: any) {
return json({ error: e?.message || 'Druckbogen konnte nicht erzeugt werden.' }, 400);
// Interne Details (Pfade, Hostnamen der Auslieferungsziele) bleiben im Log.
console.error('[print/sheet]', e?.message || e);
return json({ error: userMessage(e) }, 400);
}
};
/** Nur selbst formulierte Meldungen nach außen geben, nichts aus der Tiefe. */
const SAFE = /^(Papierformat|Bild \d|Maß|Ungültige|Unbekannte|Bild nicht|Kein Zugriff|Quelle|Ausschnitt|Zielbild|Zu viele)/;
function userMessage(e: any): string {
const m = String(e?.message || '');
return SAFE.test(m) ? m : 'Druckbogen konnte nicht erzeugt werden.';
}
function readSheet(body: any): SheetSpec {
let w: number | null = null, h: number | null = null;
const p = body?.paper;
+5 -2
View File
@@ -34,7 +34,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
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: Number(body?.bleedMm) || 0, fit, background });
{ ext, bleedMm: Math.min(10, Math.max(0, Number(body?.bleedMm) || 0)), fit, background });
// Hinweis, falls die Quelle für echte 300 dpi zu klein ist.
const cropW = (crop?.w ?? 1) * out.srcPx[0];
@@ -50,7 +50,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
},
});
} catch (e: any) {
return json({ error: e?.message || 'Bild konnte nicht erzeugt werden.' }, 400);
console.error('[print/single]', e?.message || e);
const m = String(e?.message || '');
const safe = /^(Maß|Ungültige|Unbekannte|Bild nicht|Kein Zugriff|Quelle|Ausschnitt|Zielbild)/.test(m);
return json({ error: safe ? m : 'Bild konnte nicht erzeugt werden.' }, 400);
}
};
+5 -1
View File
@@ -31,11 +31,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
ext = meta.format === 'jpeg' ? 'jpg' : (meta.format || 'png');
}
const meta = await sharp(buf, { failOn: 'none' }).metadata();
// EXIF-Drehung einrechnen: der Browser zeigt das Bild orientiert an, also
// müssen auch die gemeldeten Maße orientiert sein (Zuschnitt im Druckmodul).
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: meta.width, height: meta.height,
width: turned ? meta.height : meta.width,
height: turned ? meta.width : meta.height,
source_quality: 'original',
});
} catch (e: any) {