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:
2026-08-18 07:50:11 +00:00
parent 7d42782c41
commit e87c61435c
12 changed files with 303 additions and 76 deletions
+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) {