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:
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user