feat: CSP and security headers, memory limit, AI provenance, one crop core
Five things off the open list in the wiki. CSP with real hashes for every inline script (Astro's experimental.csp), so no 'unsafe-inline' for scripts. style-src is deliberately reopened in middleware: React sets styles as attributes and attributes cannot be hashed -- measured, the hash-only policy blocks them and the print preview loses its layout. Plus nosniff, referrer-policy, X-Frame-Options and a permissions-policy. Verified across all pages: zero violations. mem_limit 3g in compose. sharp can spike; without a limit an outlier takes the whole container down instead of just the process, and the healthcheck can't help. AI results now carry IPTC DigitalSourceType (trainedAlgorithmicMedia, or compositeWithTrainedAlgorithmicMedia for edited photos) plus the model, embedded as XMP -- written by hand into PNG iTXt and JPEG APP1 rather than pulling in a sharp upgrade with a native postinstall. exiftool reads it back from both formats; pixels, dimensions and dpi are provably untouched. Crop maths lived twice, in the browser preview and the server renderer, with nothing holding them together. Now one dependency-free module, and 60000 randomised cases assert both entry points agree. That test immediately found a real bug: the zoom limits (0.02 / 8x) also clamped the *initial* crop, so an extreme format silently broke the aspect ratio -- "Rand lassen" would have cropped after all. Limits are now widened to whatever the format needs. Also: the colour flag sat exactly on the selection circle in the library and covered it. Moved to the bottom left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
This commit is contained in:
11 files changed
+370
-64
No files matched your search
@@ -0,0 +1,106 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { baseCrop, clampCrop, centred, coverSize, containSize, type CropRel } from '../src/lib/cropmath.ts';
|
||||
import { coverCrop, containCrop } from '../src/lib/printrender.ts';
|
||||
|
||||
/** Deterministischer Zufall — bei einem Fehlschlag ist der Fall reproduzierbar. */
|
||||
function rng(seed: number) {
|
||||
let s = seed >>> 0;
|
||||
return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 0x1_0000_0000);
|
||||
}
|
||||
|
||||
test('Server-Einstieg und Browser-Einstieg rechnen identisch (cover)', () => {
|
||||
const r = rng(20260821);
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const natW = 1 + Math.floor(r() * 8000);
|
||||
const natH = 1 + Math.floor(r() * 8000);
|
||||
const aspect = 0.05 + r() * 20;
|
||||
const server = coverCrop(natW, natH, aspect); // printrender.ts → PDF
|
||||
const browser = baseCrop(natW, natH, aspect, 'cover'); // cropmath.ts → Vorschau
|
||||
assert.deepEqual(browser, server, `cover ${natW}x${natH} @ ${aspect}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Server-Einstieg und Browser-Einstieg rechnen identisch (contain)', () => {
|
||||
const r = rng(7);
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const natW = 1 + Math.floor(r() * 8000);
|
||||
const natH = 1 + Math.floor(r() * 8000);
|
||||
const aspect = 0.05 + r() * 20;
|
||||
assert.deepEqual(baseCrop(natW, natH, aspect, 'contain'), containCrop(natW, natH, aspect),
|
||||
`contain ${natW}x${natH} @ ${aspect}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('cover bleibt im Bild, contain umschließt das ganze Bild', () => {
|
||||
const r = rng(99);
|
||||
const EPS = 1e-9;
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const natW = 1 + Math.floor(r() * 6000);
|
||||
const natH = 1 + Math.floor(r() * 6000);
|
||||
const aspect = 0.05 + r() * 20;
|
||||
|
||||
const c = baseCrop(natW, natH, aspect, 'cover');
|
||||
assert.ok(c.x >= -EPS && c.y >= -EPS && c.x + c.w <= 1 + EPS && c.y + c.h <= 1 + EPS,
|
||||
`cover ragt heraus: ${JSON.stringify(c)}`);
|
||||
|
||||
const k = baseCrop(natW, natH, aspect, 'contain');
|
||||
assert.ok(k.x <= EPS && k.y <= EPS && k.x + k.w >= 1 - EPS && k.y + k.h >= 1 - EPS,
|
||||
`contain umschließt nicht: ${JSON.stringify(k)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Das Seitenverhältnis stimmt — sonst würde verzerrt', () => {
|
||||
const r = rng(4242);
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const natW = 1 + Math.floor(r() * 6000);
|
||||
const natH = 1 + Math.floor(r() * 6000);
|
||||
const aspect = 0.05 + r() * 20;
|
||||
for (const fit of ['cover', 'contain'] as const) {
|
||||
const c = baseCrop(natW, natH, aspect, fit);
|
||||
// Der Ausschnitt in Pixeln muss exakt das Zielverhältnis haben.
|
||||
const ist = (c.w * natW) / (c.h * natH);
|
||||
assert.ok(Math.abs(ist - aspect) < 1e-9,
|
||||
`${fit} ${natW}x${natH}: Verhältnis ${ist} statt ${aspect}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Bei passendem Verhältnis ist der Ausschnitt das ganze Bild', () => {
|
||||
for (const [w, h] of [[3000, 2000], [1000, 1000], [350, 450]]) {
|
||||
for (const fit of ['cover', 'contain'] as const) {
|
||||
assert.deepEqual(baseCrop(w, h, w / h, fit), { x: 0, y: 0, w: 1, h: 1 }, `${w}x${h} ${fit}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Formatwechsel behält den Bildmittelpunkt', () => {
|
||||
// Nutzer hat nach links oben verschoben, wechselt dann von 3:2 auf 35x45.
|
||||
const vorher: CropRel = { x: 0.1, y: 0.05, w: 0.4, h: 0.4 };
|
||||
const nachher = baseCrop(3000, 2000, 35 / 45, 'cover', vorher);
|
||||
const mitteVorher = vorher.x + vorher.w / 2;
|
||||
const mitteNachher = nachher.x + nachher.w / 2;
|
||||
// In der eingeschränkten Achse kann der Anschlag greifen — er darf den
|
||||
// Mittelpunkt aber nur zum Bild hin verschieben, nicht darüber hinaus.
|
||||
assert.ok(Math.abs(mitteNachher - mitteVorher) < 0.5, `Mitte sprang: ${mitteVorher} → ${mitteNachher}`);
|
||||
assert.ok(nachher.x >= 0 && nachher.x + nachher.w <= 1);
|
||||
});
|
||||
|
||||
test('clampCrop hält cover im Bild und lässt contain kontrolliert hinausragen', () => {
|
||||
assert.deepEqual(clampCrop({ x: -5, y: -5, w: 0.5, h: 0.5 }, 'cover'), { x: 0, y: 0, w: 0.5, h: 0.5 });
|
||||
assert.deepEqual(clampCrop({ x: 5, y: 5, w: 0.5, h: 0.5 }, 'cover'), { x: 0.5, y: 0.5, w: 0.5, h: 0.5 });
|
||||
// contain: mindestens 5 % des Ausschnitts müssen im Bild bleiben.
|
||||
const weit = clampCrop({ x: -99, y: -99, w: 2, h: 2 }, 'contain');
|
||||
assert.ok(weit.x + weit.w > 0 && weit.y + weit.h > 0, 'Motiv komplett aus dem Rahmen geschoben');
|
||||
});
|
||||
|
||||
test('centred legt den Ausschnitt um einen Mittelpunkt', () => {
|
||||
assert.deepEqual(centred(0.5, 0.5), { x: 0.25, y: 0.25, w: 0.5, h: 0.5 });
|
||||
assert.deepEqual(centred(0.2, 0.2, { x: 0, y: 0, w: 1, h: 1 }), { x: 0.4, y: 0.4, w: 0.2, h: 0.2 });
|
||||
});
|
||||
|
||||
test('coverSize und containSize sind zueinander invers im Verhältnis', () => {
|
||||
const c = coverSize(3000, 2000, 0.5), k = containSize(3000, 2000, 0.5);
|
||||
assert.ok(c.w <= 1 + 1e-9 && c.h <= 1 + 1e-9, 'cover darf nicht über 1');
|
||||
assert.ok(k.w >= 1 - 1e-9 || k.h >= 1 - 1e-9, 'contain muss mindestens eine Kante füllen');
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
buildXmp, embedProvenance, readXmp,
|
||||
TRAINED_ALGORITHMIC_MEDIA, COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA,
|
||||
} from '../src/lib/provenance.ts';
|
||||
|
||||
const bild = (w = 240, h = 160) =>
|
||||
sharp({ create: { width: w, height: h, channels: 3, background: '#c8463c' } });
|
||||
|
||||
test('Das XMP-Paket trägt den IPTC-Code und das Modell', () => {
|
||||
const xmp = buildXmp({ sourceType: TRAINED_ALGORITHMIC_MEDIA, model: 'google/gemini-3-pro-image' });
|
||||
assert.match(xmp, /Iptc4xmpExt:DigitalSourceType="http:\/\/cv\.iptc\.org\/newscodes\/digitalsourcetype\/trainedAlgorithmicMedia"/);
|
||||
assert.match(xmp, /xmp:CreatorTool="Klarbild \(google\/gemini-3-pro-image\)"/);
|
||||
assert.match(xmp, /<x:xmpmeta/);
|
||||
});
|
||||
|
||||
test('Sonderzeichen im Prompt zerlegen das XML nicht', () => {
|
||||
const xmp = buildXmp({
|
||||
sourceType: TRAINED_ALGORITHMIC_MEDIA,
|
||||
description: 'Lea & Phoenix <b>"groß"</b> — 3 < 5',
|
||||
});
|
||||
assert.ok(!xmp.includes('<b>'), 'unmaskiertes Tag im XMP');
|
||||
assert.match(xmp, /Lea & Phoenix/);
|
||||
// Genau so viele öffnende wie schließende Klammern der bekannten Elemente.
|
||||
assert.equal((xmp.match(/<rdf:Description/g) || []).length, 1);
|
||||
assert.equal((xmp.match(/<\/rdf:Description>/g) || []).length, 1);
|
||||
});
|
||||
|
||||
for (const [name, machen] of [
|
||||
['PNG', async () => bild().png().toBuffer()],
|
||||
['JPEG', async () => bild().jpeg().toBuffer()],
|
||||
['JPEG mit dpi-Metadaten (wie im Auftrag)', async () => bild().withMetadata({ density: 300 }).jpeg({ quality: 92 }).toBuffer()],
|
||||
] as const) {
|
||||
test(`${name}: Kennzeichnung wird geschrieben und das Bild bleibt heil`, async () => {
|
||||
const vorher = await machen();
|
||||
const nachher = embedProvenance(vorher, {
|
||||
sourceType: TRAINED_ALGORITHMIC_MEDIA, model: 'test/modell', description: 'Prüflauf',
|
||||
});
|
||||
assert.ok(nachher.length > vorher.length, 'nichts eingebettet');
|
||||
|
||||
// 1) Kennzeichnung ist wieder auslesbar …
|
||||
const xmp = readXmp(nachher);
|
||||
assert.ok(xmp, 'XMP nicht gefunden');
|
||||
assert.match(xmp!, /trainedAlgorithmicMedia/);
|
||||
|
||||
// 2) … und sharp liest die Datei weiterhin, mit unveränderten Maßen.
|
||||
const m = await sharp(nachher).metadata();
|
||||
const m0 = await sharp(vorher).metadata();
|
||||
assert.equal(m.width, m0.width);
|
||||
assert.equal(m.height, m0.height);
|
||||
assert.equal(m.format, m0.format);
|
||||
if (m0.density) assert.equal(m.density, m0.density, 'dpi verloren');
|
||||
|
||||
// 3) Die Bildpunkte selbst sind unangetastet.
|
||||
const a = await sharp(vorher).raw().toBuffer();
|
||||
const b = await sharp(nachher).raw().toBuffer();
|
||||
assert.ok(a.equals(b), 'Bildinhalt hat sich verändert');
|
||||
});
|
||||
}
|
||||
|
||||
test('Bereinigte Fotos bekommen den Code für verändertes Material', () => {
|
||||
const xmp = buildXmp({ sourceType: COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA });
|
||||
assert.match(xmp, /compositeWithTrainedAlgorithmicMedia/);
|
||||
});
|
||||
|
||||
test('Unbekannte Formate und Schrott kommen unverändert zurück', () => {
|
||||
for (const b of [Buffer.from('kein bild'), Buffer.alloc(0), Buffer.from([0x52, 0x49, 0x46, 0x46])]) {
|
||||
assert.ok(embedProvenance(b, { sourceType: TRAINED_ALGORITHMIC_MEDIA }).equals(b));
|
||||
}
|
||||
});
|
||||
|
||||
test('Zweimal kennzeichnen bricht die Datei nicht', async () => {
|
||||
const png = await bild().png().toBuffer();
|
||||
const einmal = embedProvenance(png, { sourceType: TRAINED_ALGORITHMIC_MEDIA });
|
||||
const zweimal = embedProvenance(einmal, { sourceType: TRAINED_ALGORITHMIC_MEDIA });
|
||||
const m = await sharp(zweimal).metadata();
|
||||
assert.equal(m.width, 240);
|
||||
});
|
||||
Reference in new issue
Block a user