Zwei Druckbogen desselben Tages hießen beide `2026-09-01_druckbogen.pdf`. Der
Browser hängte „(1)" an; wer sie in denselben Ordner oder aufs NAS legte, verlor
den ersten. Genau der Fehler, der im August für die Bibliotheksbilder behoben
wurde — im Druckmodul stand er noch.
Druckdateien folgen jetzt derselben Regel wie alles andere:
2026-09-01_143207_druck-9-teile_a4_4eca.pdf
Datum, Uhrzeit auf die Sekunde, was auf dem Bogen liegt, das Papier, ein Kürzel.
Das Kürzel ist die Kennung des Verlaufseintrags — eine PDF-Datei lässt sich also
ihrem Eintrag unter „Zuletzt gedruckt" zuordnen. Dafür entsteht die Kennung vor
dem Dateinamen und wird an `recordRun` durchgereicht.
Einzelbilder erben das Motiv ihrer Quelle:
`2026-09-01_143207_lea-badge_130x180_25b3.jpg` statt `2026-09-01_1318.jpg`.
Dafür gibt `loadSourceNamed` neben den Bilddaten auch Namen und Kennung zurück.
Die Oberfläche erfindet keinen eigenen Namen mehr. Der Server schickt einen, der
Client liest ihn aus `Content-Disposition` — es gab zwei Wahrheiten, und die des
Clients war die schlechtere: Sie enthielt nur das Datum. Aus dem Kopf gelesene
Namen werden entschärft, bevor sie in `a.download` landen.
`sheetFilename` in printsource.ts ist entfallen; die Regeln stehen jetzt
vollständig in `naming.ts`, wo sie schon für die Bibliothek stehen.
12 neue Tests, 123 grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
Drei Dinge, die im Alltag gefehlt haben.
**Bilder aus der Bibliothek in den Druckbereich.** Bisher musste man eine
Datei hochladen, auch wenn Klarbild sie längst hatte. Jetzt gibt es einen
Wähler mit Suche, Ordnerfilter und Mehrfachauswahl — erreichbar aus der
leeren Ablage, aus der Kopfzeile der ersten Karte und an jeder Zellengruppe.
**Verlauf der Drucke.** Gespeichert wird bewusst nur die Anleitung, nicht
das Ergebnis: ein A3-Bogen ist schnell 20 bis 80 MB, ein Verlaufseintrag
rund ein bis drei Kilobyte. Weil das Druckmodul ohne KI rechnet, ergibt
derselbe Auftrag wieder exakt dasselbe Blatt — nur die Zeitstempel im PDF
unterscheiden sich, der Bildinhalt ist Byte für Byte gleich. „Nochmal
drucken" gibt es deshalb nur, wenn alle Zellen aus der Bibliothek kamen und
alle Bilder noch da sind; sonst steht am Eintrag, woran es liegt. Je Konto
bleiben die letzten 100 Drucke, ältere räumt der Eintrag selbst weg.
Sichtbar sind nur die eigenen Läufe, Admins sehen alle.
**Mailzugang aus der Umgebung.** Der Versand nimmt jetzt die Server-
Einstellung, solange unter Admin → E-Mail nichts eingetragen ist. Die
Zugangsdaten liegen als Umgebungsvariablen in Coolify, nicht im Repo. Der
Admin zeigt an, welche Quelle gerade wirklich greift.
Unterwegs mitgenommen:
- Auf schmalen Geräten stand die rechte Spalte oben (order:-1, damit die
Vorschau gleich sichtbar ist). Mit der Verlaufskarte hätte man an einem
ganzen Block vorbeigescrollt, ehe man ein Bild hinzufügen kann — die
Karten laufen jetzt in ihrer eigenen Nummerierung.
- Die Kurzfassung listete jede Zelle einzeln: „10 × 15 cm, 10 × 15 cm".
Gleiche Maße werden zusammengezogen, vorhandene Stückzahlen addiert.
Der erste Ansatz machte dabei aus „13 × 18 cm" prompt „13 cm ×18" — das
× im Maß sah aus wie eine Stückzahl. Sieben Tests halten das jetzt fest.
- „das PDF war 0.0 MB groß" für einen 20-KB-Bogen: unter einem Megabyte
wird in KB gerechnet.
- Fehlt eine Datei auf der Platte, zeigte der Browser ein kaputtes
Bildsymbol. Jetzt bleibt die Fläche ruhig.
111 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
Twenty pictures meant twenty taps. Now a single button above the grid, a plain
GET link so it works without JavaScript, on a page whose recipients are mostly
on a phone.
Written to stream rather than buffer. The existing /api/items/zip loads every
object into memory and appends them all before the consumer attaches; for a
share of full-resolution posters that is gigabytes at once against a 3g limit.
This route reads one file at a time and waits for archiver's `entry` event
before reading the next. Measured: 440 MB of images, 26 s, RSS peaked at 196 MB
against a 120 MB baseline, and the archive verifies.
Guards, all checked: download-locked links return 403 and the button does not
render; a passphrase-protected link needs the proof cookie (401 without);
revoked 410, unknown 404; five packages per link and address per ten minutes;
200 files and 500 MB, past which the archive closes with a HINWEIS.txt saying
what happened and where to get the rest. Counted as one download.
Name collisions inside the archive are numbered, since duplicates only surface
at extraction time where the second silently overwrites the first. Archive
naming pulled into testable helpers with 7 tests (104 total).
Also fixes a bug the screenshot caught: `.bild a` matched the per-image download
button inside figcaption as well as the image link, painting it paper-coloured
with near-white text. It had been effectively invisible since the page was
written; I had looked at it twice without seeing it. Scoped to `.bild > a`.
Contrast measured from real pixels afterwards: 7.73 on both buttons, 5.08 on the
muted text, all above AA.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
Two reviews, both dimensions. The serious ones, verified before fixing:
Sessions never expired server-side and survived a password reset. `Max-Age` is
a request to the browser, not a rule for the server, and `readSession` only
checked the signature -- a leaked cookie stayed valid indefinitely, and the very
reset meant to rescue a compromised account left the intruder logged in. A
demoted admin also kept admin, because the role came from the cookie. Now the
cookie carries a session epoch, `verifySession` checks the account against the
database on every request and takes the role from there, and the epoch is
bumped on password reset and on any role change. This was pre-existing, but the
reset feature promised safety it did not deliver.
"Allow download: off" did nothing for images with transparency -- exactly the
cutouts and stickers this tool exists for. The view path skipped resizing when
`has_alpha`, so the untouched original went out at full resolution, right-click
saveable. Verified: now 1500x2000 instead of 2400x3200, and the download route
returns 403.
The share title went into outgoing mail HTML unescaped while the message was
escaped -- a foreign link could be posted through the account's own
SPF/DKIM-signed mailserver. Both escaped now, plus length limits.
argon2 ran on every request for passphrase-protected links, including every
image: 130 ms and 64 MiB each. A 40-image gallery would have walked into the
memory limit, and a few dozen wrong guesses could have stalled the process.
Checked once, then a short-lived signed cookie scoped to that one link. The
passphrase moved from the query string to POST (it was landing in history and
proxy logs) and wrong guesses are now rate limited.
`days` accepted nonsense and quietly produced an unlimited link -- `Number('abc')`
is NaN and `NaN > 0` is false. `[]` coerced to 0, same result. Now rejected
outright, which matters when the pictures are of children.
Rate limits keyed on `clientAddress`, which is the *first* X-Forwarded-For
value -- set by the caller. Counting from the right instead, with the number of
trusted proxies configurable (TRUSTED_PROXIES, default 1), plus per-username and
per-recipient counters, a real limit on outgoing mail, and a limiter that
expires entries individually instead of clearing everyone's counter.
Also: expired links could still be mailed; missing PUBLIC_BASE_URL sent dead
links while reporting success; the reset endpoint's response time revealed
whether an account existed (send is no longer awaited); invalid UUIDs produced
500s; the email uniqueness race returned an HTML error page; share_views grew
without bound and is now one row per visitor per hour, hashed per link, user
agent reduced to browser and system, deleted after 90 days; double provenance
marking appended a second XMP packet with the older model instead of replacing.
21 new tests (97 total) for shares, mail and provenance -- all three were
completely untested, and that is where both reviews found the most.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
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
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
Two badges of the same person, same format, same day produced the same
filename -- `2026-07-23_lea_the-frame.png` -- and the second upload silently
overwrote the first at Picdrop. The name now carries the time down to the
second and a short tag derived from the item id:
2026-08-20_143207_lea-badge_the-frame_a3f9.jpg
The timestamp leads, so any file listing sorts chronologically on its own.
The tag is derived, not random, so re-running the backfill is idempotent.
Timestamps use Europe/Berlin (KLARBILD_TZ) rather than UTC -- a picture taken
at 00:30 should not be dated to the previous day.
Naming moved into its own dependency-free module so the rules are testable and
identical everywhere. The generator strips its own stamp when a result is fed
back in as a source, so names cannot accumulate.
Backfill for the existing library in Admin -> Dateinamen: preview first, then
apply. It re-stamps from `created_at` and the item id, keeping the motif and
format already chosen, and leaves the stored files alone -- they live under
their id, not their name. A separate button re-delivers everything under the
new name; the old copies stay put at the destination, since deleting a
customer's files automatically is the wrong reflex.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
Prints larger than the sheet no longer just fail. Each cell can now spill over
the whole sheet (20x30 on A4 loses exactly 3 mm, and says so beforehand) or be
tiled across several sheets with a dashed glue fold and a sheet number. The
maths lives in printoversize.ts, dependency-free, so the browser preview and the
server PDF compute the same thing -- including the note strip that keeps the
sheet number off the picture.
The same image can now appear as several cells, so a portrait can be printed at
two sizes on one sheet without copying and re-uploading the file. The footer
line can be switched off, given custom text and placed at one of six positions;
it is only drawn where that edge is free.
Layout reworked for all three widths: work left, sticky preview right on the
desktop, collapsed setting groups paired in a two-column grid that expands full
width; preview first and a fixed action bar on the phone. Verified at 390, 820,
1440 and 1680 px -- no horizontal overflow, no dead white space.
Also a public presentation page at /vorstellung (no login, nothing operable),
whose sample sheet is computed by the real packer rather than drawn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
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.
Print now offers every format the AI pipeline knows (9x13 to 60x90, DIN A6-A2,
squares, poster/frame sizes to 70x100, and the screen ratios as physical sizes).
When a picture does not match the target ratio the user picks per image between
cropping to fill and keeping the whole picture on a border colour - never a
stretch. The module is called Passbilder now; /druck redirects.
Fixes two real defects: sharp runs extend after resize, so padded cells came out
oversized (a 35x45 mm cell became 35x171 mm with a border), and the grid packer
rotated a single portrait photo just because more would fit sideways.
Mobile: cards become rows, touch targets ~40px, crop editor as a bottom sheet.
Verified at 390/820/1440px - all three produce the same PDF.
The tsx postinstall pulls its own esbuild binary, which fails inside the
Docker build ("Expected 0.28.2 but got 0.27.7") and broke the deploy.
Tests now run on node --experimental-strip-types, so the image installs
nothing extra.
Pure geometry, no I/O: DIN A6-A2 incl. A3+ (329x483), photo-paper cuts,
US Letter/Legal, biometric and small photo formats, plus a forgiving free
size parser (12x15 cm, 35x45mm, 5, 4:3/15). Packing: exact grid for uniform
sizes, MaxRects with one fixed orientation per format for mixed sets, so
small prints fill the space next to a large one. Crop marks as line segments
(corner marks outside the trim box; continuous guides that never cross a
motif). Covered by tests/printlayout.test.ts.