e87c61435c
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.
68 lines
3.4 KiB
TypeScript
68 lines
3.4 KiB
TypeScript
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) });
|
||
};
|