From e87c61435c62a5da30c7c2c5df858d03e1ea15cd Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Tue, 18 Aug 2026 07:50:11 +0000 Subject: [PATCH] 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. --- src/components/PrintApp.tsx | 14 +++- src/lib/paper.ts | 13 ++-- src/lib/printlayout.ts | 42 +++++++++--- src/lib/printrender.ts | 114 ++++++++++++++++++++------------- src/lib/printsource.ts | 4 +- src/lib/telegram.ts | 2 + src/pages/api/print/presets.ts | 22 +++++-- src/pages/api/print/sheet.ts | 31 +++++++-- src/pages/api/print/single.ts | 7 +- src/pages/api/uploads.ts | 6 +- tests/printlayout.test.ts | 67 ++++++++++++++++++- tests/printrender.test.ts | 57 +++++++++++++++++ 12 files changed, 303 insertions(+), 76 deletions(-) diff --git a/src/components/PrintApp.tsx b/src/components/PrintApp.tsx index 9afa0fd..07315a3 100644 --- a/src/components/PrintApp.tsx +++ b/src/components/PrintApp.tsx @@ -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.'} + {marks === 'corner' && marginMm < markOff + markLen && ( +
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.
+ )} + {marks === 'grid' && new Set(ready.map((c) => { const s = sizeOfCell(c); return s ? `${s.w}x${s.h}` : ''; })).size > 1 && ( +
Durchgehende Linien passen nur zu einem Bildmaß. Bei gemischten Größen + werden sie unterbrochen — ein Schnitt über das ganze Blatt würde andere Bilder treffen. Besser Eckmarken.
+ )} {marks === 'corner' && (