Files
klarbild/src/pages/api/print/single.ts
T
till e87c61435c 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.
2026-08-18 07:50:11 +00:00

68 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { APIRoute } from 'astro';
import { renderCell, type CropRel } from '../../../lib/printrender';
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
import { parseSizeMm, mmToPx, labelMm } from '../../../lib/paper';
import { dpiCheck } from '../../../lib/printlayout';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/**
* Ein einzelnes Bild exakt auf ein Maß bringen — ohne KI, ohne Bogen.
* Antwort: die fertige Bilddatei (PNG/JPG) mit dpi-Metadaten.
*/
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
let body: any;
try { body = await request.json(); } catch { return json({ error: 'Ungültige Anfrage.' }, 400); }
try {
const src: PrintSource = body?.src;
const crop: CropRel | null = body?.crop ?? null;
let size = (Number(body?.wMm) > 0 && Number(body?.hMm) > 0)
? { w: Number(body.wMm), h: Number(body.hMm) }
: parseSizeMm(String(body?.size || ''));
if (!size) return json({ error: 'Maß fehlt oder ist ungültig.' }, 400);
if (body?.landscape) size = { w: size.h, h: size.w };
if (size.w < 5 || size.h < 5 || size.w > 2000 || size.h > 2000) return json({ error: 'Maß außerhalb des zulässigen Bereichs.' }, 400);
const dpi = Math.min(1200, Math.max(72, Number(body?.dpi) || 300));
const ext: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
const buf = await loadSource(src, locals.user as any);
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: 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];
const check = dpiCheck(cropW, size.w, dpi);
return new Response(out.buffer, {
headers: {
'Content-Type': out.ext === 'png' ? 'image/png' : 'image/jpeg',
'Content-Disposition': `attachment; filename="${sheetFilename(body?.name || labelMm(size.w, size.h).replace(/[^0-9x×]/g, ''), out.ext)}"`,
'X-Klarbild-Px': `${out.width}x${out.height}`,
'X-Klarbild-Real-Dpi': String(check.dpi),
'X-Klarbild-Dpi-Ok': check.ok ? '1' : '0',
},
});
} catch (e: any) {
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);
}
};
/** Kleine Hilfe für Skripte/MCP: Maß → Pixel bei dpi. */
export const GET: APIRoute = async ({ url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const size = parseSizeMm(url.searchParams.get('size') || '');
const dpi = Math.min(1200, Math.max(72, Number(url.searchParams.get('dpi')) || 300));
if (!size) return json({ error: 'Parameter size fehlt (z. B. 12x15 oder 35x45mm).' }, 400);
return json({ mm: size, dpi, px: { w: mmToPx(size.w, dpi), h: mmToPx(size.h, dpi) }, label: labelMm(size.w, size.h) });
};