Files
tillandClaude Opus 5 623b93ad70 fix: close the path-escape hole in delivery (gallery and filename)
The wiki flagged this twice as open: `recipe.picdrop_gallery` had the same
weakness the print module already fixed. `posixpath.join('/kunden/lea',
'../../../etc/cron.d')` is `/etc/cron.d`, and the upload creates the directory
first. Gallery and file names come from the database -- recipes, folders,
settings -- and therefore ultimately from user input.

Checked in `safeSegment()` immediately before the write, in both `uploadBuffer`
(Picdrop and every FTP target) and `uploadToNas`. That is the one place nobody
delivering can bypass, rather than a check somewhere further up that a new call
site would miss.

Deliberately a denylist of the dangerous constructs -- separators, control
characters, all-dot names -- not a narrow allowlist. A customer folder may well
be called "Müller & Söhne (2026)"; that is harmless, and rejecting it would
have been a self-inflicted outage. Covered both ways by tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 02:21:47 +00:00

157 lines
8.0 KiB
TypeScript
Raw Permalink 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 { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildResultFilename, timeStamp, shortTag, slugify, KLARBILD_NAME, splitOldName, newNameFor } from '../src/lib/naming.ts';
const AT = new Date('2026-08-20T12:32:07Z'); // = 14:32:07 in Berlin (Sommerzeit)
const ID1 = 'a3f9c1de-1111-2222-3333-444455556666';
const ID2 = '77b21e00-1111-2222-3333-444455556666';
test('Der gemeldete Fehler: zwei Badges am selben Tag überschreiben sich nicht mehr', () => {
// Gleiches Motiv, gleiches Format, gleiche Sekunde — früher zweimal derselbe Name.
const a = buildResultFilename('Lea Badge.png', 'the-frame', 'jpg', { at: AT, id: ID1 });
const b = buildResultFilename('Lea Badge.png', 'the-frame', 'jpg', { at: AT, id: ID2 });
assert.notEqual(a, b, 'zwei Bilder dürfen nie denselben Namen tragen');
assert.equal(a, '2026-08-20_143207_lea-badge_the-frame_a3f9.jpg');
assert.equal(b, '2026-08-20_143207_lea-badge_the-frame_77b2.jpg');
});
test('Namen sortieren lexikografisch = chronologisch', () => {
const zeiten = [
new Date('2026-08-19T23:59:59Z'),
new Date('2026-08-20T00:00:01Z'),
new Date('2026-08-20T12:32:07Z'),
new Date('2026-12-24T08:00:00Z'), // Winterzeit — anderer UTC-Versatz
new Date('2027-01-01T00:00:00Z'),
];
const namen = zeiten.map((at, i) => buildResultFilename('foto', '30x40', 'jpg', { at, id: `${i}${i}${i}${i}0000-0000-0000-0000-000000000000` }));
const sortiert = [...namen].sort();
assert.deepEqual(sortiert, namen, `Sortierung weicht ab:\n${sortiert.join('\n')}`);
});
test('Zeitstempel steht in Berliner Zeit, nicht in UTC', () => {
// 00:30 Uhr Berliner Sommerzeit ist 22:30 UTC am Vortag — der Name muss den
// Tag zeigen, an dem Till das Bild gemacht hat.
assert.equal(timeStamp(new Date('2026-08-19T22:30:00Z')), '2026-08-20_003000');
assert.equal(timeStamp(new Date('2026-12-24T07:05:09Z')), '2026-12-24_080509'); // Winterzeit: +1
});
test('Mitternacht wird als 00 geschrieben, nicht als 24', () => {
assert.match(timeStamp(new Date('2026-08-19T22:00:00Z')), /^2026-08-20_00000/);
});
test('Kürzel ist stabil — ein zweiter Lauf ergibt denselben Namen', () => {
assert.equal(shortTag(ID1), shortTag(ID1));
assert.equal(shortTag(ID1), 'a3f9');
const it = { id: ID1, filename: '2026-07-23_lea_the-frame.png', created_at: AT };
assert.equal(newNameFor(it), newNameFor(it));
});
test('Erneutes Verarbeiten stapelt keine Stempel', () => {
const einmal = buildResultFilename('Lea Badge.png', 'the-frame', 'jpg', { at: AT, id: ID1 });
const zweimal = buildResultFilename(einmal, '30x40', 'jpg', { at: AT, id: ID2 });
assert.equal(zweimal, '2026-08-20_143207_lea-badge_30x40_77b2.jpg');
const dreimal = buildResultFilename(zweimal, 'a4', 'jpg', { at: AT, id: ID1 });
assert.equal(dreimal, '2026-08-20_143207_lea-badge_a4_a3f9.jpg');
});
test('Das eigene Muster erkennt eigene Namen und nur die', () => {
assert.ok(KLARBILD_NAME.test('2026-08-20_143207_lea-badge_the-frame_a3f9'));
assert.ok(KLARBILD_NAME.test('2026-08-20_143207_lea_30x40_a3f9-2'));
assert.ok(!KLARBILD_NAME.test('2026-07-23_lea_the-frame')); // altes Schema
assert.ok(!KLARBILD_NAME.test('urlaub-2026_strand')); // fremder Name
});
test('Alte Namen werden korrekt zerlegt', () => {
assert.deepEqual(splitOldName('2026-07-23_lea_the-frame.png'), { motif: 'lea', format: 'the-frame', ext: 'png' });
assert.deepEqual(splitOldName('2026-07-23_klarbild_30x40.jpg'), { motif: 'klarbild', format: '30x40', ext: 'jpg' });
// Fremde Datei ohne unser Schema: alles wird zum Motiv, Format „original".
assert.deepEqual(splitOldName('Urlaub Kreta.JPEG'), { motif: 'urlaub-kreta', format: 'original', ext: 'jpeg' });
// Schon neues Schema: Motiv und Format bleiben, damit die Nachbenennung idempotent ist.
assert.deepEqual(splitOldName('2026-08-20_143207_lea-badge_the-frame_a3f9.jpg'),
{ motif: 'lea-badge', format: 'the-frame', ext: 'jpg' });
});
test('Nachbenennung ist idempotent — zweiter Lauf ändert nichts', () => {
const alt = { id: ID1, filename: '2026-07-23_lea_the-frame.png', created_at: new Date('2026-07-23T09:15:42Z') };
const neu = newNameFor(alt);
assert.equal(neu, '2026-07-23_111542_lea_the-frame_a3f9.png');
assert.equal(newNameFor({ ...alt, filename: neu }), neu, 'zweiter Lauf muss denselben Namen liefern');
});
test('Nachbenennung nimmt das Erstelldatum, nicht das Datum aus dem alten Namen', () => {
// Falls ein Name mal ein falsches Datum trug, gewinnt created_at aus der Datenbank.
const it = { id: ID2, filename: '2020-01-01_lea_a4.png', created_at: new Date('2026-03-05T06:00:00Z') };
assert.match(newNameFor(it), /^2026-03-05_070000_lea_a4_77b2\.png$/);
});
test('Zähler hängt sich an, wenn ein Name wirklich vergeben ist', () => {
const a = buildResultFilename('lea', 'a4', 'png', { at: AT, id: ID1 });
const b = buildResultFilename('lea', 'a4', 'png', { at: AT, id: ID1, seq: 2 });
assert.equal(b, a.replace('.png', '-2.png'));
assert.ok(KLARBILD_NAME.test(b.replace('.png', '')));
});
test('Motive bleiben lesbar und dateisystemtauglich', () => {
const n = buildResultFilename('Größe & Maß – Übersicht!.png', '13x18', 'jpg', { at: AT, id: ID1 });
assert.equal(n, '2026-08-20_143207_groesse-mass-uebersicht_13x18_a3f9.jpg');
assert.ok(!/[^a-z0-9._-]/.test(n), `unerwartete Zeichen in ${n}`);
});
test('Generische Namen werden zu „klarbild", statt Unsinn zu erben', () => {
for (const n of ['IMG.png', 'foto.jpg', 'telegram.jpg', null]) {
const out = buildResultFilename(n as any, 'a4', 'png', { at: AT, id: ID1 });
assert.match(out, /_klarbild_a4_a3f9\.png$/, `${n} → ${out}`);
}
});
test('slugify bleibt unverändert nutzbar', () => {
assert.equal(slugify('Grüße aus Köln'), 'gruesse-aus-koeln');
});
// --- Kollisionsschleife (der Gürtel zum Hosenträger) --------------------------
// Die Schleife wird mit einer eingesetzten „ist vergeben"-Prüfung getestet, damit
// dafür keine Datenbank laufen muss.
import { uniqueFilename } from '../src/lib/renames.ts';
test('Ist der Wunschname wider Erwarten vergeben, hängt sich ein Zähler an', async () => {
const belegt = new Set(['2026-08-20_143207_lea_a4_a3f9.png']);
const n = await uniqueFilename(ID1, 'lea', 'a4', 'png', AT, async (x) => belegt.has(x));
assert.equal(n, '2026-08-20_143207_lea_a4_a3f9-2.png');
});
test('Freier Name wird unverändert vergeben', async () => {
const n = await uniqueFilename(ID1, 'lea', 'a4', 'png', AT, async () => false);
assert.equal(n, '2026-08-20_143207_lea_a4_a3f9.png');
});
test('Die Schleife gibt auch bei dauerhaft belegten Namen etwas zurück', async () => {
const n = await uniqueFilename(ID1, 'lea', 'a4', 'png', AT, async () => true);
assert.ok(n.endsWith('.png') && n.length > 10, `unbrauchbarer Rückfall: ${n}`);
});
// --- Pfad-Ausbruch beim Ausliefern -------------------------------------------
// `posixpath.join('/kunden/lea','../../../etc/cron.d')` ergibt `/etc/cron.d`, und
// der Upload legt das Verzeichnis vorher an. Deshalb wird unmittelbar vor dem
// Schreiben geprüft — hier die Prüfung selbst.
import { safeSegment } from '../src/lib/picdrop.ts';
test('Galerie- und Dateinamen können den Basisordner nicht verlassen', () => {
const boese = ['..', '../..', '../../../etc/cron.d', 'a/b', 'a\\b', '.', '...',
'POSTER LEA/../..', 'y\ny', 'z\u0000z'];
for (const b of boese) {
assert.throws(() => safeSegment(b, 'Galerie'), /Ungültige/, `durchgerutscht: ${JSON.stringify(b)}`);
}
});
test('Echte Kundenordner bleiben erlaubt — die Prüfung ist keine Schikane', () => {
const gut = ['POSTER LEA', 'TheFrame-Backgrounds', 'Müller & Söhne (2026)',
"Hochzeit O'Brien", 'Kita 2026_Gruppe 3', 'a.b.c',
'2026-08-20_143207_lea-badge_the-frame_a3f9.jpg'];
for (const g of gut) assert.equal(safeSegment(g, 'Galerie'), g, `fälschlich abgelehnt: ${g}`);
});
test('Leerer Name ist erlaubt und bedeutet „kein Unterordner"', () => {
assert.equal(safeSegment('', 'Galerie'), '');
assert.equal(safeSegment(' ', 'Galerie'), '');
});