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
The crop frame had width:100% together with max-height, so the height was
clamped while the width stayed - the frame lost the target aspect ratio and
object-fit:fill stretched the picture into it. Measured at 390px: frame 0.843
instead of 0.778, picture 8.3% too wide. The width now follows from the allowed
height and the ratio, and the image carries its own aspect-ratio instead of a
second percentage, so it cannot stretch at all.
Dragging was fine but felt dead: at fill zoom the crop sits at the stop in one
axis. The editor now states which way it can move, double-tap zooms, arrow keys
nudge, and the zoom slider shows its factor.
Sizes: 41 -> 86 in ten groups, including US inch sizes, instant-film picture
areas, cards, DIN A0-A7 and eleven aspect ratios as physical sizes.
The tab is called Drucken now; /druck and /passbilder redirect there.
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.
Seeds the four sheet setups Till actually prints (Kita set, small school set,
full passport sheet, two 10x7,5 on 10x15 paper). A multi-format template
applied to a single image clones it into every format, so one click yields the
whole set. Margin gets one-click borderless / 5 mm / 10 mm profiles, and the
finished PDF can be pushed to Picdrop, the NAS or any extra target.
Telegram gains a fully button-driven print flow (photo -> size -> count ->
A4 PDF with corner marks), running inline without the queue or a model. MCP
gains exact_size and print_sheet so an assistant can produce print-ready files
from a local folder. Keeps the rule that every feature works on all three ways in.
New Druck tab: upload or pick from the library, choose a target size and copy
count per image, pan/zoom crop on a locked aspect ratio, and a live preview
that renders the same layout the PDF will contain. Sheet controls for paper,
margin, gap, bleed, crop marks, dpi and templates.
POST /api/print/single (image at an exact size, dpi headers incl. a real-dpi
warning), GET /api/print/single as a mm-to-px calculator, POST /api/print/sheet
(multi-image sheet as PDF, limits on cells/copies/pages) and print_presets for
reusable sheet setups.
renderCell brings a relative crop onto an exact physical size; missing bleed
pixels are copied from the edge instead of stretching the motif. Without a
crop it falls back to a centred cover crop, never a distorted fill.
buildSheetPdf writes a PDF page that is exactly the sheet size in mm with
hairline crop marks and an optional footer. Source resolution guards uploads
to the sources/ prefix and reuses the library visibility rules.
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.
- delivery.ts: upload the stored result in its chosen format (output_ext,
global or per-recipe) instead of always converting non-alpha to JPEG.
- jobs POST froze the snapshot without output_ext -> per-conversion/per-recipe
format choice was silently dropped. Now carried through.
- telegram.ts: recipe snapshots carry output_ext + delivery_target_id
(custom formats already flow via output_format).
- mcp/klarbild-mcp.mjs: process_images gains tasks, output_ext, orientation,
crop_mode, contour_mm, picdrop_gallery; job_status shows finished_at + errors.
- Docs: llms.txt, mcp/README, changelog updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
- /llms.txt: public machine-readable capability/API/MCP doc for agents.
- Job delete action + queue button for terminal jobs; cleans objects.
- process.ts: clear 'Quelle nicht mehr vorhanden' message instead of raw
EISDIR/ENOENT when a source was purged.
- Docs corrected: Picdrop 'missing images' was web-UI sorting, not PNG;
JPG delivery kept as size/perf improvement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
- format.ts parses free formats from the key: sticker<N> -> N×N cm,
<W>x<H> -> W×H cm. Fixes 'Unbekanntes Format: sticker5' (the seeded
'Sticker 5 cm' recipe was unusable) and lets admins define ANY format
without a migration. Guarded to <=300 cm.
- Output file format PNG/JPG: global default (settings.output_ext) plus
per-recipe/per-conversion override. JPG only for non-alpha; cutouts
stay PNG. file.ts sniffs content-type from magic bytes.
- Jobs can now be 'failed' (all items failed) -> red in the queue; add
finished time to queue list + detail. migration 012.
- Studio: custom-measure input + Dateiformat selector; Admin: global
Standard-Dateiformat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
Word-boundary (\b) anchors don't fire next to underscores, so
already-processed names like 2026-07-24_2026-07-24-<uuid>_30x40 kept
their doubled date prefix. Drop \b; match dates/hex globally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
- Picdrop is a photo-proofing tool that expects handy JPGs; non-alpha
results are now delivered as JPEG q92 (4:4:4) instead of 12-32 MB PNGs.
Cutouts with alpha stay PNG. Fixes images not showing in POSTER LEA.
- buildResultFilename strips doubled dates/UUIDs/hex to avoid messy names.
- picdrop-ls gains action:cleanup_tmp; Admin 'Reste aufräumen' button
removes leftover .tmp- files from the default gallery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
- studio: quality/model selector (friendly names from active models); library shows model used
- seed 2 models (Schnell/Beste Qualitaet); GET /api/models for studio+library
- filename: YYYY-MM-DD_motif_format.png (was redundant slug, no date)
- picdrop: direct upload to final name (fix .tmp- leftover from failed rename on picdrop); cleanupTmp helper
- configurable default gallery (migration 003, settings + admin field); base_path=SFTP root, gallery=subfolder
- responsive nav: compact sticky topbar + bottom tab bar on mobile (SVG icons, safe-area insets), inline top nav on desktop; no more horizontal overflow
- previews show full aspect ratio (object-fit contain) on subtle checker bg for transparency (library, studio, queue)
- 16px inputs on mobile (no iOS zoom), tactile :active feedback, larger touch targets
- keeps Klarbild identity (Space Grotesk/Mono, blue, paper, regmark), no glows/gradients