Compare commits

..
Author SHA1 Message Date
till 2c2ef9d458 Astro 7: die AVIF-Lücke in sharp/Astro 5 schließen
GHSA-26w7-cxv4-gfx2 (kritisch, RCE beim Verarbeiten von AVIF) hat für
Astro 5 keinen Fix. Also hoch auf Astro 7.3.2, @astrojs/node 11,
@astrojs/react 6 und sharp 0.35. `npm audit`: 0 Funde.

Zwei Stellen mussten mit:

- `experimental.csp` heißt seit Astro 7 `security.csp`. Das ist keine
  kosmetische Umbenennung: ein `csp` auf oberster Ebene wird ohne Warnung
  geschluckt und die Seite läuft ganz ohne Policy. Gemessen — Header
  vorher weg, nach der Korrektur wieder da, mit denselben Skript-Hashes
  wie unter Astro 5.
- sharp exportiert den Typ `Sharp` jetzt benannt statt als Namensraum.

Dazu das Image: node 20 → 22 (Astro 7 verlangt mehr als 20.19, die Tests
brauchen 22.6 für --experimental-strip-types), und `npm ci` mit Lockfile
statt `npm install` — sonst baut das Image nicht die Versionen, die
getestet wurden.

Geprüft gegen echtes Postgres: alle Seiten 200, Upload durch sharp 0.35,
Bogen-PDF auf 210,00 × 297,00 mm gemessen, Einzelbild 35 × 45 mm bei
300 dpi = 413 × 531 px mit korrekter dpi-Angabe. 124 Tests grün.
2026-09-15 20:14:10 +02:00
tillandClaude Opus 5 5ba448f250 Changelog-Seite: eindeutige Dateinamen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 15:45:03 +00:00
tillandClaude Opus 5 545f9ae909 Bibliothek: eindeutige Namen auch für Auswahl-ZIPs
Dieselbe Schwäche eine Ebene weiter: Jedes ZIP aus der Bibliothek hieß
`klarbild_2026-09-01.zip`, zwei verschiedene Auswahlen am selben Tag im selben
Ordner überschrieben sich also. Jetzt
`2026-09-01_143207_auswahl-12-bilder_a3f9.zip`, und der Client liest den Namen
aus dem Kopf statt einen eigenen zu bauen.

124 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 15:39:39 +00:00
tillandClaude Opus 5 a87587c273 Drucken: eindeutige Dateinamen für PDFs und Einzelbilder
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
2026-09-01 15:31:23 +00:00
tillandClaude Opus 5 841ff41076 Changelog-Seite: Eintrag für Bibliothekswahl und Druckverlauf
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 14:34:57 +00:00
tillandClaude Opus 5 76aa4cc071 Changelog und Hilfeseite für Bibliothekswahl und Druckverlauf
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 14:23:38 +00:00
tillandClaude Opus 5 825dcc4444 Drucken: Kartenziffern auch auf schmalen Geräten
Sie waren ausgeblendet, weil die rechte Spalte dort nach oben gezogen wurde
und die Reihenfolge nicht mehr 1-2-3-4 war. Seit die Karten in ihrer eigenen
Nummerierung laufen, stimmt sie wieder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 14:19:55 +00:00
tillandClaude Opus 5 15634f38bb Drucken: Bibliothek als Quelle, Verlauf der Drucke, Mailzugang aus der Umgebung
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
2026-09-01 14:17:15 +00:00
tillandClaude Opus 5 ef2e945080 docs: download-all in changelog, help page, README and llms.txt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 04:05:47 +00:00
tillandClaude Opus 5 7d53962543 feat: download all images of a share link as one ZIP
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
2026-09-01 04:05:03 +00:00
tillandClaude Opus 5 5a438e2c19 docs: sharing, mail and provenance in changelog, README and llms.txt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 03:43:04 +00:00
tillandClaude Opus 5 14dae8e550 fix: design critique findings, and the settings card that saved nothing
The critique found a functional bug I had shipped an hour earlier: the whole
e-mail configuration was never sent. `saveSettings` builds its body from a fixed
field list, and none of the smtp_* fields or share_default_days were in it, and
the password input had no state at all. Filling in the card and pressing save
lost everything while the toast said "Gespeichert." That card is the
precondition for password reset, job notifications and mailing share links, so
it would have looked like the whole feature was broken. Now sent, with the
password held separately like the other secrets, and a failed PATCH no longer
reports success.

The public page told recipients how to bypass "view only": the hint about
long-pressing to save was printed regardless of allow_download, two lines under
a caption saying "Nur ansehen". The backend takes that setting seriously enough
to withhold the original even for cutouts; the page undid it. Now the two cases
have separate copy, worded for Android as well as iOS, and the hint sits above
the grid where it is actually read rather than under twenty tiles.

The share dialog had two blue buttons in step two, the exact mistake DESIGN.md
records as having happened once before, and asked five questions before a link
existed when the common case ("one picture for grandma") needs none of them.
One blue button now, and the rarely-used settings collapse behind a summary
that states what currently applies.

/konto was unreachable on a phone: the only way in was the username in the top
bar, which is hidden below 480px. It is a tab bar entry now, with short labels
so eight items still fit at 390px (49x48px each).

Network failures rendered as content: a failed load showed "nothing shared yet"
alongside an invitation to share something, and /konto and /admin hung on
"loading" forever. All three have an error state with a retry now.

Also: the public page nested a second <main> inside the app shell and inherited
padding for a tab bar anonymous visitors never see; admin inputs were 14px, so
iOS zoomed on focus at tablet width; the admin had no media queries at all, so
"Postausgangsserver" got ~156px on a phone; "Jetzt umbenennen" was clickable
without the dry run PRODUCT.md requires; share_default_days did not reach the
dialog; partial mail success was coloured as failure by a regex on the message
text; the passphrase sheet ignored the home indicator; the privacy line claimed
"encrypted" for what is a salted hash.

Added PRODUCT.md and DESIGN.md, written from the code and the wiki rather than
invented, so future design work has something to be measured against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 03:41:10 +00:00
tillandClaude Opus 5 1d78535662 fix: findings from the code review of the mail and sharing work
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
2026-09-01 03:25:02 +00:00
tillandClaude Opus 5 63d4b7d894 feat: e-mail, password reset and shareable links for images and collections
Mail goes out through the account's own mailbox (SMTP) rather than a sending
service: the domain lives there anyway, so SPF and DKIM line up by themselves
and no third party gets to see the messages. Credentials are AES-256-GCM
encrypted in settings like every other secret, never in the repo. nodemailer
has zero dependencies and no native postinstall -- checked before adding it,
given the history.

Password reset: single-use token, only its hash is stored, one hour, and the
request endpoint answers identically whether or not the address exists so it
cannot be used to enumerate accounts. Rate limited per address.

Share links (/s/<slug>) let someone without an account see the pictures and
download them at full resolution. These are photos of children, so the
defaults lean safe: every link expires by itself (30 days), can be revoked,
optionally carries a passphrase, and the page and files are noindex. Access is
bound to the link, not the image -- swapping in another item id on a valid link
returns 404. Private-session results can never be shared at all.

Tracking records when and with what, never who: IP addresses are only stored
salted-and-hashed, so repeat visitors can be counted but nobody identified.

Sharing goes out by mail (several recipients at once, no attachment -- a link
can be withdrawn later, an attachment cannot) or via wa.me, which opens
WhatsApp with the text prepared. The WhatsApp Business API would need a Meta
account, approved templates and per-conversation fees; this works today for
free and the API can slot in behind it later.

Verified end to end against a local SMTP server: reset issues and consumes its
token exactly once, the old password stops working, an unknown address gives
the same answer, share mail reaches several recipients. Link behaviour checked
for expiry (410), revocation (410), wrong passphrase (401) and download lock
(403). Outbound SMTP is blocked in the sandbox, so the real mailbox still needs
one test from the live server.

Also fixes a regression this introduced: the seventh nav item pushed the logout
button off screen at 820px. The bar now shrinks and scrolls, logout never does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 03:02:33 +00:00
tillandClaude Opus 5 6ae6e0348c 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
2026-09-01 02:44:45 +00:00
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
tillandClaude Opus 5 f8d247fcce docs: unique filenames in changelog, help page, README and llms.txt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 02:17:17 +00:00
tillandClaude Opus 5 f695554e5d fix: unique, chronologically sortable result filenames
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
2026-09-01 02:15:29 +00:00
tillandClaude Opus 5 5ebe8026e0 feat: oversize sheets, image duplication, free labelling and a new layout
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
2026-08-20 10:55:11 +00:00
till aeea706eb6 fix: keep playwright out of package.json
It slipped in from a local UI check. The Docker build installs devDependencies,
and playwright's postinstall would try to download browsers there - the same
class of failure as the tsx/esbuild one.
2026-08-19 07:50:59 +00:00
till 210a84dcf8 feat: rename to Drucken, 86 image sizes, fix the distorted crop editor
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.
2026-08-19 07:50:44 +00:00
till 7289cde03a docs: changelog for the code-review fixes 2026-08-18 07:50:30 +00:00
till e87c61435c fix: code-review findings in the print module
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.
2026-08-18 07:50:11 +00:00
till 7d42782c41 feat: all AI formats printable, crop-vs-border rule, mobile layout, Passbilder rename
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.
2026-08-18 07:04:08 +00:00
till f6b81a0f5e fix: drop tsx dev dependency, run tests with node's native type stripping
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.
2026-08-18 06:45:28 +00:00
till 606a78bef0 feat: print module — exact sizes, print sheets and crop marks without AI 2026-08-18 06:40:55 +00:00
83 changed files with 9779 additions and 4454 deletions

No files matched your search

+296
View File
@@ -2,6 +2,302 @@
All notable changes to Klarbild are documented here. Newest first.
## 2026-09-01 (later) — Unique names for print files
### Fixed
- **Two print PDFs from the same day had the same name** — `2026-09-01_druckbogen.pdf`,
every time. The browser appended "(1)"; anyone filing them in the same folder or on the
NAS lost the first one. Exactly the defect fixed for library images in August, still
sitting in the print module. Print files now follow the same rule as everything else:
`2026-09-01_143207_druck-9-teile_a4_4eca.pdf` — date, time to the second, what is on the
sheet, the paper, and a short tag. **The tag is the id of the history entry**, so a PDF
can be traced back to its run under "Last printed".
- Single images carry the motif of their source: `2026-09-01_143207_lea-badge_130x180_25b3.jpg`
instead of `2026-09-01_1318.jpg`.
- The browser no longer invents its own name. The server sends one and the client reads it
from `Content-Disposition` — there were two truths, and the client's was the worse one.
Names read from that header are stripped of anything path-like before use.
## 2026-09-01 (later) — Printing from the library, and a print history
### Added
- **Pick images from the library** in the print area. Until now a file had to be
uploaded even when Klarbild already had it — a studio result went to disk and back
through the uploader. The picker has search (filename and description), a folder
filter and multi-select, reachable from the empty tray, from the card header and from
each cell group. Only finished, visible images are offered, under the same rules as
the library itself.
- **Last printed** (card 4 on `/drucken`): what was printed recently, with a button to
produce the exact same sheet again. **Only the instruction is stored, never the PDF** —
an A3 sheet runs to 20–80 MB, an entry to 1–3 KB. That works because the print module
computes without a model: the same job yields the same sheet. Verified — two runs of
one configuration produced PDFs differing *only* in `CreationDate` and `ModDate`; the
image streams are byte-identical. Repeat is offered only when every cell came from the
library and every image still exists; otherwise the entry says which of the two reasons
applies. The last 100 runs per account are kept, older ones drop off by themselves.
You see your own runs; admins see all.
- **Mail access from the server environment.** Sending now falls back to `SMTP_*`
environment variables when nothing is entered under Admin → E-mail, so a fresh install
can send straight away — and with it, password reset works. Credentials live in Coolify,
never in the repo. The admin card says which source is actually in effect.
### Fixed
- The run summary listed every cell separately: "10 × 15 cm, 10 × 15 cm". Identical sizes
are now merged and their counts added. The first attempt turned "13 × 18 cm" into
"13 cm ×18" — the × inside a measurement looked like a count. Anchored to the end of the
line now, with seven tests holding it there.
- "1 image is missing" for a sheet whose source was simply an upload: one counter was
serving two different causes. Two counters, two explanations.
- "the PDF was 0.0 MB" for a 20 KB sheet — below a megabyte it now counts in KB.
- A file missing from disk showed the browser's broken-image icon in the picker and the
history. The tile stays quiet now, and still clickable.
- On narrow screens the right-hand column was pulled to the top (`order: -1`, so the
preview is visible straight away). With a fourth card you would have scrolled past a
whole block before reaching the upload area. The cards now run in their own numbering —
and the numbers in the card headers are visible again, because they are correct again.
## 2026-09-01 — Sharing, e-mail, password reset, and a security pass
### Added
- **Share links** (`/s/<slug>`) for a single image or a collection: someone without an
account can view them and download at full resolution. These are photos of children,
so the defaults lean safe — every link expires by itself (30 days, configurable), can
be revoked and un-revoked, optionally carries a passphrase, download can be switched
off entirely, and pages and files are noindex. Access is bound to the link, not the
image: swapping in another item id on a valid link returns 404. Private-session
results can never be shared.
- **Download everything at once**: a share with several pictures gets one button above the
grid for a ZIP at full resolution. Written to stream, not buffer -- one file at a time,
waiting for archiver's `entry` before reading the next. Measured: 440 MB of images, RSS
peaked at 196 MB against a 120 MB baseline. Capped at 200 files and 500 MB, past which
the archive closes with a note saying what happened. Respects the download lock and the
passphrase; rate limited per link and address.
- **Passing it on** by e-mail (several recipients, no attachment — a link can be
withdrawn later, an attachment cannot) or via `wa.me`, which opens WhatsApp with the
text prepared. The WhatsApp Business API needs a Meta account, approved templates and
per-conversation fees; this works today for free and the API can slot in behind it.
- **E-mail through the account's own mailbox** (SMTP, credentials AES-256-GCM encrypted
like every other secret). Powers password reset, optional job-done notifications, and
mailing share links. nodemailer has zero dependencies and no native postinstall —
checked before adding it, given the history.
- **Password reset**: single-use token, only its hash stored, one hour, identical
response whether or not the address exists, rate limited per address and per sender.
- **`/konto`** for the e-mail address and notifications, **`/teilen`** for managing links
with view and download counts.
- **IPTC/XMP provenance** on AI results (`trainedAlgorithmicMedia`, or
`compositeWithTrainedAlgorithmicMedia` for edited photos) plus the model, written by
hand into PNG iTXt and JPEG APP1 rather than pulling in a sharp upgrade. exiftool reads
it back from both formats; pixels, dimensions and dpi provably untouched.
- **CSP with real hashes** for every inline script, plus nosniff, referrer-policy,
X-Frame-Options and a permissions-policy. `style-src` stays open because React sets
styles as attributes and attributes cannot be hashed — measured, the hash-only policy
breaks the print preview.
- `mem_limit: 3g` in compose. sharp can spike; without a limit an outlier takes the whole
container down instead of just the process.
### Fixed
- **Sessions never expired server-side and survived a password reset.** `Max-Age` is a
request to the browser, not a rule for the server. 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. Cookies now carry a session epoch, checked against the database on every
request, and the epoch is bumped on password reset and on any role change.
- **"Allow download: off" did nothing for images with transparency** — exactly the
cutouts and stickers this tool exists for. Verified: now 1500x2000 instead of the
2400x3200 original, and the download route returns 403.
- **The e-mail settings card saved nothing** while reporting success. Found by the design
critique an hour after shipping.
- **The public page explained how to bypass "view only"**, printing the long-press hint
regardless of the setting.
- The share title went into outgoing mail HTML unescaped while the message was escaped.
- argon2 ran on every request for protected links, including every image (130 ms, 64 MiB
each). Checked once now, then a short-lived signed cookie scoped to that link. The
passphrase moved from the query string to POST.
- `days` accepted nonsense and quietly produced an unlimited link (`Number('abc')` is
NaN, `NaN > 0` is false; `[]` coerces to 0).
- Rate limits keyed on the *first* X-Forwarded-For value, which the caller sets. Counting
from the right now, trusted-proxy count configurable.
- **The path-escape hole in delivery** the wiki had flagged twice as open.
- The crop maths lived twice with nothing holding the two copies together. One module
now, 60000 randomised cases asserting both entry points agree — which immediately
found a real bug: the zoom limits also clamped the *initial* crop, so an extreme format
silently broke the aspect ratio.
- Smaller: expired links could still be mailed; missing PUBLIC_BASE_URL sent dead links
while reporting success; the reset response time revealed whether an account existed;
`share_views` grew without bound; double provenance marking appended a second packet
with the older model; the colour flag covered the selection circle in the library; the
seventh nav item pushed logout off screen at 820px; `/konto` was unreachable on a
phone; network failures rendered as empty states.
### Changed
- 42 new tests (97 total). `shares`, `mail` and `provenance` had none, and that is where
both reviews found the most.
- `PRODUCT.md` and `DESIGN.md` added, written from the code and the wiki.
## 2026-08-21 — Unique, chronologically sortable filenames
### Fixed
- **Pictures overwrote each other at Picdrop.** Two badges of the same person in the
same format on the same day both came out as `2026-07-23_lea_the-frame.png`, so the
second upload silently replaced the first. Reported from real use — Till lost images
this way. The name now carries the time down to the second plus a short tag derived
from the item id:
2026-08-20_143207_lea-badge_the-frame_a3f9.jpg
Two pictures can no longer collide: the tag comes from the item id, which is unique.
A belt-and-braces loop appends `-2`, `-3` … if a name is somehow still taken —
deliberately a code loop rather than a UNIQUE constraint, because a rejected write
would fail an already-paid AI job at the very last step.
### Added
- **Backfill for the existing library** (Admin → Dateinamen). Preview first — it reports
how many names are currently duplicated — then apply. It re-stamps from `created_at`
and the item id while keeping the motif and format already chosen, so it is idempotent:
a second run changes nothing. Stored files are untouched; they live under their id,
not their name.
- **Re-deliver everything** under the new name, for copies already sitting at Picdrop.
The old files stay where they are — deleting a customer's files automatically is the
wrong reflex; that stays a manual decision.
### Changed
- Timestamps use **Europe/Berlin** (override with `KLARBILD_TZ`) rather than UTC. A
picture taken at 00:30 should not be dated to the previous day.
- Naming moved into `src/lib/naming.ts` — dependency-free, like `printlayout.ts`, so the
rules are testable and identical in every path (jobs, Telegram, MCP, backfill).
The generator strips its own stamp when a result is fed back in as a source, so
re-processing cannot accumulate timestamps.
- 17 new tests (56 total), including the reported bug as an explicit regression test and
a check that lexicographic sorting equals chronological sorting across a DST boundary.
## 2026-08-20 — Oversize sheets, duplicate images, free labelling, new layout
### Added
- **Prints larger than the sheet.** The everyday case is 20 × 30 cm on A4 photo paper, where
exactly 3 mm are missing. Instead of refusing, each cell now offers two ways out
(`src/lib/printoversize.ts`, dependency-free like `printlayout.ts` so browser and server compute
the same thing):
- `overflow` centres the piece on the **whole sheet**, not the printable area — otherwise you
throw away the margin a borderless printer can actually use. How much is lost per side is
computed up front and shown before printing.
- `tile` splits the piece across several sheets. Tiles overlap by a glue fold (10 mm by default),
printed on both neighbours and marked with a dashed line. Each sheet carries its number, row
and column. 30 × 40 cm on A4 → 2 × 2 sheets.
Verified by rasterising the PDFs at 254 dpi: the overflow case measures 200,1 mm wide at x = 5 mm
and bleeds off both long edges; the tiles measure 200 × 286 and 110 × 124 mm, overlapping by 10.
- **Duplicate an image.** The same picture can now appear as several cells and be printed in two
different sizes on one sheet. Previously you had to copy the file, rename it and upload it again.
- **Configurable label.** The footer line can be switched off, given custom text, and placed at one
of six positions. It is only drawn if that edge is actually free — it never lands on a picture.
- **Public presentation page** at `/vorstellung` (no login, nothing operable). The sample sheet on it
is not a drawing: it runs the real packer at render time, so it follows the algorithm.
### Fixed
- **The tile number overlapped the picture.** The sheet number is printed at 8 pt from 3 mm off the
top edge, which needs about 6 mm — with a 5 mm paper margin it ran 0,8 mm into the motif.
`noteInset()`/`tileSheet()` now shift the tile down by the shortfall and shorten the sheet by the
same amount, so nothing sticks out at the bottom. Both the browser preview and the PDF call the
same helper, so the previewed sheet count cannot drift from the printed one.
### Changed
- **Desktop layout.** Work on the left, preview on the right, and the preview column is sticky so it
follows along — no more endless column next to an empty one. Setting groups sit collapsed side by
side in a two-column grid and expand across the full width, so three-way switches keep their
labels instead of being squeezed to 50 px.
- **Mobile layout.** Preview first, "Druck-PDF erzeugen" as a fixed bar above the tab navigation,
each image row showing thumbnail and controls side by side, touch targets at 38–40 px, and the
remove button moved onto the thumbnail so it stops covering the format dropdown. The step numbers
("1 ·", "2 ·") are hidden below 1080 px, where the preview comes first and the numbers would lie.
- Advanced setting groups (margins, quality, labelling, delivery) start collapsed — their summary
line already says what is set.
## 2026-08-18 (4) — Renamed to "Drucken", 86 sizes, crop editor fixed
### Fixed
- **The crop editor distorted the picture.** `.crop-box` had `width:100%` *and*
`max-height`, so the height was clamped while the width stayed put — the frame no
longer had the target aspect ratio, and `object-fit:fill` stretched the image into it.
Measured on a 390 px viewport: frame 0.843 instead of 0.778, picture **8.3 % too wide**.
The frame width is now derived from the allowed height and the ratio, and the image
carries its own `aspect-ratio` instead of a second percentage — it can no longer stretch,
even if the frame is ever off.
- **Dragging felt broken.** At "fill" zoom the crop sits exactly at the stop in one axis,
so pulling that way does nothing — correct, but invisible. The editor now says which way
the picture can move, double-click/double-tap zooms a step, arrow keys nudge (shift for
bigger steps), and the zoom slider shows its factor.
### Changed
- **Renamed to "Drucken"** — the tab, page, title and Telegram button. `/druck` and
`/passbilder` both redirect permanently to `/drucken`.
- **86 image sizes in ten groups** (was 41): passport formats per country, cm small sizes,
classic photo sizes, **US inch sizes** (wallet to 24 × 36), **instant-film picture areas**
(Instax mini/Square/Wide, Polaroid), poster and frame sizes up to 100 × 140, squares,
**DIN A0–A7 incl. A3+**, cards (business card, postcard, DIN lang, cheque card) and
aspect ratios as physical sizes (16:9, 9:16, 21:9, 2:1, 3:2, 2:3, 4:3, 3:4, 5:4, 4:5, 1:1).
- Paper list grew to 19: DIN A1, square photo paper, and the US inch cuts.
## 2026-08-18 (3) — Code review: fixes
### Fixed
- **EXIF orientation was ignored.** Phone photos carry their rotation as metadata only.
sharp cropped the unrotated raster, so a portrait shot came out of the printer sideways
and with the wrong framing. Orientation is now applied before any geometry, and
`/api/uploads` reports oriented dimensions so the crop editor agrees with the render.
- **Memory blow-up on "Rand lassen".** The padded image was materialised at source
resolution before the resize. A panorama into a narrow contain target built a ~960 MB
intermediate and then failed outright; it is one extract→resize→extend chain now
(sharp's own order) — 90 ms and a few MB.
- **`bleedMm` was unbounded** in `/api/print/single` (the sheet endpoint clamped it).
- **Delivery gallery could escape the target's base folder** — `posixpath.join` happily
resolves `../..`, and the folder is created before upload. Names are validated now.
- **Denial of service:** sheet requests are capped at 500 pieces and the packer has a
step budget, so a degenerate request can no longer block the single-threaded server.
- **Print presets:** delete only your own (admins all), config size and count limits,
and `by_name` honours `anonymous_generations`.
- **Telegram callbacks** now require an active pairing, like every other path.
- **Error responses** no longer leak storage paths or delivery hostnames.
- `allowRotate: undefined` meant "no rotation" in one place and "rotation allowed" in
two others — a picture that only fits rotated was reported as unplaceable.
- The many-formats shortcut dropped a format that only fits rotated.
- `unplaced` blamed the first format instead of the one actually missing.
- Corner marks could land inside the printed bleed; the offset is raised to clear it.
- `capacity()` silently capped at 200.
- Image keys could collide with a cell literally named `x::rot`.
- `labelMm` rounded away real decimals (11,25 cm became 11,3); `parseSizeMm` ignored the
order in `a:b`, so `3:4/15` and `4:3/15` produced the same portrait size.
- The footer was drawn over the artwork when the margin was small.
- The UI now warns when corner marks do not fit the margin, and when continuous guides
are combined with mixed sizes (they cannot run through).
### Changed
- Tests grew from 21 to 31 — every finding above has a regression test.
## 2026-08-18 (2) — Passbildfunktion: all sizes, fit rules, mobile
### Added
- **Renamed to "Passbilder"** — the tab, page and Telegram button now say what it is.
`/druck` permanently redirects to `/passbilder`.
- **Every size the AI pipeline knows** is now printable too: 9×13 … 60×90, DIN A6–A2,
squares, plus poster/frame sizes up to 70×100 and the screen ratios (16:9 "The Frame",
9:16, 3:2, 4:3, 5:4) as physical measurements. 41 presets in seven groups.
- **Fit rule per image** — when the picture does not match the target ratio, choose
**Zuschneiden** (fill the format, crop the overflow — default) or **Rand lassen**
(keep the whole picture, pad with a border colour: white, black, paper or custom).
Nothing is ever distorted. Works in the crop editor, the sheet preview, the PDF,
`/api/print/*` (`fit`, `bg`) and the saved templates.
- **Mobile layout** — image cards become a row on phones, touch targets grow to ~40 px,
the crop editor turns into a full-width bottom sheet, no horizontal overflow.
Verified at 390 / 820 / 1440 px; all three produce the identical PDF.
### Fixed
- **Sheet cells could come out oversized.** sharp applies `extend` *after* `resize`, so
padding was added on top of the finished size — a 35×45 mm cell became 35×171 mm in
"Rand lassen" mode, and bleed near an image edge was off too. Crop and padding now run
in their own pass. Covered by `tests/printrender.test.ts`.
- **Orientation is no longer overridden.** A single portrait passport photo was laid down
sideways just because more would fit that way. Rotation now only happens when it
actually saves a sheet.
## 2026-08-18 — Print module: exact sizes, sheets & crop marks (no AI)
### Added
+96
View File
@@ -0,0 +1,96 @@
# Klarbild — Gestaltung
> Aus dem Code gelesen, nicht erfunden. Quelle der Wahrheit bleibt
> `src/styles/tokens.css`; diese Datei erklärt, **warum** die Werte so sind und wo sie
> hingehören.
## Haltung
Technisch-editorial. Das Vorbild ist der Andruckbogen, nicht das Dashboard: warmes
Papier, scharfe Ecken, feine Linien, eine Passermarke als Zeichen. Die Oberfläche soll
aussehen wie etwas, mit dem man **arbeitet** — nicht wie etwas, das verkauft werden will.
Farbstrategie: **restrained**. Getönte Neutrale tragen die Fläche, ein einziges kräftiges
Blau markiert das, was man anfassen soll. Der Akzent bleibt deutlich unter 10 % der
Fläche. Bewusst: Auf dem Bildschirm liegen ständig **Fotos**, und die sollen die
einzigen bunten Dinge sein.
## Theme
**Hell, ohne Alternative.** Die Szene: Till oder Lea am Küchentisch oder im Büro, Tageslicht,
ein Foto von Phoenix auf dem Schirm, gleich soll es auf Papier. Wer Bilder beurteilt, die
auf hellem Papier landen, braucht einen hellen Untergrund — auf Dunkel wirkt jedes Bild
heller und wärmer, als es gedruckt wird. Ein Dunkelmodus wäre hier kein Komfort, sondern
eine Fehlerquelle.
## Farben
Alle Werte in OKLCH, alle Neutralen zum Papierton hin getönt (Hue 95, Chroma 0.004–0.01).
Kein `#000`, kein `#fff` — nirgends.
| Rolle | Token | Verwendung |
|---|---|---|
| Papier | `--paper` | Seitengrund, vertiefte Flächen, Eingabefelder |
| Karte | `--card` | erhobene Flächen, Kopfleiste |
| Linie | `--line` | 1-px-Trennung; **nie** dicker als Zierde |
| Schrift | `--ink` / `--ink-soft` | Haupttext / Nebentext |
| Akzent | `--accent` | genau eine Sache je Ansicht: die Handlung, die weiterführt |
| Akzentgrund | `--accent-bg` | aktiver Zustand, 6 % Deckung |
| Zustände | `--ok` `--warn` `--err` | nur für Zustände, nie zur Zierde |
**Regel für den Akzent:** Pro Ansicht ein blauer Knopf. Steht ein zweiter daneben, ist
einer davon falsch eingestuft. (Genau dieser Fehler stand kurz im Teilen-Dialog —
„Per E-Mail schicken" und „Fertig" waren beide blau.)
## Typografie
- **Space Grotesk** für alles Gelesene, **Space Mono** für Marken, Maße, Kennungen.
Beide selbst gehostet (`@fontsource`) — keine Anfrage an einen fremden Server.
- `.mono-label`: 10 px, `letter-spacing: .18em`, Großbuchstaben, `--soft`. Das ist die
**einzige** Art, Abschnitte zu betiteln. Sie kommt aus der Druckvorstufe und hält die
Oberfläche ruhig, weil sie nie mit dem Inhalt konkurriert.
- Fließtext 15–16 px, Zeilenhöhe 1.55–1.6, Zeilenlänge höchstens ~68ch.
- Überschriften mit `letter-spacing: -0.02em`; Stufenverhältnis mindestens 1.25.
- **16 px in jedem Eingabefeld** — darunter zoomt iOS beim Fokus hinein. Steht als Regel
in `tokens.css` und gilt ohne Ausnahme.
## Form
- Radius **4 px** (`--radius`), innen 3 px. Nie mehr. Weiche Ecken erzählen „App-Store",
scharfe erzählen „Werkzeug".
- Schatten praktisch unsichtbar (`0 1px 2px` bei 5 % Deckung). Tiefe entsteht über
Fläche und Linie, nicht über Weichzeichner.
- **Glasoptik nirgends.** Einzige Ausnahme: der Hintergrund hinter einem Dialog, damit
klar ist, was gerade nicht anfassbar ist.
## Layout
- `--maxw: 1060px` für Textseiten. Arbeitsansichten dürfen breiter werden, wenn sie den
Platz auch nutzen.
- Karten gruppieren, sie dekorieren nicht. **Nie verschachtelt.**
- Umbruchpunkte, die tatsächlich benutzt werden: **1200** (Einstellungen zweispaltig),
**1080** (eine Spalte, Vorschau nach vorn), **768** (Kopfleiste → Tab-Leiste unten),
**720** (Bildkarten werden zu Zeilen), **640/560** (Dialoge werden zu Blättern von unten).
- Geprüft wird bei **390 / 820 / 1440 / 1680 px**. Kein waagerechtes Scrollen, nirgends.
## Bewegung
Sparsam. `transition` auf Farbe und Hintergrund (0.15 s), `transform: scale(0.97)` beim
Drücken (0.06 s). Kein Federn, kein Nachwippen. `prefers-reduced-motion` schaltet alles ab.
## Muster
- **Dialog**: mittig am Rechner, **Blatt von unten** am Handy. Fokus wird eingefangen,
Hintergrund festgehalten, Escape schließt.
- **Zustandsmarke**: Mono, 9–10 px, Großbuchstaben, gerundete Pille, getönter Grund.
Für „aktiv / abgeschaltet / abgelaufen / geschützt".
- **Leerer Zustand**: Passermarke, eine Überschrift, ein Satz, der sagt, was zu tun ist,
und ein Weg dorthin. Nie nur „Keine Einträge".
- **Gefahr**: Rot **nur** in der Schrift, nie als gefüllter Knopf. Ein rotes Rechteck lädt
zum Draufklicken ein; genau das will man beim Löschen nicht.
## Was hier nicht vorkommt
Farbverläufe. Farbige Seitenstreifen an Karten. Glasoptik als Deko. Verlaufstext.
Illustrationen. Emoji in der Oberfläche. Gleich große Icon-Karten in Reihe.
Em-Gedankenstriche in der Oberflächensprache.
+5 -3
View File
@@ -1,4 +1,4 @@
FROM node:20-bookworm-slim
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production PORT=4321 HOST=0.0.0.0
@@ -8,8 +8,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package.json ./
RUN npm install --no-audit --no-fund
# Lockfile zuerst: `npm ci` baut exakt die Versionen, die getestet wurden —
# nicht das, wozu die ^-Bereiche heute zufällig auflösen.
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
+75
View File
@@ -0,0 +1,75 @@
# Klarbild — Produktkontext
> Zusammengetragen aus dem, was da ist: `README.md`, `src/styles/tokens.css`, dem Wiki
> (Sammlung „Klarbild") und dem Verhalten der Anwendung. Keine Wunschbeschreibung.
**register:** product
## Was es ist
Eine selbst gehostete Bildwerkstatt für einen Ein-Personen-Digitalbetrieb mit Familie.
Zwei Hälften, die dieselbe Oberfläche teilen:
1. **Mit KI** — Screenshots und Fundstücke bereinigen, freistellen, aufs Format bringen,
kombinieren, neu erzeugen. Läuft über OpenRouter, kostet Geld, dauert.
2. **Ohne KI** — Drucken: Bilder millimetergenau aufs physische Maß bringen und mehrere
davon in 100-%-Größe mit Schnittmarken auf einen Bogen setzen. Reine Geometrie,
kein Modell, keine Kosten, kein Warten.
Fertige Bilder gehen an Picdrop (Kundenauslieferung), aufs NAS (zweite Sicherung) und
seit 08/2026 über **Teilen-Links** direkt an Menschen ohne Konto.
## Wer es benutzt
- **Till** — Betreiber, Admin, baut es selbst. Arbeitet am Rechner und am Handy, oft
zwischendurch. Erwartet, dass Maße stimmen und Dateien nicht verschwinden.
- **Lea** — nutzt es, ohne es warten zu wollen. Sagt Dinge wie „drucke das bitte in
zwanzig mal dreißig". Für sie muss der Weg vom Bild zum Ergebnis kurz und
selbsterklärend sein.
- **Empfänger von Teilen-Links** — Großeltern, Kunden. Bekommen einen Link per WhatsApp
oder Mail, haben **kein Konto**, sind oft am Handy, und sollen ohne Erklärung
verstehen, was zu tun ist. Können in Bezug auf Technik nichts vorausgesetzt bekommen.
Auf vielen Bildern sind **Kinder**. Das ist keine Randnotiz, sondern der Grund, warum
Voreinstellungen zur Vorsicht neigen.
## Ton
Deutsch, per Du, sachlich und knapp. Erklärt **warum**, nicht nur was. Keine Ausrufezeichen,
keine Jubelsprache, keine Emoji in der Oberfläche (Telegram ausgenommen, dort tragen sie
Funktion). Fehlermeldungen sagen, was zu tun ist, statt was kaputt ist.
Fachbegriffe werden benutzt, wo sie stimmen (Beschnittzugabe, Eckmarken, dpi) — und beim
ersten Auftreten in einem Halbsatz erklärt. Das Werkzeug spricht mit jemandem, der etwas
kann, aber nicht alles wissen muss.
## Anti-Referenzen
- **SaaS-Startseiten-Ästhetik.** Keine Farbverläufe, keine schwebenden Karten mit Schatten,
keine „Hero-Kennzahl" mit großer Zahl und kleinem Label, keine Kachelraster aus
Icon + Überschrift + drei Zeilen Text.
- **Adobe-Dichte.** Keine Werkzeugpaletten, keine verschachtelten Reiter, keine
Einstellung, die man nur mit Handbuch findet.
- **Verspieltes.** Keine abgerundeten Riesenradien, keine Illustrationen mit lachenden
Menschen, keine Konfetti-Animation nach dem Speichern.
- **Alles-ist-eine-Karte.** Karten nur, wo sie wirklich etwas gruppieren. Nie verschachtelt.
## Grundsätze
1. **Maße sind keine Meinung.** Wo etwas physisch wird, wird gerechnet und nachgemessen,
nicht geschätzt. Millimeter ist die führende Einheit; Pixel entstehen erst beim Rendern.
2. **Alle Wege können alles.** Web, Telegram und MCP sind gleichberechtigt. Eine neue
Funktion, die nur im Web geht, ist nicht fertig.
3. **Voreinstellungen neigen zur Vorsicht.** Teilen-Links laufen ab. Private Sitzungen
hinterlassen nichts. Beim Aufräumen fremder Dateien wird angehalten und gefragt.
4. **Sag, was passiert, bevor es passiert.** „Überstehen (−3 mm)" statt „geht nicht".
Probelauf vor der Umbenennung. Vorschau, die zeigt, was gedruckt wird.
5. **Fehler gehören ins Log, nicht ins Gesicht des Nutzers** — aber sie gehören
*irgendwohin*. Nichts still verschlucken.
6. **Es läuft auf einem Server.** Ein Node-Prozess, begrenzter Speicher. Teure Rechnung
braucht eine Obergrenze, bevor sie beginnt.
## Register-Begründung
Die Oberfläche **dient** der Arbeit, sie ist nicht das Produkt. Ausnahme: `/vorstellung`
ist eine Marketingseite und läuft im Brand-Register.
+53 -6
View File
@@ -32,25 +32,72 @@ Migrationen und Seeds laufen beim Serverstart automatisch. Erststart-Passwörter
- **Bibliothek:** Ordner, Farbmarkierungen, Prompt-Anzeige, Vorher/Nachher, Vollbild + „In Fotos sichern".
- **Rechte/Datenschutz:** Sichtbarkeit (eigene/alle), anonyme Generierungen, private Sessions, NSFW-Gate.
- **Telegram-Bot** (grammY-Webhook) als vollwertiger Website-Ersatz. **API-Token** (`Bearer`) für `/api/*`.
- **Druck (`/druck`, ohne KI):** Bilder exakt auf physische Maße bringen (interaktiver Zuschnitt mit
- **Drucken (`/drucken`, ohne KI):** Bilder exakt auf physische Maße bringen (interaktiver Zuschnitt mit
Zoom/Verschieben) und mehrere davon in 100-%-Größe mit **Schnittmarken** auf einen Bogen setzen —
Passbildsatz, Kita-Satz, Sticker. Papier DIN A6–A2 inkl. **A3+**, Fotopapier 9×13–20×30, Letter/Legal
oder freies Maß. Marken: Eckmarken (0,25 pt, außerhalb des Endformats) oder durchgehende Linien.
Beschnittzugabe 0–10 mm. Ausgabe: PDF in 1:1 (`pdf-lib`) bzw. Einzelbild mit dpi-Metadaten.
Layout: gleiche Größen → exaktes Raster, gemischte Größen → MaxRects-Packer.
Alle Formate der KI-Generierung sind auch hier wählbar; passt das Bild nicht zum Format,
entscheidet man je Bild zwischen **Zuschneiden** und **Rand lassen** (mit Randfarbe) — nie verzerren.
86 Bildmaße in 10 Gruppen (Passbild bis 100 × 140 cm), 19 Papierformate.
**Größer als der Bogen** (20 × 30 cm auf A4): je Zelle `oversize` = `fit` (melden) ·
`overflow` (mittig aufs ganze Blatt, Verlust wird vorab beziffert — hier genau 3 mm) ·
`tile` (Posterdruck auf mehrere Blätter mit gestricheltem Klebefalz und Blattnummer).
**Beschriftung** frei: aus, automatisch oder eigener Text an einer von sechs Stellen;
sie entfällt automatisch, wenn an der Kante kein Platz frei ist.
Dasselbe Bild darf mehrfach als Zelle vorkommen („Duplizieren"), um es in zwei Maßen zu drucken.
Kern: `src/lib/paper.ts` (Formate), `src/lib/printlayout.ts` (reine Mathematik, getestet),
`src/lib/printrender.ts` (sharp + pdf-lib). Auch per Telegram („📐 Druckbogen") und MCP
(`exact_size`, `print_sheet`).
- **Admin:** Key/Picdrop/Modelle/Nutzer/Kostendeckel/Presets/Speicherverwaltung, Picdrop-Diagnose.
`src/lib/printoversize.ts` (Überstand & Kachelung, getestet), `src/lib/printrender.ts`
(sharp + pdf-lib). Browser-Vorschau und Server-PDF benutzen denselben Rechenkern.
Auch per Telegram („🖨 Drucken") und MCP (`exact_size`, `print_sheet`).
Öffentliche Vorstellungsseite ohne Login: `/vorstellung`.
- **Dateinamen (`src/lib/naming.ts`, abhängigkeitsfrei):** `JJJJ-MM-TT_HHMMSS_<motiv>_<format>_<kürzel>.<ext>`,
z. B. `2026-08-20_143207_lea-badge_the-frame_a3f9.jpg`. Zeitstempel vorn → jede Dateiliste sortiert
chronologisch; Kürzel aus der Item-ID → zwei Bilder können sich beim Ausliefern **nicht mehr überschreiben**.
Zeitzone `KLARBILD_TZ` (Standard `Europe/Berlin`). Nachbenennung der Bibliothek und erneute Auslieferung
im Admin unter „Dateinamen" (`src/lib/renames.ts`), idempotent.
- **Teilen (`/s/<slug>`, öffentlich):** ein Bild oder eine Sammlung ohne Konto zugänglich machen —
ansehen und in voller Auflösung herunterladen. Voreinstellungen bewusst vorsichtig: **Ablauf**
(Standard 30 Tage), **widerrufbar**, optionales **Zugangswort** (argon2), **Download abschaltbar**,
`noindex`. Zugriff hängt am **Link**, nicht am Bild (fremde `item`-Kennung ⇒ 404); private Sitzungen
lassen sich nie teilen. Verwaltung unter `/teilen` mit Aufrufen und Downloads. Gezählt wird *wann*
und *womit*, nicht *wer*: IP nur gehasht, gesalzen mit Server-Geheimnis **und** Link-Kennung, eine
Zeile je Besucher und Stunde, Löschung nach 90 Tagen. **Alle Bilder als ZIP** über
`GET /api/s/<slug>/zip` — strömend, eine Datei nach der anderen (440 MB Bilder → 196 MB
Spitzenspeicher gemessen), gedeckelt bei 200 Dateien und 500 MB. Kern: `src/lib/shares.ts`.
- **Weitergeben:** per E-Mail aus Klarbild heraus (mehrere Empfänger, **kein Anhang** — ein Link lässt
sich zurückziehen), über das eigene Mailprogramm oder per `wa.me`-Link, der WhatsApp mit fertigem
Text öffnet. (Server-seitiger WhatsApp-Versand bräuchte die Business-API: Meta-Konto, genehmigte
Vorlagen, Gebühren je Konversation.)
- **E-Mail (`src/lib/mail.ts`):** SMTP über das **eigene Postfach** — SPF/DKIM stimmen dadurch von
selbst und kein Versanddienst sieht mit. Zugang AES-256-GCM in `settings`. Trägt Passwort-Reset
(Einmal-Token, nur Hash gespeichert, 60 min, gleiche Antwort für unbekannte Adressen),
Auftragshinweise (je Nutzer, `/konto`) und den Versand von Teilen-Links.
- **Sitzungen:** signiertes Cookie **plus** serverseitige Prüfung (`session_epoch`). Passwortwechsel und
Rollenänderung entwerten alle offenen Anmeldungen; die Rolle kommt aus der Datenbank, nicht aus dem
Cookie.
- **KI-Kennzeichnung (`src/lib/provenance.ts`):** IPTC `DigitalSourceType` als XMP direkt in der Datei
(`trainedAlgorithmicMedia` bzw. `compositeWithTrainedAlgorithmicMedia`) samt Modell — von Hand in
PNG-`iTXt` und JPEG-`APP1` geschrieben, ohne neue native Abhängigkeit. Mit exiftool prüfbar.
- **Sicherheits-Header:** CSP mit echten Hashes für jedes inline-Skript (`experimental.csp`), dazu
`nosniff`, `referrer-policy`, `X-Frame-Options`, `permissions-policy`. `style-src` bleibt offen, weil
React Stile als Attribute setzt und Attribute sich nicht hashen lassen.
- **Admin:** Key/Picdrop/Modelle/Nutzer/Kostendeckel/Presets/Speicherverwaltung, E-Mail, Teilen-Links,
Dateinamen, Picdrop-Diagnose.
## Datenmodell / Migrationen
Nummerierte SQL in `migrations/`, beim Start automatisch (kein ORM-Push). Wichtige Spalten:
`items.has_alpha`, `jobs.status` (inkl. `failed`), `jobs.finished_at`,
`settings.output_ext` / `recipes.output_ext`, `settings.*_metadata_sidecar`, `delivery_targets.*`,
`print_presets` (Bogen-Vorlagen, `013`).
`print_presets` (Bogen-Vorlagen, `013`), `shares`/`share_items`/`share_views` + `settings.smtp_*` +
`users.email`/`notify_jobs` (`014`), `users.session_epoch` (`015`).
## Tests
`npm test` — Node-Testrunner über `tsx`. Deckt die Druck-Layoutmathematik ab (Maß-Parsing,
`npm test` — Node-Testrunner mit nativem Type-Stripping (`node --experimental-strip-types`).
**Kein `tsx`, kein `playwright` in `package.json`** — der Docker-Build installiert devDependencies,
und deren native postinstall-Schritte haben den Deploy schon zweimal zerlegt. Testwerkzeug gehört
außerhalb des Projekts installiert. Deckt die Druck-Layoutmathematik ab (Maß-Parsing,
mm → px exakt, Überlappungsfreiheit, Ränder, Beschnittzugabe, Markengeometrie, Mehrseitigkeit).
## Stolpersteine & Lösungen (Learnings)
+21 -1
View File
@@ -9,7 +9,27 @@ export default defineConfig({
integrations: [react()],
// CSRF: eigener Schutz über SameSite=Lax-Session-Cookie. Astros checkOrigin
// bricht multipart-Uploads hinter dem Reverse-Proxy (http↔https-Mismatch).
security: { checkOrigin: false },
//
// Content-Security-Policy mit Hashes statt 'unsafe-inline': Astro rechnet die
// Hashes seiner eigenen inline-Skripte und -Styles beim Bauen aus. Die übrigen
// Direktiven ergänzt die Middleware (src/middleware.ts).
// Seit Astro 7 stabil und unter `security.csp` — nicht mehr unter `experimental`.
// Achtung: ein `csp` auf oberster Ebene wird stillschweigend ignoriert.
security: {
checkOrigin: false,
csp: {
directives: [
"default-src 'self'",
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
],
},
},
server: { host: true, port: Number(process.env.PORT) || 4321 },
vite: {
ssr: {
+14
View File
@@ -23,9 +23,23 @@ services:
SEED_LEA_PASSWORD: "${SEED_LEA_PASSWORD}"
PUBLIC_BASE_URL: "${PUBLIC_BASE_URL}"
N8N_WEBHOOK_URL: "${N8N_WEBHOOK_URL}"
# E-Mail-Versand. Die Datenbank hat Vorrang; diese Werte greifen, solange
# unter Admin → E-Mail nichts eingetragen ist. So läuft der Versand nach
# einem frischen Aufsetzen sofort, ohne dass jemand etwas abtippen muss.
SMTP_HOST: "${SMTP_HOST}"
SMTP_PORT: "${SMTP_PORT}"
SMTP_SECURE: "${SMTP_SECURE}"
SMTP_USER: "${SMTP_USER}"
SMTP_PASSWORD: "${SMTP_PASSWORD}"
SMTP_FROM: "${SMTP_FROM}"
depends_on:
- db
- minio
# Speichergrenze: sharp kann bei großen Bildern kurzzeitig viel Speicher
# ziehen. Ohne Grenze reißt ein Ausreißer den ganzen Container mit; mit
# Grenze stirbt nur der Node-Prozess, und der Healthcheck startet ihn neu.
mem_limit: 3g
mem_reservation: 512m
expose:
- "4321"
networks:
+1 -1
View File
@@ -1,4 +1,4 @@
-- Druckbogen-Vorlagen („Kita-Satz Felix", „8× Passbild") -------------------
-- Vorlagen für Passbilder/Druckbogen („Kita-Satz", „8× Passbild") ----------
CREATE TABLE IF NOT EXISTS print_presets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
+71
View File
@@ -0,0 +1,71 @@
-- E-Mail-Versand, Passwort-Rücksetzung und Teilen-Links -------------------
-- Alles vorwärts und idempotent, wie die übrigen Migrationen.
-- SMTP-Zugang. Das Passwort wird wie die übrigen Geheimnisse AES-256-GCM
-- verschlüsselt abgelegt (src/lib/crypto.ts) — nie im Klartext.
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_host text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_port integer NOT NULL DEFAULT 465;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_secure boolean NOT NULL DEFAULT true;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_user text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_password text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_from text;
-- Wie lange ein neuer Teilen-Link standardmäßig gilt (0 = unbegrenzt).
ALTER TABLE settings ADD COLUMN IF NOT EXISTS share_default_days integer NOT NULL DEFAULT 30;
-- E-Mail am Nutzerkonto: für „Passwort vergessen" und optionale Hinweise.
ALTER TABLE users ADD COLUMN IF NOT EXISTS email text;
ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_jobs boolean NOT NULL DEFAULT false;
CREATE UNIQUE INDEX IF NOT EXISTS users_email_idx ON users (lower(email)) WHERE email IS NOT NULL;
-- Passwort zurücksetzen. Gespeichert wird nur der **Hash** des Tokens — wer die
-- Datenbank liest, kann damit kein Konto übernehmen.
CREATE TABLE IF NOT EXISTS password_resets (
token_hash text PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS password_resets_user_idx ON password_resets(user_id);
-- Teilen-Links: ein Bild oder eine Sammlung, öffentlich erreichbar unter /s/<slug>.
CREATE TABLE IF NOT EXISTS shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text UNIQUE NOT NULL,
kind text NOT NULL DEFAULT 'item' CHECK (kind IN ('item','collection')),
title text,
note text,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
expires_at timestamptz,
revoked_at timestamptz,
allow_download boolean NOT NULL DEFAULT true,
-- Optionales Zugangswort (argon2), damit ein weitergeleiteter Link allein nicht reicht.
password_hash text,
views integer NOT NULL DEFAULT 0,
downloads integer NOT NULL DEFAULT 0,
last_seen_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS shares_created_by_idx ON shares(created_by);
CREATE TABLE IF NOT EXISTS share_items (
share_id uuid NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
item_id uuid NOT NULL REFERENCES items(id) ON DELETE CASCADE,
position integer NOT NULL DEFAULT 0,
PRIMARY KEY (share_id, item_id)
);
CREATE INDEX IF NOT EXISTS share_items_item_idx ON share_items(item_id);
-- Zugriffe je Link. Die IP wird nur **gehasht** abgelegt (mit dem Server-Geheimnis
-- gesalzen): Man kann damit wiederkehrende Besucher zählen, aber niemanden
-- identifizieren oder die Adresse zurückrechnen.
CREATE TABLE IF NOT EXISTS share_views (
id bigserial PRIMARY KEY,
share_id uuid NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
kind text NOT NULL DEFAULT 'view' CHECK (kind IN ('view','download')),
ip_hash text,
user_agent text,
at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS share_views_share_idx ON share_views(share_id, at DESC);
+37
View File
@@ -0,0 +1,37 @@
-- Sitzungen serverseitig entwertbar machen ---------------------------------
-- Bis hierher war das Sitzungs-Cookie nur HMAC-signiert: Wer es einmal hatte,
-- kam damit unbegrenzt hinein — auch nachdem das Passwort zurückgesetzt oder
-- die Rolle entzogen wurde. Genau der Vorgang, der ein übernommenes Konto
-- retten soll, half also nicht.
--
-- `session_epoch` wird bei jedem Passwortwechsel und jeder Rollenänderung
-- hochgezählt. Das Cookie trägt den Stand mit; stimmt er nicht mehr, gilt es
-- nicht mehr. Ein Zähler statt eines Zeitstempels, weil er keine Uhrzeit-
-- Ungenauigkeiten kennt.
ALTER TABLE users ADD COLUMN IF NOT EXISTS session_epoch integer NOT NULL DEFAULT 0;
-- Für die Namensvergabe: `renames.ts` fragt bei jedem fertigen Bild, ob ein
-- Name schon vergeben ist — ohne Index war das ein voller Tabellendurchlauf.
CREATE INDEX IF NOT EXISTS items_filename_idx ON items(filename);
-- Aufräumen: Beide Tabellen wuchsen unbegrenzt.
CREATE INDEX IF NOT EXISTS share_views_at_idx ON share_views(at);
CREATE INDEX IF NOT EXISTS password_resets_expires_idx ON password_resets(expires_at);
-- Zähler für verschiedene Besucher je Teilen-Link. Vorher wurde er bei jedem
-- Aufruf der Verwaltungsseite über `count(DISTINCT ip_hash)` aus share_views
-- gerechnet — das wurde mit wachsender Tabelle zäh und machte die Seite
-- angreifbar, weil jeder mit dem Link beliebig viele Zeilen erzeugen konnte.
ALTER TABLE shares ADD COLUMN IF NOT EXISTS besucher integer NOT NULL DEFAULT 0;
-- Nur eine Zeile je Link, Besucher und Stunde: Ohne diese Grenze konnte ein
-- Empfänger (oder ein <img>-Tag auf irgendeiner Seite) die Tabelle unbegrenzt
-- füllen und damit die Platte volllaufen lassen.
--
-- Der Stundenwert wird beim Schreiben mitgegeben statt im Index gerechnet:
-- `date_trunc` auf einem timestamptz hängt an der Zeitzonen-Einstellung und ist
-- damit nicht unveränderlich genug für einen Index.
ALTER TABLE share_views ADD COLUMN IF NOT EXISTS stunde text;
CREATE UNIQUE INDEX IF NOT EXISTS share_views_einmal_je_stunde_idx
ON share_views (share_id, kind, ip_hash, stunde)
WHERE ip_hash IS NOT NULL AND stunde IS NOT NULL;
+40
View File
@@ -0,0 +1,40 @@
-- Druck-Historie: was zuletzt gedruckt wurde ------------------------------
--
-- Gespeichert wird **nur die Anleitung**, nicht das Ergebnis: die
-- Bogen-Einstellung, die Verweise auf die Quellbilder und ein paar Eckdaten.
-- Ein A3-Druckbogen als PDF ist schnell 20–80 MB; bei ein paar Dutzend
-- Drucken wäre die Platte voll, und zwar mit Dateien, die sich in Sekunden
-- neu erzeugen lassen. Der Bogen wird beim erneuten Drucken einfach neu
-- gerechnet — deterministisch, aus denselben Quellen.
--
-- Zeilengröße: rund 1–3 KB je Eintrag. Tausend Drucke sind damit ein paar
-- Megabyte statt vieler Gigabyte.
CREATE TABLE IF NOT EXISTS print_runs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
-- Woher der Druck kam: 'web' | 'telegram' | 'mcp' | 'api'
origin text NOT NULL DEFAULT 'web',
-- Sprechende Kurzfassung für die Liste („13 × 18 cm ×1, 35 × 45 mm ×8").
summary text,
paper text, -- „DIN A4 · 210 × 297 mm"
pages integer NOT NULL DEFAULT 1,
pieces integer NOT NULL DEFAULT 1,
bytes integer, -- Größe des erzeugten PDFs, nur zur Information
-- Die vollständige Anfrage, aus der sich der Bogen neu erzeugen lässt.
-- Bewusst der Anfragekörper und nicht ein eigenes Format: Was hier steht,
-- lässt sich unverändert wieder an /api/print/sheet schicken.
config jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS print_runs_user_idx ON print_runs(created_by, created_at DESC);
CREATE INDEX IF NOT EXISTS print_runs_created_idx ON print_runs(created_at DESC);
-- Welche Bibliotheksbilder in einem Druck steckten. Nur für Bilder aus der
-- Bibliothek — hochgeladene Einzeldateien haben keine dauerhafte Kennung und
-- sind nach dem Aufräumen der Quellen ohnehin weg.
CREATE TABLE IF NOT EXISTS print_run_items (
run_id uuid NOT NULL REFERENCES print_runs(id) ON DELETE CASCADE,
item_id uuid NOT NULL REFERENCES items(id) ON DELETE CASCADE,
PRIMARY KEY (run_id, item_id)
);
CREATE INDEX IF NOT EXISTS print_run_items_item_idx ON print_run_items(item_id);
+1733 -3829
View File
File diff suppressed because it is too large. Load diff
+9 -6
View File
@@ -3,40 +3,43 @@
"type": "module",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.6"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"start": "node ./dist/server/entry.mjs",
"migrate": "node --loader tsx ./scripts/migrate.mjs",
"astro": "astro",
"test": "node --import tsx --test tests/*.test.ts"
"test": "node --experimental-strip-types --test tests/*.test.ts"
},
"dependencies": {
"@astrojs/node": "^9.1.3",
"@astrojs/react": "^4.2.1",
"@astrojs/node": "^11.1.5",
"@astrojs/react": "^6.0.5",
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/s3-request-presigner": "^3.700.0",
"@fontsource/space-grotesk": "^5.1.0",
"@fontsource/space-mono": "^5.1.0",
"archiver": "^7.0.1",
"argon2": "^0.41.1",
"astro": "^5.5.0",
"astro": "^7.3.2",
"basic-ftp": "^5.0.5",
"grammy": "^1.30.0",
"heic-convert": "^2.1.0",
"nodemailer": "^9.1.0",
"pdf-lib": "^1.17.1",
"pg": "^8.13.1",
"pg-boss": "^10.1.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sharp": "^0.33.5",
"sharp": "^0.35.4",
"ssh2-sftp-client": "^11.0.0"
},
"devDependencies": {
"@types/pg": "^8.11.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tsx": "^4.23.12",
"typescript": "^5.7.2"
}
}
+169 -3
View File
@@ -10,6 +10,9 @@ export default function AdminApp() {
const [toast, setToast] = useState<string | null>(null);
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState('');
const [storage, setStorage] = useState<any>(null);
const [rename, setRename] = useState<any>(null); // Probelauf/Ergebnis der Umbenennung
const [mailAn, setMailAn] = useState(''); // Adresse für die Testnachricht
const [smtpPw, setSmtpPw] = useState(''); // neues Postfach-Passwort (nie vorbelegt)
const [targets, setTargets] = useState<any[]>([]);
const [nt, setNt] = useState<any>({ protocol: 'sftp' });
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
@@ -84,12 +87,21 @@ export default function AdminApp() {
private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw,
nas_enabled: !!s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
nas_user: s.nas_user, nas_base_path: s.nas_base_path,
// E-Mail und Teilen — ohne diese Zeilen wurde die ganze Karte stillschweigend
// verworfen und die Oberfläche meldete trotzdem „Gespeichert."
smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure !== false,
smtp_user: s.smtp_user, smtp_from: s.smtp_from,
share_default_days: s.share_default_days,
};
if (orKey.trim()) body.openrouter_key = orKey.trim();
if (pdPw.trim()) body.picdrop_password = pdPw.trim();
if (nasPw.trim()) body.nas_password = nasPw.trim();
await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
setOrKey(''); setPdPw(''); setNasPw(''); notify('Gespeichert.'); load();
if (smtpPw.trim()) body.smtp_password = smtpPw.trim();
const r = await fetch('/api/admin/settings', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
if (r?.error) { notify(r.error); return; }
setOrKey(''); setPdPw(''); setNasPw(''); setSmtpPw(''); notify('Gespeichert.'); load();
};
const testPicdrop = async () => {
notify('Teste …');
@@ -129,6 +141,45 @@ export default function AdminApp() {
notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : r.made != null ? `${r.made} Vorschaubilder erzeugt.` : (r.error || 'OK'));
load();
};
const testeMail = async (mitVersand: boolean) => {
if (mitVersand && !mailAn.trim()) { notify('Bitte eine Adresse für die Testnachricht eintragen.'); return; }
notify(mitVersand ? 'Verschicke Testnachricht …' : 'Prüfe Verbindung …');
const r = await fetch('/api/admin/test-mail', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mitVersand ? { to: mailAn.trim() } : {}) }).then((x) => x.json());
notify(r.message || (r.ok ? 'OK' : 'Fehler'));
};
// Eindeutige Dateinamen — erst zeigen, was passieren würde, dann anwenden.
const renamePreview = async () => {
notify('Prüfe Dateinamen …');
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'rename_preview' }) }).then((x) => x.json());
if (r.error) { notify(r.error); return; }
setRename(r);
notify(r.umbenannt
? `${r.umbenannt} von ${r.geprueft} Bildern bekämen einen neuen Namen${r.vorherDoppelt ? ` — ${r.vorherDoppelt} Namen sind aktuell doppelt vergeben` : ''}.`
: `Alle ${r.geprueft} Namen sind bereits eindeutig.`);
};
const renameApply = async () => {
if (!rename) { notify('Erst „Bibliothek prüfen" — dann siehst du, was sich ändern würde.'); return; }
if (!confirm('Alle Bilder der Bibliothek auf eindeutige Namen bringen?\n\nDie Dateien selbst bleiben unberührt — sie liegen unter ihrer ID im Speicher. '
+ 'Bereits bei Picdrop abgelegte Kopien behalten ihren alten Namen; dafür gibt es danach „Erneut ausliefern".')) return;
notify('Benenne um …');
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'rename_apply' }) }).then((x) => x.json());
setRename(r);
notify(r.error || `${r.umbenannt} Bilder umbenannt, ${r.unveraendert} waren schon in Ordnung.`);
load();
};
const redeliverAll = async () => {
if (!confirm('Alle bereits ausgelieferten Bilder erneut hochladen — unter dem neuen Namen?\n\n'
+ 'Die alten Dateien bleiben am Ziel liegen und müssen dort von Hand entfernt werden. Das kann dauern.')) return;
notify('Liefere erneut aus … (kann dauern)');
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'redeliver_all' }) }).then((x) => x.json());
notify(r.versucht != null ? `${r.ok}/${r.versucht} erneut ausgeliefert${r.fehler ? `, ${r.fehler} fehlgeschlagen` : ''}.` : (r.error || 'Fehler'));
load();
};
const mirrorAll = async () => {
if (!s.nas_enabled) { notify('NAS erst aktivieren & speichern.'); return; }
if (!confirm('Alle fertigen Bilder aufs NAS sichern?')) return;
@@ -274,6 +325,102 @@ export default function AdminApp() {
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">E-Mail</span>
<div className="reihe">
<button className="mini" onClick={() => testeMail(false)}>Verbindung testen</button>
<button className="mini" onClick={() => testeMail(true)}>Testnachricht</button>
</div></div>
<div className="steuer">
<div className="fein">Klarbild verschickt über dein eigenes Postfach — dadurch stimmen SPF und DKIM
von selbst und es kommt kein weiterer Anbieter ins Spiel. Gebraucht wird es für
<b> Passwort zurücksetzen</b>, <b>Hinweise zu fertigen Aufträgen</b> und <b>Teilen-Links per Mail</b>.
Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt.</div>
{s.smtp_quelle === 'umgebung' && s.smtp_wirksam && (
<div className="hinweiskasten">
<b>Der Versand läuft bereits</b> — über die Server-Einstellung
(<code>{s.smtp_wirksam.user}</code> auf <code>{s.smtp_wirksam.host}:{s.smtp_wirksam.port}</code>).
Die Felder unten sind leer, weil hier nichts eingetragen ist; das ist in Ordnung.
Wer hier etwas einträgt und speichert, <b>überschreibt</b> die Server-Einstellung.
</div>
)}
{s.smtp_quelle === 'datenbank' && (
<div className="hinweiskasten">Der Versand läuft über das, was hier eingetragen ist.</div>
)}
{!s.smtp_quelle && (
<div className="hinweiskasten warn">Noch kein Versand eingerichtet — Passwort vergessen,
Auftragshinweise und der Mailversand von Teilen-Links funktionieren bis dahin nicht.</div>
)}
<div className="zwei">
<div className="feld"><label>Postausgangsserver</label>
<input className="input" placeholder="z. B. w020d97a.kasserver.com" value={s.smtp_host ?? ''} onChange={(e) => field('smtp_host', e.target.value)} /></div>
<div className="feld"><label>Port</label>
<input className="input" type="number" placeholder="465" value={s.smtp_port ?? 465} onChange={(e) => field('smtp_port', e.target.value)} />
<div className="fein">465 = SSL · 587 = STARTTLS</div></div>
</div>
<label className="schalt"><input type="checkbox" checked={s.smtp_secure !== false} onChange={(e) => field('smtp_secure', e.target.checked)} />
<span><b>Verschlüsselt verbinden (SSL)</b><em>Für Port 465 an, für 587 aus.</em></span></label>
<div className="zwei">
<div className="feld"><label>Benutzername</label>
<input className="input" placeholder="mail@deine-domain.de" value={s.smtp_user ?? ''} onChange={(e) => field('smtp_user', e.target.value)} /></div>
<div className="feld"><label>Passwort {s.smtp_password_set ? <span className="fein">· gesetzt</span> : null}</label>
<input className="input" type="password" value={smtpPw} autoComplete="new-password"
placeholder={s.smtp_password_set ? 'unverändert lassen' : 'Postfach-Passwort'}
onChange={(e) => setSmtpPw(e.target.value)} /></div>
</div>
<div className="feld"><label>Absender</label>
<input className="input" placeholder="Klarbild &lt;mail@deine-domain.de&gt;" value={s.smtp_from ?? ''} onChange={(e) => field('smtp_from', e.target.value)} />
<div className="fein">Leer = Benutzername. Muss zum Postfach gehören, sonst lehnen Empfänger die Mail ab.</div></div>
<div className="feld"><label>Testnachricht an</label>
<input className="input" type="email" placeholder="du@beispiel.de" value={mailAn} onChange={(e) => setMailAn(e.target.value)} />
<div className="fein">„Verbindung testen" prüft nur die Anmeldung. Erst die Testnachricht beweist,
dass die Mail wirklich ankommt — schau auch im Spam-Ordner nach.</div></div>
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Teilen-Links</span></div>
<div className="steuer">
<div className="feld"><label>Standard-Gültigkeit (Tage)</label>
<input className="input" type="number" min={0} max={3650} value={s.share_default_days ?? 30}
onChange={(e) => field('share_default_days', e.target.value)} />
<div className="fein">Voreinstellung für neue Links; beim Erzeugen jederzeit änderbar. <b>0 = unbegrenzt.</b>
Auf den Bildern sind Kinder — ein Link, der von selbst abläuft, ist die sichere Voreinstellung.</div></div>
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Dateinamen</span>
<span className="fein">JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</span></div>
<div className="steuer">
<div className="fein">Jedes Ergebnis bekommt einen Namen, der mit Datum und Uhrzeit beginnt — dadurch sortiert
jede Dateiliste von allein chronologisch — und mit einem kurzen Kürzel endet, das aus der Bild-ID stammt.
Damit können zwei gleichartige Bilder desselben Tages einander <b>nicht mehr überschreiben</b>.
Beispiel: <code>2026-08-20_143207_lea-badge_the-frame_a3f9.jpg</code></div>
<div className="reihe">
<button className="mini" onClick={renamePreview}>Bibliothek prüfen</button>
<button className="mini" onClick={renameApply}>Jetzt umbenennen</button>
<button className="mini" onClick={redeliverAll}>Erneut ausliefern</button>
</div>
{rename && (
<div className="fein">
<b>{rename.geprueft} Bilder geprüft</b> · {rename.umbenannt} {rename.probelauf ? 'bekämen einen neuen Namen' : 'umbenannt'} ·
{' '}{rename.unveraendert} unverändert{rename.vorherDoppelt ? ` · ${rename.vorherDoppelt} Namen waren doppelt vergeben` : ''}
{rename.beispiele?.length ? (
<ul className="namensliste">
{rename.beispiele.map((b: any) => (
<li key={b.id}><s>{b.alt}</s> → <code>{b.neu}</code></li>
))}
</ul>
) : null}
</div>
)}
<div className="fein">„Erneut ausliefern" lädt alle schon ausgelieferten Bilder unter dem neuen Namen erneut
zum Ziel hoch. Die alten Dateien bleiben dort liegen — automatisch etwas beim Kunden zu löschen wäre der
falsche Reflex; das machst du bei Picdrop selbst.</div>
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Synology-NAS (zweite Sicherung)</span>
<div className="reihe"><button className="mini" onClick={netcheck}>Erreichbarkeit</button>
@@ -453,7 +600,10 @@ function AdminStyles() {
.zwei{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
.feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);margin-bottom:6px;}
.feld em{text-transform:none;letter-spacing:0;color:var(--accent);font-style:normal;}
.input{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);}
/* 16 px, nicht 14: Darunter zoomt iOS beim Fokus ins Feld hinein — die
Notbremse in tokens.css greift nur bis 767 px, der Admin wird aber auch
auf dem iPad benutzt. */
.input{width:100%;background:var(--card);border:1px solid var(--line);border-radius:3px;padding:10px 11px;font-family:inherit;font-size:16px;color:var(--ink);}
.fein{font-size:11.5px;color:var(--soft);line-height:1.5;}
.knopf{border:none;border-radius:3px;padding:12px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
.knopf.warn{background:var(--err);}
@@ -464,6 +614,22 @@ function AdminStyles() {
.zahlen div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;}
.zahlen b{display:block;font-size:22px;line-height:1;}.zahlen span{font-size:11.5px;color:var(--soft);}
.karte>.fein{padding:0 16px 14px;}
.hinweiskasten{padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-sm);
background:var(--paper);font-size:12.5px;line-height:1.55;}
.hinweiskasten.warn{border-color:var(--warn);background:oklch(72% .15 75 / .08);}
.hinweiskasten code{font-family:var(--font-mono);font-size:11.5px;}
/* Am Handy war alles zweispaltig — ein Feld „Postausgangsserver" bekam so
rund 156 px. Und Tippziele unter 44 px trifft man nicht zuverlässig. */
@media(max-width:560px){
.zwei{grid-template-columns:1fr;}
.mini{padding:11px 13px;font-size:13px;}
.reihe{flex-wrap:wrap;}
}
/* Vorher/Nachher der Umbenennung — lange Namen dürfen die Karte nicht sprengen. */
.namensliste{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:4px;max-height:220px;overflow:auto;}
.namensliste li{font-family:var(--font-mono);font-size:10.5px;line-height:1.5;overflow-wrap:anywhere;}
.namensliste s{color:var(--soft);}
.namensliste code{color:var(--ink);}
.liste{padding:8px 16px 16px;display:flex;flex-direction:column;}
.zeile{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--line);}
.zeile b{font-size:14px;}
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useState } from 'react';
/**
* Eigenes Konto: E-Mail hinterlegen und Benachrichtigungen schalten.
*
* Die Adresse ist zugleich der Weg, ein vergessenes Passwort zurückzusetzen —
* das steht auch so dabei, damit niemand sie für reine Werbepost hält.
*/
interface Me {
username: string | null; display_name: string | null;
email: string | null; notify_jobs: boolean; mail_ready: boolean; role: string;
}
export default function KontoApp() {
const [me, setMe] = useState<Me | null>(null);
const [email, setEmail] = useState('');
const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null);
const [laeuft, setLaeuft] = useState(false);
const [ladefehler, setLadefehler] = useState(false);
const holen = () => {
setLadefehler(false);
fetch('/api/me')
.then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
.then((m) => { setMe(m); setEmail(m.email || ''); })
// Ohne diesen Zweig stand hier für immer „Einen Moment …".
.catch(() => setLadefehler(true));
};
useEffect(holen, []);
const sagen = (t: string, ok: boolean) => { setMsg({ t, ok }); setTimeout(() => setMsg(null), 5000); };
const patch = async (body: unknown, erfolg: string) => {
// Den neuen Wert sofort übernehmen, sonst springt der Haken beim Rerender
// auf den alten Stand zurück und man klickt aus Versehen ein zweites Mal.
setMe((m) => (m ? { ...m, ...(body as object) } as Me : m));
setLaeuft(true);
const r = await fetch('/api/me', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
setLaeuft(false);
if (r.error) { sagen(r.error, false); return false; }
sagen(erfolg, true);
const m = await fetch('/api/me').then((x) => x.json()).catch(() => null);
if (m) setMe(m);
return true;
};
if (ladefehler) return (
<div className="ko">
<section className="ko-karte"><div className="ko-inhalt">
<p className="ko-fein">Dein Konto ließ sich gerade nicht laden — vielleicht war die
Verbindung kurz weg.</p>
<button className="ko-btn stark" onClick={holen}>Noch einmal versuchen</button>
</div></section>
</div>
);
if (!me) return <p className="ko-fein">Einen Moment …</p>;
return (
<div className="ko">
<section className="ko-karte">
<div className="ko-kopf"><span className="mono-label">Anmeldung</span></div>
<div className="ko-inhalt">
<div className="ko-zeile">
<span className="ko-label">Benutzername</span>
<span className="ko-wert">{me.username}</span>
</div>
{me.display_name && (
<div className="ko-zeile">
<span className="ko-label">Angezeigt als</span>
<span className="ko-wert">{me.display_name}</span>
</div>
)}
<div className="ko-zeile">
<span className="ko-label">Passwort</span>
<span className="ko-wert"><a href="/passwort">Neues Passwort setzen</a></span>
</div>
</div>
</section>
<section className="ko-karte">
<div className="ko-kopf"><span className="mono-label">E-Mail-Adresse</span></div>
<div className="ko-inhalt">
<p className="ko-fein">
Damit kannst du dir ein <b>neues Passwort schicken lassen</b>, wenn du deines vergessen hast.
Ohne hinterlegte Adresse geht das nicht — dann muss ein Admin ran.
</p>
<div className="ko-eingabe">
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
placeholder="du@beispiel.de" aria-label="E-Mail-Adresse" autoComplete="email" />
<button className="ko-btn stark" disabled={laeuft || email === (me.email || '')}
onClick={() => patch({ email }, email ? 'Adresse gespeichert.' : 'Adresse entfernt.')}>
Speichern
</button>
</div>
{!me.mail_ready && (
<p className="ko-warn">
Auf diesem Server ist noch kein E-Mail-Versand eingerichtet — die Adresse wird zwar
gespeichert, es kommt aber noch nichts an. Ein Admin richtet das unter
<b> Admin → E-Mail</b> ein.
</p>
)}
</div>
</section>
<section className="ko-karte">
<div className="ko-kopf"><span className="mono-label">Benachrichtigungen</span></div>
<div className="ko-inhalt">
<label className="ko-schalt">
<input type="checkbox" checked={me.notify_jobs} disabled={!me.email || laeuft}
onChange={(e) => patch({ notify_jobs: e.target.checked },
e.target.checked ? 'Hinweise eingeschaltet.' : 'Hinweise ausgeschaltet.')} />
<span>
<b>Sag mir Bescheid, wenn ein Auftrag fertig ist</b>
<em>Eine kurze Mail je Auftrag — kein Newsletter, keine Werbung.
{!me.email && ' Dafür brauchst du zuerst eine E-Mail-Adresse (oben).'}</em>
</span>
</label>
<p className="ko-fein">
Aufträge aus einer <b>privaten Sitzung</b> lösen bewusst nie eine Mail aus — die sollen
ja gerade keine Spur hinterlassen.
</p>
</div>
</section>
{msg && <p className={`ko-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
<style>{`
.ko{display:grid;gap:14px;max-width:620px;}
.ko-karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
.ko-kopf{padding:13px 16px;border-bottom:1px solid var(--line);}
.ko-inhalt{padding:16px;display:grid;gap:12px;}
.ko-zeile{display:flex;gap:12px;justify-content:space-between;align-items:baseline;
padding-bottom:9px;border-bottom:1px solid var(--line);font-size:.92rem;}
.ko-zeile:last-child{border-bottom:none;padding-bottom:0;}
.ko-label{color:var(--soft);font-size:.85rem;}
.ko-wert{font-weight:500;overflow-wrap:anywhere;text-align:right;}
.ko-wert a{color:var(--accent);}
.ko-fein{margin:0;font-size:.85rem;color:var(--ink-soft);line-height:1.6;}
.ko-eingabe{display:flex;gap:8px;}
.ko-eingabe input{flex:1;min-width:0;padding:11px 12px;border:1px solid var(--line);
border-radius:var(--radius-sm);background:var(--paper);color:var(--ink);font-size:16px;font-family:inherit;}
.ko-btn{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);
color:var(--ink);padding:11px 16px;font-size:.9rem;cursor:pointer;font-family:inherit;white-space:nowrap;}
.ko-btn.stark{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;}
.ko-btn:disabled{background:var(--paper);color:var(--soft);border-color:var(--line);cursor:not-allowed;}
.ko-schalt{display:flex;gap:11px;align-items:flex-start;cursor:pointer;font-size:.92rem;}
.ko-schalt input{width:19px;height:19px;margin-top:2px;flex:none;accent-color:var(--accent);}
.ko-schalt input:disabled{cursor:not-allowed;}
.ko-schalt b{display:block;}
.ko-schalt em{display:block;font-style:normal;color:var(--soft);font-size:.82rem;margin-top:3px;line-height:1.5;}
.ko-warn{margin:0;padding:10px 12px;border:1px solid var(--warn);border-radius:var(--radius-sm);
background:oklch(72% .15 75 / .08);font-size:.82rem;line-height:1.55;}
.ko-msg{margin:0;font-size:.9rem;}
.ko-msg.ok{color:var(--ok);}
.ko-msg.err{color:var(--err);}
@media(max-width:560px){
.ko-eingabe{flex-direction:column;}
.ko-btn{width:100%;padding:13px;}
.ko-zeile{flex-direction:column;gap:3px;}
.ko-wert{text-align:left;}
}
`}</style>
</div>
);
}
+18 -2
View File
@@ -1,4 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { nameAusKopf } from '../lib/naming';
import ShareDialog, { type ShareZiel } from './ShareDialog';
interface Item { id: string; filename: string; output_px: string; has_alpha: boolean;
folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; nas_status: string;
@@ -24,6 +26,7 @@ export default function LibraryApp() {
const [promptOf, setPromptOf] = useState<Item | null>(null);
const [saveView, setSaveView] = useState<Item | null>(null);
const [tagMenu, setTagMenu] = useState<string | null>(null);
const [teilen, setTeilen] = useState<ShareZiel | null>(null);
const [tagFilter, setTagFilter] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
@@ -75,7 +78,9 @@ export default function LibraryApp() {
if (!res.ok) { notify('ZIP fehlgeschlagen.'); return; }
const blob = await res.blob();
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = `klarbild_${new Date().toISOString().slice(0, 10)}.zip`; a.click();
// Der Name kommt vom Server — hier einen eigenen zu bauen hieße, zwei
// Auswahlen desselben Tages wieder gleich zu nennen.
a.download = nameAusKopf(res.headers.get('Content-Disposition'), 'klarbild-auswahl.zip'); a.click();
URL.revokeObjectURL(a.href); notify('ZIP geladen.');
};
const alternative = async (id: string) => {
@@ -129,6 +134,7 @@ export default function LibraryApp() {
{pick.size > 0 && <>
<span className="fein">{pick.size} gewählt</span>
<button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button>
<button className="mini" onClick={() => setTeilen({ itemIds: [...pick] })}>Teilen</button>
<button className="mini" onClick={zipSelected}>ZIP laden</button>
<select className="mini-select" value="" onChange={(e) => { if (e.target.value) moveSelected(e.target.value === '__none' ? null : e.target.value); }}>
<option value="" disabled>In Ordner …</option>
@@ -224,6 +230,8 @@ export default function LibraryApp() {
<button className="mini stark2" onClick={() => setSaveView(v)} title="Groß öffnen, dann sichern (iPhone: In Fotos sichern)">Laden</button>
<button className="mini" onClick={() => reuse(v.id)} title="Ergebnis im Studio weiterbearbeiten">Weiter</button>
<button className="mini" onClick={() => alternative(v.id)} title="Weitere Fassung erzeugen">+ Alt.</button>
<button className="mini" onClick={() => setTeilen({ itemIds: [v.id] })}
title="Link erzeugen — auch ohne Klarbild-Konto zu öffnen">Teilen</button>
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}>
<option value="">Kein Ordner</option>
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
@@ -236,6 +244,10 @@ export default function LibraryApp() {
</div>
)}
{teilen && (
<ShareDialog ziel={teilen} onClose={() => setTeilen(null)} />
)}
{compare && (
<div className="lupe" onClick={() => setCompare(null)}>
<div className="vergleich" onClick={(e) => e.stopPropagation()}>
@@ -302,7 +314,11 @@ function LibStyles() {
.haken{position:absolute;top:6px;left:6px;width:21px;height:21px;border-radius:50%;border:1.5px solid #fff;background:rgba(22,21,15,.35);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;}
.kachel.gewaehlt .haken{background:var(--accent);}
.modetag{position:absolute;top:6px;right:6px;background:rgba(59,91,219,.92);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:20px;}
.flagbadge{position:absolute;top:5px;left:6px;font-size:19px;line-height:1;text-shadow:0 1px 2px rgba(0,0,0,.35);}
/* Unten links — oben links sitzt der Auswahlkreis (.haken), oben rechts die
Modus-Marke, unten rechts die Fassungsnummer. Vorher lag die Flagge exakt
auf dem Auswahlkreis und verdeckte ihn. */
.flagbadge{position:absolute;bottom:5px;left:6px;font-size:19px;line-height:1;
text-shadow:0 1px 3px rgba(0,0,0,.55);pointer-events:none;}
.tagbar{margin:0 0 12px;}
.flagchip .flag{font-size:13px;vertical-align:-1px;}
.flag.none{color:var(--soft);}
+1169 -352
View File
File diff suppressed because it is too large. Load diff
+220
View File
@@ -0,0 +1,220 @@
import { useCallback, useEffect, useState } from 'react';
import { nameAusKopf } from '../lib/naming';
/**
* Die letzten Drucke.
*
* Gespeichert ist **nur die Anleitung** — Bogen, Maße, Stückzahlen und die
* Verweise auf die Bilder. Kein PDF: Ein A3-Bogen ist schnell 20–80 MB, und
* das Modul rechnet ohne KI, also ergibt derselbe Auftrag wieder exakt
* dasselbe Blatt. Neu erzeugen ist billiger als aufheben.
*
* „Nochmal drucken" geht deshalb nur, wenn **alle** Bilder aus der Bibliothek
* kamen und noch da sind. Bei hochgeladenen Dateien ist die Quelle nach dem
* Aufräumen weg — das steht dann auch so am Eintrag.
*/
interface Run {
id: string; origin: string; summary: string | null; paper: string | null;
pages: number; pieces: number; bytes: number | null; created_at: string;
bilder: { id: string; filename: string | null }[];
fehlend: number; hochgeladen: number; wiederholbar: boolean; config: any;
}
const wann = (s: string) => {
const d = new Date(s);
const minuten = Math.round((Date.now() - d.getTime()) / 60000);
if (minuten < 1) return 'gerade eben';
if (minuten < 60) return `vor ${minuten} min`;
if (minuten < 24 * 60) return `vor ${Math.round(minuten / 60)} h`;
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
};
// Ein Testbogen sind 20 KB, ein A3-Poster 80 MB. `toFixed(1)` in MB machte aus
// dem kleinen Fall „0.0 MB groß" — also unterhalb eines Megabytes in KB.
const groesse = (b: number | null) => {
if (!b) return '';
if (b < 1024 * 1024) return `${Math.max(1, Math.round(b / 1024))} KB`;
return `${(b / 1048576).toFixed(1)} MB`.replace('.', ',');
};
const zahl = (n: number, ein: string, viele: string) => `${n} ${n === 1 ? ein : viele}`;
export default function PrintRuns({ onLaden }: { onLaden?: (config: any) => void }) {
const [runs, setRuns] = useState<Run[]>([]);
const [laden, setLaden] = useState(true);
const [fehler, setFehler] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null);
const holen = useCallback(async () => {
try {
const r = await fetch('/api/print/runs');
if (!r.ok) throw new Error(String(r.status));
const d = await r.json();
setRuns(d.runs || []); setFehler(false);
} catch { setFehler(true); }
finally { setLaden(false); }
}, []);
useEffect(() => { holen(); }, [holen]);
const sagen = (t: string, ok = true) => { setMsg({ t, ok }); setTimeout(() => setMsg(null), 5000); };
/** Denselben Bogen noch einmal erzeugen — dieselbe Anfrage, dasselbe PDF. */
const nochmal = async (r: Run) => {
setBusy(r.id);
try {
const res = await fetch('/api/print/sheet', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
// `origin` markiert den Lauf als Wiederholung, damit die Liste nicht
// aussieht, als hätte jemand denselben Bogen zweimal von Hand gebaut.
body: JSON.stringify({ ...r.config, origin: 'wiederholung' }),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
sagen(j.error || 'Das hat nicht geklappt — vielleicht fehlt ein Bild.', false);
return;
}
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
// Der Wiederholungsdruck ist eine **neue** Datei mit neuem Zeitstempel und
// neuem Kürzel — er überschreibt den ersten Bogen also nicht.
a.download = nameAusKopf(res.headers.get('Content-Disposition'), 'klarbild-druck.pdf');
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
sagen('PDF neu erzeugt. Beim Drucken „Tatsächliche Größe / 100 %" wählen.');
holen();
} catch { sagen('Netzwerkfehler.', false); }
finally { setBusy(null); }
};
const loeschen = async (r: Run) => {
if (!confirm('Diesen Eintrag aus der Liste nehmen?\n\nDie Bilder bleiben in der Bibliothek — es verschwindet nur der Verlaufseintrag.')) return;
const res = await fetch(`/api/print/runs?id=${r.id}`, { method: 'DELETE' }).then((x) => x.json()).catch(() => ({}));
if (res.ok) { setRuns((p) => p.filter((x) => x.id !== r.id)); } else sagen(res.error || 'Hat nicht geklappt.', false);
};
if (laden) return <p className="pr-leer">Wird geladen …</p>;
if (fehler) return (
<div className="pr">
<p className="pr-leer">Der Verlauf ließ sich nicht laden.{' '}
<button className="pr-btn" onClick={() => { setLaden(true); holen(); }}>Noch einmal</button></p>
<PrintRunsStyles />
</div>
);
if (!runs.length) return (
<div className="pr">
<div className="pr-leer-box">
<span className="regmark" />
<h2>Noch nichts gedruckt</h2>
<p>Sobald du im <a href="/drucken">Druckbereich</a> ein PDF erzeugst, steht es hier —
mit einem Knopf, um genau denselben Bogen noch einmal zu bekommen.</p>
</div>
<PrintRunsStyles />
</div>
);
return (
<div className="pr">
{msg && <p className={`pr-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
<div className="pr-liste">
{runs.map((r) => (
<article key={r.id} className="pr-karte">
<div className="pr-kopf">
<div className="pr-titel">
<h3>{r.summary || 'Druckbogen'}</h3>
<span className="pr-wann">{wann(r.created_at)}</span>
</div>
{r.origin === 'wiederholung' && <span className="pr-marke">wiederholt</span>}
{r.origin === 'telegram' && <span className="pr-marke">Telegram</span>}
</div>
<div className="pr-meta">
{r.paper} · {zahl(r.pages, 'Bogen', 'Bogen')} · {zahl(r.pieces, 'Teil', 'Teile')}
{r.bytes ? <> · das PDF war {groesse(r.bytes)} groß</> : null}
</div>
{r.bilder.length > 0 && (
<div className="pr-bilder">
{r.bilder.slice(0, 8).map((b) => (
<img key={b.id} src={`/api/items/${b.id}/file?thumb=1`} alt={b.filename || ''}
title={b.filename || ''} loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; }} />
))}
{r.bilder.length > 8 && <span className="pr-mehr">+{r.bilder.length - 8}</span>}
</div>
)}
<div className="pr-aktionen">
{r.wiederholbar ? (
<button className="pr-btn stark" disabled={busy === r.id} onClick={() => nochmal(r)}>
{busy === r.id ? 'Wird erzeugt …' : 'Nochmal drucken'}
</button>
) : (
<span className="pr-warum">
{r.fehlend > 0
? `${zahl(r.fehlend, 'Bild ist', 'Bilder sind')} inzwischen gelöscht — nicht wiederholbar.`
: 'Damals hochgeladen statt aus der Bibliothek — die Quelldatei ist weg.'}
</span>
)}
{onLaden && (
<button className="pr-btn" onClick={() => onLaden(r.config)}>Einstellung übernehmen</button>
)}
<button className="pr-btn gefahr" onClick={() => loeschen(r)}>Entfernen</button>
</div>
</article>
))}
</div>
<p className="pr-fein">
Hier liegen nur die Einstellungen, nicht die PDFs — ein A3-Bogen wären schnell 20 bis 80 MB.
Weil das Druckmodul ohne KI rechnet, ergibt derselbe Auftrag wieder exakt dasselbe Blatt.
Aufgehoben werden die letzten {100} Drucke je Person.
</p>
<PrintRunsStyles />
</div>
);
}
function PrintRunsStyles() {
return <style>{`
.pr{display:grid;gap:14px;}
.pr-leer{color:var(--soft);}
.pr-leer-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
padding:44px 24px;text-align:center;}
.pr-leer-box h2{margin:14px 0 8px;font-size:1.15rem;}
.pr-leer-box p{margin:0;color:var(--ink-soft);line-height:1.6;}
.pr-liste{display:grid;gap:12px;}
.pr-karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
padding:15px;display:grid;gap:10px;}
.pr-kopf{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline;}
.pr-titel{display:flex;flex-wrap:wrap;gap:10px;align-items:baseline;min-width:0;flex:1;}
.pr-titel h3{margin:0;font-size:.98rem;letter-spacing:-.01em;overflow-wrap:anywhere;}
.pr-wann{font-size:.78rem;color:var(--soft);white-space:nowrap;}
.pr-marke{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
padding:3px 8px;border-radius:20px;background:var(--paper);color:var(--soft);border:1px solid var(--line);}
.pr-meta{font-size:.8rem;color:var(--soft);line-height:1.5;}
.pr-bilder{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
.pr-bilder img{width:46px;height:46px;object-fit:cover;border:1px solid var(--line);border-radius:3px;
background:var(--paper);}
.pr-mehr{font-family:var(--font-mono);font-size:11px;color:var(--soft);}
.pr-aktionen{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
.pr-btn{background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);
padding:9px 12px;font-size:.84rem;cursor:pointer;font-family:inherit;color:var(--ink);white-space:nowrap;}
.pr-btn:hover{border-color:var(--accent);}
.pr-btn.stark{background:var(--accent);color:var(--card);border-color:var(--accent);font-weight:600;}
.pr-btn.gefahr{color:var(--err);}
.pr-btn:disabled{background:var(--paper);color:var(--soft);border-color:var(--line);cursor:not-allowed;}
.pr-warum{font-size:.8rem;color:var(--soft);line-height:1.5;}
.pr-fein{margin:0;font-size:.78rem;color:var(--soft);line-height:1.6;max-width:70ch;}
.pr-msg{margin:0;font-size:.9rem;}
.pr-msg.ok{color:var(--ok);}
.pr-msg.err{color:var(--err);}
@media(max-width:640px){
.pr-btn{flex:1;text-align:center;padding:11px 10px;}
.pr-warum{flex-basis:100%;}
}
`}</style>;
}
+307
View File
@@ -0,0 +1,307 @@
import { useEffect, useRef, useState } from 'react';
/**
* Teilen-Dialog: aus einer Bildauswahl einen Link machen und ihn weitergeben.
*
* Bewusst zweistufig — erst Einstellungen, dann Weitergabe. Der Link entsteht
* beim Klick auf „Link erzeugen"; bis dahin ist nichts öffentlich. Danach zeigt
* derselbe Dialog die Wege: kopieren, WhatsApp, E-Mail.
*/
export interface ShareZiel { itemIds: string[]; titel?: string }
const TAGE = [
{ v: 7, t: '7 Tage' },
{ v: 30, t: '30 Tage' },
{ v: 90, t: '3 Monate' },
{ v: 365, t: '1 Jahr' },
{ v: 0, t: 'unbegrenzt' },
];
export default function ShareDialog(
{ ziel, onClose, onFertig }: { ziel: ShareZiel; onClose: () => void; onFertig?: () => void },
) {
const mehrere = ziel.itemIds.length > 1;
const [titel, setTitel] = useState(ziel.titel || '');
const [notiz, setNotiz] = useState('');
const [tage, setTage] = useState(30);
const [download, setDownload] = useState(true);
const [wort, setWort] = useState('');
const [link, setLink] = useState<{ slug: string; id: string } | null>(null);
const [laeuft, setLaeuft] = useState(false);
const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null);
const [mailAn, setMailAn] = useState('');
const [mailText, setMailText] = useState('');
const [mailBereit, setMailBereit] = useState<boolean | null>(null);
const [kopiert, setKopiert] = useState(false);
const box = useRef<HTMLDivElement>(null);
const url = link ? `${location.origin}/s/${link.slug}` : '';
useEffect(() => {
fetch('/api/me').then((r) => r.json()).then((m) => {
setMailBereit(!!m.mail_ready);
// Voreinstellung aus dem Admin übernehmen — sonst verspricht die Einstellung
// dort etwas, das hier nicht ankommt.
if (typeof m.share_default_days === 'number') setTage(m.share_default_days);
}).catch(() => setMailBereit(null));
}, []);
// Fokus einfangen und Hintergrund festhalten, solange der Dialog offen ist.
useEffect(() => {
const vorher = document.body.style.overflow;
document.body.style.overflow = 'hidden';
box.current?.querySelector<HTMLElement>('input,select,button')?.focus();
const key = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key !== 'Tab') return;
const f = box.current?.querySelectorAll<HTMLElement>('a[href],button:not([disabled]),input,select,textarea');
if (!f?.length) return;
const [erst, letzt] = [f[0], f[f.length - 1]];
if (e.shiftKey && document.activeElement === erst) { e.preventDefault(); letzt.focus(); }
else if (!e.shiftKey && document.activeElement === letzt) { e.preventDefault(); erst.focus(); }
};
document.addEventListener('keydown', key);
return () => { document.removeEventListener('keydown', key); document.body.style.overflow = vorher; };
}, [onClose]);
const erzeugen = async () => {
setLaeuft(true); setMsg(null);
const r = await fetch('/api/shares', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemIds: ziel.itemIds, title: titel || null, note: notiz || null,
days: tage, allowDownload: download, password: wort || null,
}),
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
setLaeuft(false);
if (r.error) { setMsg({ t: r.error, ok: false }); return; }
setLink({ slug: r.share.slug, id: r.share.id });
onFertig?.();
};
const kopieren = async () => {
try { await navigator.clipboard.writeText(url); }
catch {
// Ohne Zwischenablage-Recht: Feld markieren, damit man von Hand kopieren kann.
box.current?.querySelector<HTMLInputElement>('.linkfeld')?.select();
}
setKopiert(true); setTimeout(() => setKopiert(false), 2000);
};
const waText = `${titel || 'Bilder'}\n${url}`;
const waHref = `https://wa.me/?text=${encodeURIComponent(waText)}`;
const mailen = async () => {
if (!mailAn.trim()) { setMsg({ t: 'Bitte eine Empfängeradresse eintragen.', ok: false }); return; }
setLaeuft(true); setMsg(null);
const r = await fetch('/api/shares/send', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shareId: link!.id, to: mailAn, message: mailText }),
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
setLaeuft(false);
if (r.error) { setMsg({ t: r.error, ok: false }); return; }
// Teilweiser Erfolg ist Erfolg: Vorher färbte eine Textmustererkennung
// „2 verschickt, bei 1 hat es nicht geklappt" komplett rot.
setMsg(r.fehler?.length
? { t: `${r.ok} verschickt, bei ${r.fehler.length} hat es nicht geklappt.`, ok: r.ok > 0 }
: { t: `Verschickt an ${r.ok === 1 ? 'die Adresse' : `${r.ok} Adressen`}.`, ok: true });
setMailAn('');
};
return (
<div className="tl-hinter" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div className="tl-box" ref={box} role="dialog" aria-modal="true" aria-label="Teilen">
<div className="tl-kopf">
<h2>{mehrere ? `${ziel.itemIds.length} Bilder teilen` : 'Bild teilen'}</h2>
<button className="tl-x" onClick={onClose} aria-label="Schließen">✕</button>
</div>
{!link ? (
<div className="tl-inhalt">
<p className="tl-lead">
Es entsteht ein Link, den auch jemand ohne Klarbild-Konto öffnen kann.
Er läuft von selbst ab und lässt sich jederzeit wieder abschalten.
</p>
<label className="tl-feld">Überschrift
<input value={titel} onChange={(e) => setTitel(e.target.value)}
placeholder={mehrere ? 'z. B. Kita-Fotos August' : 'z. B. Phoenix im Garten'} maxLength={120} />
</label>
<label className="tl-feld">Gültig
<select value={tage} onChange={(e) => setTage(Number(e.target.value))}>
{TAGE.map((t) => <option key={t.v} value={t.v}>{t.t}</option>)}
</select>
</label>
{/* Der häufige Fall ist „ein Bild an Oma" — der braucht nichts davon.
Zugeklappt, mit einer Zusammenfassung, die den aktuellen Stand nennt. */}
<details className="tl-mehr">
<summary>
<span>Mehr einstellen</span>
<em>{download ? 'Herunterladen erlaubt' : 'nur ansehen'}
{wort ? ' · mit Zugangswort' : ' · ohne Zugangswort'}</em>
</summary>
<div className="tl-mehr-inhalt">
<label className="tl-feld">Nachricht auf der Seite
<textarea value={notiz} onChange={(e) => setNotiz(e.target.value)} rows={2}
placeholder="Steht über den Bildern" maxLength={500} />
</label>
<label className="tl-check">
<input type="checkbox" checked={download} onChange={(e) => setDownload(e.target.checked)} />
<span><b>Herunterladen erlauben</b><em>Ohne Haken kann man die Bilder nur ansehen.</em></span>
</label>
<label className="tl-feld">Zugangswort
<input type="text" value={wort} onChange={(e) => setWort(e.target.value)}
placeholder="Leer = jeder mit dem Link kommt rein" maxLength={100} />
<span className="tl-fein">Schick das Wort auf einem anderen Weg als den Link.</span>
</label>
</div>
</details>
{msg && <p className={`tl-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
<div className="tl-fuss">
<button className="tl-zweit" onClick={onClose}>Abbrechen</button>
<button className="tl-haupt" onClick={erzeugen} disabled={laeuft}>
{laeuft ? 'Einen Moment …' : 'Link erzeugen'}
</button>
</div>
</div>
) : (
<div className="tl-inhalt">
<p className="tl-lead">Der Link ist da. So kommt er zum Empfänger:</p>
<div className="tl-linkzeile">
<input className="linkfeld" value={url} readOnly onFocus={(e) => e.currentTarget.select()} aria-label="Teilen-Link" />
<button className="tl-haupt" onClick={kopieren}>{kopiert ? 'Kopiert ✓' : 'Kopieren'}</button>
</div>
<div className="tl-wege">
<a className="tl-weg wa" href={waHref} target="_blank" rel="noopener">
<span className="tl-weg-t">WhatsApp</span>
<span className="tl-weg-f">Öffnet WhatsApp mit fertigem Text</span>
</a>
<a className="tl-weg" href={`mailto:?subject=${encodeURIComponent(titel || 'Bilder')}&body=${encodeURIComponent(waText)}`}>
<span className="tl-weg-t">Eigenes Mailprogramm</span>
<span className="tl-weg-f">Öffnet deine Mail-App</span>
</a>
</div>
{wort && (
<p className="tl-warn">
Der Link ist mit <b>{wort}</b> geschützt. Schick das Wort getrennt vom Link —
sonst nützt der Schutz nichts.
</p>
)}
<div className="tl-trenn"><span>oder direkt von hier verschicken</span></div>
{mailBereit === null ? null : mailBereit ? (
<>
<label className="tl-feld">An
<input value={mailAn} onChange={(e) => setMailAn(e.target.value)} type="email"
placeholder="oma@beispiel.de, opa@beispiel.de" />
<span className="tl-fein">Mehrere Adressen mit Komma trennen.</span>
</label>
<label className="tl-feld"><span className="tl-kopfzeile">Dazu schreiben <em>optional</em></span>
<textarea value={mailText} onChange={(e) => setMailText(e.target.value)} rows={2}
placeholder="Ein paar Zeilen an den Empfänger" maxLength={1000} />
</label>
<button className="tl-zweit breit" onClick={mailen} disabled={laeuft}>
{laeuft ? 'Wird verschickt …' : 'Per E-Mail schicken'}
</button>
</>
) : (
<p className="tl-fein">
Direkter E-Mail-Versand ist noch nicht eingerichtet — ein Admin kann das
unter <b>Admin → E-Mail</b> nachholen. Kopieren und WhatsApp gehen trotzdem.
</p>
)}
{msg && <p className={`tl-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
<div className="tl-fuss">
<a className="tl-zweit" href="/teilen">Alle Links verwalten</a>
<button className="tl-zweit" onClick={onClose}>Fertig</button>
</div>
</div>
)}
</div>
<style>{`
.tl-hinter{position:fixed;inset:0;z-index:80;background:rgba(22,21,15,.55);
display:flex;align-items:center;justify-content:center;padding:16px;backdrop-filter:blur(2px);}
.tl-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
width:min(520px,100%);max-height:min(88vh,760px);display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(22,21,15,.25);}
.tl-kopf{display:flex;align-items:center;justify-content:space-between;gap:10px;
padding:16px 18px;border-bottom:1px solid var(--line);}
.tl-kopf h2{margin:0;font-size:1.05rem;letter-spacing:-.01em;}
.tl-x{border:none;background:none;font-size:17px;cursor:pointer;color:var(--soft);
width:34px;height:34px;border-radius:50%;line-height:1;}
.tl-x:hover{background:var(--paper);color:var(--ink);}
.tl-inhalt{padding:18px;overflow:auto;display:grid;gap:14px;}
.tl-lead{margin:0;font-size:.9rem;color:var(--ink-soft);line-height:1.6;}
.tl-feld{display:grid;gap:6px;font-size:.86rem;color:var(--ink-soft);}
.tl-feld input,.tl-feld select,.tl-feld textarea{padding:10px 11px;border:1px solid var(--line);
border-radius:var(--radius-sm);background:var(--paper);color:var(--ink);font-size:16px;
font-family:inherit;width:100%;box-sizing:border-box;}
.tl-feld textarea{resize:vertical;line-height:1.5;}
.tl-kopfzeile{display:flex;gap:6px;align-items:baseline;}
.tl-kopfzeile em{font-style:normal;color:var(--soft);font-size:.78rem;}
.tl-fein{font-size:.78rem;color:var(--soft);line-height:1.5;}
.tl-check{display:flex;gap:10px;align-items:flex-start;font-size:.88rem;cursor:pointer;}
.tl-check input{width:18px;height:18px;margin-top:2px;flex:none;accent-color:var(--accent);}
.tl-check b{display:block;font-weight:600;color:var(--ink);}
.tl-check em{display:block;font-style:normal;color:var(--soft);font-size:.8rem;margin-top:2px;}
.tl-linkzeile{display:flex;gap:8px;}
.tl-linkzeile input{flex:1;min-width:0;padding:10px 11px;border:1px solid var(--line);
border-radius:var(--radius-sm);background:var(--paper);font-family:var(--font-mono);
font-size:12.5px;color:var(--ink);}
.tl-haupt{border:none;border-radius:var(--radius-sm);background:var(--accent);color:#fff;
padding:11px 16px;font-weight:600;font-size:.92rem;cursor:pointer;font-family:inherit;white-space:nowrap;}
.tl-haupt.breit,.tl-zweit.breit{width:100%;}
.tl-haupt:disabled{background:var(--paper);color:var(--soft);border:1px solid var(--line);cursor:not-allowed;}
.tl-zweit{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);
color:var(--ink);padding:11px 16px;font-size:.92rem;cursor:pointer;font-family:inherit;
text-decoration:none;display:inline-block;text-align:center;}
.tl-wege{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
.tl-weg{display:grid;gap:2px;padding:11px 13px;border:1px solid var(--line);
border-radius:var(--radius-sm);background:var(--paper);text-decoration:none;color:var(--ink);}
.tl-weg:hover{border-color:var(--accent);}
.tl-weg.wa{border-color:#25D366;}
.tl-weg-t{font-weight:600;font-size:.88rem;}
.tl-weg-f{font-size:.75rem;color:var(--soft);line-height:1.4;}
.tl-mehr{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--paper);}
.tl-mehr>summary{list-style:none;cursor:pointer;display:flex;align-items:baseline;
justify-content:space-between;gap:10px;padding:12px;font-size:.88rem;}
.tl-mehr>summary>span{white-space:nowrap;}
.tl-mehr>summary::-webkit-details-marker{display:none;}
.tl-mehr>summary::after{content:'▾';color:var(--soft);font-size:11px;transition:transform .15s;}
.tl-mehr[open]>summary::after{transform:rotate(180deg);}
.tl-mehr>summary em{font-style:normal;font-size:.76rem;color:var(--soft);
margin-left:auto;text-align:right;line-height:1.4;}
.tl-mehr-inhalt{display:grid;gap:12px;padding:0 12px 12px;}
.tl-trenn{display:flex;align-items:center;gap:10px;color:var(--soft);
font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;}
.tl-trenn::before,.tl-trenn::after{content:'';flex:1;height:1px;background:var(--line);}
.tl-warn{margin:0;padding:10px 12px;border:1px solid var(--warn);border-radius:var(--radius-sm);
background:oklch(72% .15 75 / .08);font-size:.82rem;line-height:1.55;}
.tl-msg{margin:0;font-size:.86rem;line-height:1.5;}
.tl-msg.err{color:var(--err);}
.tl-msg.ok{color:var(--ok);}
.tl-fuss{display:flex;gap:8px;justify-content:flex-end;padding-top:4px;}
@media(max-width:560px){
.tl-hinter{padding:0;align-items:flex-end;}
.tl-box{width:100%;max-height:92vh;border-radius:var(--radius) var(--radius) 0 0;
padding-bottom:env(safe-area-inset-bottom);}
.tl-wege{grid-template-columns:1fr;}
.tl-fuss{flex-direction:column-reverse;}
.tl-fuss>*{width:100%;}
.tl-haupt,.tl-zweit{padding:13px 16px;}
}
`}</style>
</div>
);
}
+253
View File
@@ -0,0 +1,253 @@
import { useCallback, useEffect, useState } from 'react';
/**
* Verwaltung der Teilen-Links: sehen, wie oft ein Link aufgerufen wurde,
* ihn ändern, abschalten oder löschen.
*
* Der Zähler ist bewusst schlicht: Aufrufe, Downloads und die Zahl der
* unterschiedlichen Besucher. Namen oder Adressen stehen nirgends: Gespeichert
* wird ein Hash der IP, gesalzen mit dem Server-Geheimnis **und** der Kennung
* des Links. Damit lässt sich derselbe Besucher innerhalb eines Links
* wiedererkennen, aber nicht über mehrere Links hinweg verfolgen.
* Ehrlich benannt ist das Pseudonymisierung, nicht Anonymisierung — wer den
* Server-Schlüssel hat, könnte den IPv4-Raum durchrechnen. Deshalb wird auch
* nur 90 Tage aufgehoben.
*/
interface Share {
id: string; slug: string; kind: string; title: string | null; note: string | null;
expires_at: string | null; revoked_at: string | null; allow_download: boolean;
password_hash: string | null; views: number; downloads: number;
last_seen_at: string | null; created_at: string; anzahl: number; besucher: number;
}
const TAGE = [
{ v: 7, t: '7 Tage ab jetzt' }, { v: 30, t: '30 Tage ab jetzt' },
{ v: 90, t: '3 Monate ab jetzt' }, { v: 365, t: '1 Jahr ab jetzt' }, { v: 0, t: 'unbegrenzt' },
];
const datum = (s: string | null) => s ? new Date(s).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '';
const zeit = (s: string | null) => s ? new Date(s).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
type Zustand = 'aktiv' | 'abgelaufen' | 'aus';
const zustand = (s: Share): Zustand =>
s.revoked_at ? 'aus'
: s.expires_at && new Date(s.expires_at).getTime() < Date.now() ? 'abgelaufen'
: 'aktiv';
export default function SharesApp() {
const [shares, setShares] = useState<Share[]>([]);
const [laden, setLaden] = useState(true);
const [ladefehler, setLadefehler] = useState(false);
const [offen, setOffen] = useState<string | null>(null);
const [zugriffe, setZugriffe] = useState<Record<string, any[]>>({});
const [msg, setMsg] = useState<string | null>(null);
const [kopiert, setKopiert] = useState<string | null>(null);
const laden_ = useCallback(async () => {
// Ein Netzfehler darf nicht als „nichts geteilt" erscheinen — sonst denkt
// man, die eigenen Links seien weg.
try {
const r = await fetch('/api/shares');
if (!r.ok) throw new Error(String(r.status));
const d = await r.json();
setShares(d.shares || []); setLadefehler(false);
} catch { setLadefehler(true); }
finally { setLaden(false); }
}, []);
useEffect(() => { laden_(); }, [laden_]);
const sagen = (t: string) => { setMsg(t); setTimeout(() => setMsg(null), 4000); };
const patch = async (id: string, body: unknown) => {
const r = await fetch(`/api/shares/${id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
// `{ok:false}` heißt: Der Link gehört jemand anderem oder war schon in dem
// Zustand. Ohne Rückmeldung wirkt der Knopf kaputt.
if (r.error) sagen(r.error);
else if (r.ok === false) { sagen('Nichts zu ändern — vielleicht schon geschehen?'); await laden_(); }
else await laden_();
};
const loeschen = async (s: Share) => {
if (!confirm(`Den Link „${s.title || s.slug}" endgültig löschen?\n\nEr lässt sich danach nicht mehr öffnen und die Zugriffszahlen sind weg. `
+ `Nur abschalten reicht meistens.`)) return;
const r = await fetch(`/api/shares/${s.id}`, { method: 'DELETE' }).then((x) => x.json()).catch(() => ({}));
if (r.ok) { sagen('Link gelöscht.'); await laden_(); } else sagen(r.error || 'Hat nicht geklappt.');
};
const kopieren = async (s: Share) => {
const url = `${location.origin}/s/${s.slug}`;
try { await navigator.clipboard.writeText(url); } catch { prompt('Link kopieren:', url); }
setKopiert(s.id); setTimeout(() => setKopiert(null), 2000);
};
const details = async (s: Share) => {
if (offen === s.id) { setOffen(null); return; }
setOffen(s.id);
// Bei jedem Aufklappen neu laden — sonst zeigt die Liste beim zweiten Mal
// einen alten Stand, ohne dass man es merkt.
const r = await fetch(`/api/shares/${s.id}`).then((x) => x.json()).catch(() => ({ zugriffe: [] }));
setZugriffe((z) => ({ ...z, [s.id]: r.zugriffe || [] }));
};
if (laden) return <p className="tw-leer">Einen Moment …</p>;
return (
<div className="tw">
{msg && <div className="tw-toast" role="status">{msg}</div>}
{ladefehler ? (
<div className="tw-leer-box">
<span className="regmark" />
<h2>Die Liste ließ sich nicht laden</h2>
<p>Vielleicht war die Verbindung kurz weg. Deine Links sind davon nicht betroffen.</p>
<p><button className="tw-btn stark" onClick={() => { setLaden(true); laden_(); }}>Noch einmal versuchen</button></p>
</div>
) : !shares.length ? (
<div className="tw-leer-box">
<span className="regmark" />
<h2>Noch nichts geteilt</h2>
<p>In der <a href="/bibliothek">Bibliothek</a> auf <b>Teilen</b> tippen — dann entsteht hier
ein Eintrag, den du jederzeit wieder abschalten kannst.</p>
</div>
) : (
<div className="tw-liste">
{shares.map((s) => {
const z = zustand(s);
return (
<article key={s.id} className={`tw-karte ${z}`}>
<div className="tw-kopf">
<div className="tw-titel">
<h3>{s.title || (s.anzahl > 1 ? `${s.anzahl} Bilder` : 'Ein Bild')}</h3>
<span className={`tw-status ${z}`}>
{z === 'aktiv' ? 'aktiv' : z === 'aus' ? 'abgeschaltet' : 'abgelaufen'}
</span>
{s.password_hash && <span className="tw-schutz" title="Mit Zugangswort geschützt">geschützt</span>}
</div>
<div className="tw-zahlen">
<span><b>{s.views}</b> Aufrufe</span>
<span><b>{s.downloads}</b> Downloads</span>
<span><b>{s.besucher}</b> {s.besucher === 1 ? 'Besucher' : 'Besuchende'}</span>
</div>
</div>
<div className="tw-meta">
{s.anzahl} {s.anzahl === 1 ? 'Bild' : 'Bilder'} ·
{' '}{s.expires_at ? `gültig bis ${datum(s.expires_at)}` : 'ohne Ablauf'} ·
{' '}angelegt {datum(s.created_at)}
{s.last_seen_at && <> · zuletzt gesehen {zeit(s.last_seen_at)}</>}
{!s.allow_download && <> · nur ansehen</>}
</div>
<div className="tw-zeile">
<input className="tw-link" readOnly value={`${location.origin}/s/${s.slug}`}
onFocus={(e) => e.currentTarget.select()} aria-label="Teilen-Link" />
<button className="tw-btn" onClick={() => kopieren(s)}>{kopiert === s.id ? 'Kopiert ✓' : 'Kopieren'}</button>
<a className="tw-btn" href={`/s/${s.slug}`} target="_blank" rel="noopener">Ansehen</a>
</div>
<div className="tw-aktionen">
{z === 'aus'
? <button className="tw-btn stark" onClick={() => patch(s.id, { action: 'unrevoke' })}>Wieder freigeben</button>
: <button className="tw-btn" onClick={() => patch(s.id, { action: 'revoke' })}>Abschalten</button>}
<select className="tw-btn" value="" onChange={(e) => e.target.value !== '' && patch(s.id, { days: Number(e.target.value) })}
aria-label="Gültigkeit ändern">
<option value="" disabled>Gültigkeit ändern …</option>
{TAGE.map((t) => <option key={t.v} value={t.v}>{t.t}</option>)}
</select>
<button className="tw-btn" onClick={() => patch(s.id, { allowDownload: !s.allow_download })}>
{s.allow_download ? 'Download sperren' : 'Download erlauben'}
</button>
<button className="tw-btn" onClick={() => {
const w = prompt(s.password_hash
? 'Neues Zugangswort (leer lassen = Schutz entfernen):'
: 'Zugangswort setzen:');
if (w !== null) patch(s.id, { password: w });
}}>{s.password_hash ? 'Wort ändern' : 'Wort setzen'}</button>
<button className="tw-btn" onClick={() => details(s)}>{offen === s.id ? 'Zugriffe zu' : 'Zugriffe'}</button>
<button className="tw-btn gefahr" onClick={() => loeschen(s)}>Löschen</button>
</div>
{offen === s.id && (
<div className="tw-zugriffe">
{!zugriffe[s.id]?.length ? <p className="tw-fein">Noch keine Zugriffe.</p> : (
<ul>
{zugriffe[s.id].slice(0, 30).map((v, i) => (
<li key={i}>
<span className="tw-art">{v.kind === 'download' ? 'Download' : 'Aufruf'}</span>
<span className="tw-wann">{zeit(v.at)}</span>
<span className="tw-ua">{(v.user_agent || '').slice(0, 60)}</span>
</li>
))}
</ul>
)}
<p className="tw-fein">Festgehalten wird, <b>wann</b> und mit welcher Art Gerät geöffnet
wurde — nicht <b>wer</b>. Von der IP-Adresse wird nur ein verschlüsselter Fingerabdruck
gespeichert, der außerdem an diesen einen Link gebunden ist; über mehrere Links hinweg
lässt sich damit niemand verfolgen. Nach 90 Tagen wird alles gelöscht.</p>
</div>
)}
</article>
);
})}
</div>
)}
<style>{`
.tw{display:grid;gap:14px;}
.tw-leer{color:var(--soft);}
.tw-leer-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
padding:44px 24px;text-align:center;}
.tw-leer-box h2{margin:14px 0 8px;font-size:1.15rem;}
.tw-leer-box p{margin:0;color:var(--ink-soft);line-height:1.6;}
.tw-toast{position:sticky;top:70px;z-index:5;background:var(--ink);color:var(--card);
padding:10px 14px;border-radius:var(--radius-sm);font-size:.9rem;}
.tw-liste{display:grid;gap:12px;}
.tw-karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
padding:16px;display:grid;gap:11px;}
.tw-karte.aus,.tw-karte.abgelaufen{background:var(--paper);}
.tw-kopf{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-start;justify-content:space-between;}
.tw-titel{display:flex;flex-wrap:wrap;gap:8px;align-items:center;min-width:0;}
.tw-titel h3{margin:0;font-size:1rem;letter-spacing:-.01em;overflow-wrap:anywhere;}
.tw-status{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
padding:3px 8px;border-radius:20px;}
.tw-status.aktiv{background:oklch(60% .15 150 / .14);color:oklch(42% .13 150);}
.tw-status.aus{background:oklch(55% .19 25 / .12);color:var(--err);}
.tw-status.abgelaufen{background:var(--paper);color:var(--soft);border:1px solid var(--line);}
.tw-schutz{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
padding:3px 8px;border-radius:20px;background:oklch(72% .15 75 / .16);color:oklch(48% .13 75);}
.tw-zahlen{display:flex;gap:14px;font-size:.78rem;color:var(--soft);white-space:nowrap;}
.tw-zahlen b{color:var(--ink);font-size:.95rem;}
.tw-meta{font-size:.8rem;color:var(--soft);line-height:1.5;}
.tw-zeile{display:flex;gap:7px;}
.tw-link{flex:1;min-width:0;padding:9px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);
background:var(--paper);font-family:var(--font-mono);font-size:12px;color:var(--ink);}
.tw-aktionen{display:flex;flex-wrap:wrap;gap:6px;}
.tw-btn{background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);
padding:8px 11px;font-size:.82rem;cursor:pointer;font-family:inherit;color:var(--ink);
text-decoration:none;display:inline-block;white-space:nowrap;}
.tw-btn:hover{border-color:var(--accent);}
.tw-btn.stark{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;}
.tw-btn.gefahr{color:var(--err);}
.tw-zugriffe{border-top:1px solid var(--line);padding-top:11px;}
.tw-zugriffe ul{list-style:none;margin:0 0 10px;padding:0;display:grid;gap:5px;max-height:260px;overflow:auto;}
.tw-zugriffe li{display:grid;grid-template-columns:82px 118px minmax(0,1fr);gap:8px;
font-size:.76rem;align-items:baseline;}
.tw-art{font-weight:600;}
.tw-wann{font-family:var(--font-mono);font-size:10.5px;color:var(--soft);}
.tw-ua{color:var(--soft);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.tw-fein{font-size:.76rem;color:var(--soft);line-height:1.5;margin:0;}
@media(max-width:640px){
.tw-kopf{flex-direction:column;gap:8px;}
.tw-zahlen{gap:12px;}
.tw-zeile{flex-wrap:wrap;}
.tw-link{flex-basis:100%;}
.tw-btn{flex:1;text-align:center;padding:11px 10px;}
.tw-zugriffe li{grid-template-columns:74px 1fr;}
.tw-ua{display:none;}
}
`}</style>
</div>
);
}
+40 -14
View File
@@ -11,13 +11,17 @@ const user = Astro.locals.user;
const path = Astro.url.pathname;
const active = (href: string) => href === '/' ? path === '/' : path.startsWith(href);
// `kurz` ist die Beschriftung in der unteren Leiste am Handy: Acht Einträge sind
// auf 390 px je rund 49 px breit — „Warteschlange" passt dort nicht.
const nav = [
{ href: '/', label: 'Studio', icon: 'studio' },
{ href: '/bibliothek', label: 'Bibliothek', icon: 'library' },
{ href: '/druck', label: 'Druck', icon: 'print' },
{ href: '/warteschlange', label: 'Warteschlange', icon: 'queue' },
{ href: '/anleitung', label: 'Hilfe', icon: 'help' },
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []),
{ href: '/', label: 'Studio', kurz: 'Studio', icon: 'studio' },
{ href: '/bibliothek', label: 'Bibliothek', kurz: 'Bilder', icon: 'library' },
{ href: '/drucken', label: 'Drucken', kurz: 'Drucken', icon: 'print' },
{ href: '/warteschlange', label: 'Warteschlange', kurz: 'Läuft', icon: 'queue' },
{ href: '/teilen', label: 'Geteilte Links', kurz: 'Geteilt', icon: 'share' },
{ href: '/anleitung', label: 'Hilfe', kurz: 'Hilfe', icon: 'help' },
{ href: '/konto', label: 'Mein Konto', kurz: 'Konto', icon: 'konto' },
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', kurz: 'Admin', icon: 'admin' }] : []),
];
const S = 'fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"';
@@ -27,6 +31,8 @@ const ICONS: Record<string, string> = {
queue: `<svg viewBox="0 0 24 24" ${S}><path d="M9 6h11M9 12h11M9 18h7"/><circle cx="4.5" cy="6" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.15" fill="currentColor" stroke="none"/></svg>`,
admin: `<svg viewBox="0 0 24 24" ${S}><path d="M4 8h9M17 8h3M4 16h3M11 16h9"/><circle cx="15" cy="8" r="2.2"/><circle cx="9" cy="16" r="2.2"/></svg>`,
print: `<svg viewBox="0 0 24 24" ${S}><path d="M7 9V4.5h10V9"/><rect x="3.5" y="9" width="17" height="7" rx="2"/><path d="M7 14h10v5.5H7z"/></svg>`,
share: `<svg viewBox="0 0 24 24" ${S}><circle cx="18" cy="5.5" r="2.6"/><circle cx="6" cy="12" r="2.6"/><circle cx="18" cy="18.5" r="2.6"/><path d="M8.4 10.8 15.6 6.9M8.4 13.2l7.2 3.9"/></svg>`,
konto: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="8.5" r="3.6"/><path d="M4.8 20c.7-3.6 3.7-5.6 7.2-5.6s6.5 2 7.2 5.6"/></svg>`,
help: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.7.3-1 .8-1 1.6v.3"/><circle cx="12" cy="16.7" r="0.6" fill="currentColor" stroke="none"/></svg>`,
};
---
@@ -54,14 +60,18 @@ const ICONS: Record<string, string> = {
</header>
)}
<main class="wrap"><slot /></main>
{/* Ohne Anmeldung gibt es weder Kopf- noch Tab-Leiste — dann braucht es auch
keinen Rahmen mit Platz dafür. Die öffentlichen Seiten (/s/…, /login,
/vorstellung, /passwort) bringen ihr eigenes Layout mit; ein zweites
<main> darum wäre außerdem für Screenreader mehrdeutig. */}
{user ? <main class="wrap"><slot /></main> : <slot />}
{user && (
<nav class="tabbar" aria-label="Hauptnavigation">
{nav.map((n) => (
<a href={n.href} class:list={['tab', { on: active(n.href) }]}>
<span class="ticon" set:html={ICONS[n.icon]} />
<span class="tlabel">{n.label}</span>
<span class="tlabel">{n.kurz}</span>
</a>
))}
</nav>
@@ -86,14 +96,24 @@ const ICONS: Record<string, string> = {
.brand .regmark { transform: translateY(1px); }
.topnav { display: none; }
@media (min-width: 768px) {
.topnav { display: flex; gap: 4px; margin-left: 8px; }
.tnav { padding: 7px 12px; border-radius: 999px; color: var(--ink-soft);
text-decoration: none; font-size: 0.92rem; transition: background .15s, color .15s; }
/* Darf schrumpfen und notfalls scrollen: Mit sieben Einträgen passt die
Leiste bei 820 px sonst nicht mehr und schiebt „Abmelden" aus dem Bild. */
.topnav { display: flex; gap: 2px; margin-left: 8px; min-width: 0;
overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
.topnav::-webkit-scrollbar { display: none; }
.tnav { padding: 7px 10px; border-radius: 999px; color: var(--ink-soft); white-space: nowrap;
text-decoration: none; font-size: 0.88rem; transition: background .15s, color .15s; }
.tnav:hover { color: var(--ink); background: var(--surface-2, var(--paper)); }
.tnav.on { color: var(--accent); background: var(--accent-bg); font-weight: 600; }
}
.logout { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.uname { color: var(--ink-mute, var(--soft)); font-size: 0.9rem; display: none; }
@media (min-width: 1040px) {
.topnav { gap: 4px; }
.tnav { padding: 7px 12px; font-size: 0.92rem; }
}
/* Nicht schrumpfen — der Abmelden-Knopf muss immer erreichbar bleiben. */
.logout { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: none; }
.uname { color: var(--ink-mute, var(--soft)); font-size: 0.9rem; display: none; text-decoration: none; }
.uname:hover { color: var(--accent); }
@media (min-width: 480px) { .uname { display: inline; } }
.logout button { background: none; border: 1px solid var(--line); border-radius: 999px;
padding: 7px 14px; color: var(--ink-soft); cursor: pointer; font-family: inherit; font-size: 0.85rem;
@@ -116,7 +136,13 @@ const ICONS: Record<string, string> = {
.tab.on { color: var(--accent); }
.tab .ticon { width: 22px; height: 22px; display: grid; place-items: center; }
.tab .ticon svg { width: 22px; height: 22px; }
.tlabel { line-height: 1; }
.tlabel { line-height: 1; white-space: nowrap; }
/* Bei acht Einträgen wird es eng — Beschriftung und Symbol etwas kleiner,
die Tippfläche bleibt über 44 px hoch. */
@media (max-width: 420px) {
.tab { padding: 8px 2px 7px; font-size: 9.5px; }
.tab .ticon, .tab .ticon svg { width: 20px; height: 20px; }
}
@media (min-width: 768px) { .tabbar { display: none; } }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+52 -10
View File
@@ -1,8 +1,10 @@
import argon2 from 'argon2';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { one } from './db';
import { one, query } from './db';
export interface SessionUser { uid: string; role: 'user' | 'admin'; name: string; }
/** Was tatsächlich im Cookie steht — inklusive der Angaben, die es entwertbar machen. */
interface SessionPayload extends SessionUser { iat: number; ep: number }
const COOKIE = 'kb_session';
const MAX_AGE = 60 * 60 * 24 * 30; // 30 Tage
@@ -20,8 +22,8 @@ function sign(data: string): string {
return createHmac('sha256', secret()).update(data).digest('base64url');
}
export function makeSessionCookie(u: SessionUser): string {
const payload = Buffer.from(JSON.stringify({ ...u, iat: Math.floor(Date.now() / 1000) }))
export function makeSessionCookie(u: SessionUser, epoch = 0): string {
const payload = Buffer.from(JSON.stringify({ ...u, iat: Math.floor(Date.now() / 1000), ep: epoch }))
.toString('base64url');
const value = `${payload}.${sign(payload)}`;
return `${COOKIE}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${MAX_AGE}`;
@@ -30,7 +32,18 @@ export function makeSessionCookie(u: SessionUser): string {
export const clearSessionCookie = () =>
`${COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
export function readSession(cookieHeader?: string | null): SessionUser | null {
/**
* Cookie prüfen — Signatur **und** Alter.
*
* Die Alterprüfung ist nicht Kosmetik: `Max-Age` ist eine Bitte an den Browser,
* keine Regel für den Server. Ohne sie gilt ein einmal abgeflossenes Cookie
* unbegrenzt, auch Jahre später und auch per curl.
*
* Ob das Cookie **inhaltlich** noch gilt (Passwort gewechselt, Rolle entzogen,
* Konto gelöscht), lässt sich hier nicht entscheiden — dafür braucht es die
* Datenbank. Das erledigt `verifySession` in der Middleware.
*/
export function readSession(cookieHeader?: string | null): (SessionUser & { ep: number }) | null {
if (!cookieHeader) return null;
const raw = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
if (!raw) return null;
@@ -40,19 +53,48 @@ export function readSession(cookieHeader?: string | null): SessionUser | null {
const a = Buffer.from(sig), b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const obj = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
return { uid: obj.uid, role: obj.role, name: obj.name };
const obj = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as SessionPayload;
if (!obj.uid || typeof obj.iat !== 'number') return null;
if (Math.floor(Date.now() / 1000) - obj.iat > MAX_AGE) return null; // serverseitig abgelaufen
return { uid: obj.uid, role: obj.role, name: obj.name, ep: Number(obj.ep) || 0 };
} catch {
return null;
}
}
export async function login(username: string, password: string): Promise<SessionUser | null> {
const u = await one<{ id: string; password_hash: string; role: 'user' | 'admin'; display_name: string }>(
'SELECT id, password_hash, role, display_name FROM users WHERE username=$1', [username]);
/**
* Gegenprobe in der Datenbank: Gibt es das Konto noch, gilt der Sitzungsstand
* noch, und welche Rolle hat es **jetzt**?
*
* Die Rolle kommt bewusst aus der Datenbank und nicht aus dem Cookie — sonst
* behielte ein herabgestufter Admin seine Rechte bis zum Ablauf des Cookies.
* Ein Punkt-Lookup über den Primärschlüssel, den `tokenUser` ohnehin macht.
*/
export async function verifySession(s: (SessionUser & { ep: number }) | null): Promise<SessionUser | null> {
if (!s?.uid) return null;
const u = await one<{ role: 'user' | 'admin'; display_name: string | null; username: string; session_epoch: number }>(
'SELECT role, display_name, username, session_epoch FROM users WHERE id = $1', [s.uid]);
if (!u) return null; // Konto gelöscht
if ((u.session_epoch ?? 0) !== s.ep) return null; // Passwort gewechselt oder Rolle geändert
return { uid: s.uid, role: u.role, name: u.display_name || u.username };
}
/** Alle Sitzungen eines Kontos entwerten — nach Passwortwechsel oder Rollenänderung. */
export async function bumpSessionEpoch(userId: string): Promise<void> {
await query('UPDATE users SET session_epoch = session_epoch + 1 WHERE id = $1', [userId]);
}
export async function login(
username: string, password: string,
): Promise<{ user: SessionUser; epoch: number } | null> {
const u = await one<{ id: string; password_hash: string; role: 'user' | 'admin'; display_name: string; username: string; session_epoch: number }>(
'SELECT id, password_hash, role, display_name, username, session_epoch FROM users WHERE username=$1', [username]);
if (!u) return null;
if (!(await verifyPassword(u.password_hash, password))) return null;
return { uid: u.id, role: u.role, name: u.display_name || username };
return {
user: { uid: u.id, role: u.role, name: u.display_name || u.username },
epoch: u.session_epoch ?? 0,
};
}
// --- einfacher In-Memory-Ratelimiter: max 5 Fehlversuche / 15 Min pro IP ---
+90
View File
@@ -0,0 +1,90 @@
// Zuschnitt-Mathematik — **ohne jede Abhängigkeit**, wie printlayout.ts.
//
// Diese Datei existiert, weil dieselbe Rechnung vorher zweimal im Projekt stand:
// einmal im Browser (Vorschau und Zuschnitt-Fenster in PrintApp.tsx) und einmal
// auf dem Server (printrender.ts, für das PDF). Beide rechneten gleich — aber
// nichts hielt sie gegeneinander. Zwei Kopien derselben Formel driften früher
// oder später auseinander, und dann zeigt die Vorschau etwas anderes, als
// gedruckt wird. Jetzt gibt es nur noch eine Quelle, und ein Test hält den
// Server-Einstieg gegen den Browser-Einstieg.
//
// Alle Ausschnitte sind **relativ** (0…1 bezogen auf das Quellbild) und damit
// unabhängig von der Auflösung: derselbe Zuschnitt gilt für das kleine
// Vorschaubild wie für das Original.
export interface CropRel { x: number; y: number; w: number; h: number }
export type FitMode = 'cover' | 'contain';
/** Größter Ausschnitt im Zielverhältnis, der **innerhalb** des Bildes liegt. */
export function coverSize(natW: number, natH: number, aspect: number): { w: number; h: number } {
const imgA = natW / natH;
return imgA > aspect ? { w: aspect / imgA, h: 1 } : { w: 1, h: imgA / aspect };
}
/**
* Kleinster Ausschnitt im Zielverhältnis, der das **ganze** Bild enthält.
* Ragt bewusst über den Bildrand hinaus (w bzw. h > 1) — dort entsteht der Rand.
*/
export function containSize(natW: number, natH: number, aspect: number): { w: number; h: number } {
const imgA = natW / natH;
return imgA > aspect ? { w: 1, h: imgA / aspect } : { w: aspect / imgA, h: 1 };
}
/** Um denselben Mittelpunkt legen wie ein vorhandener Ausschnitt (sonst mittig). */
export function centred(w: number, h: number, around?: CropRel | null): CropRel {
const cx = around ? around.x + around.w / 2 : 0.5;
const cy = around ? around.y + around.h / 2 : 0.5;
return { x: cx - w / 2, y: cy - h / 2, w, h };
}
/** Grenzen fürs Herein- und Herauszoomen. */
export interface CropLimits { min?: number; max?: number }
/**
* Hält den Ausschnitt in sinnvollen Grenzen.
* Bei `cover` bleibt er im Bild. Bei `contain` darf er hinausragen — aber nicht
* so weit, dass vom Motiv nichts mehr zu sehen wäre (mindestens 5 % müssen im
* Rahmen bleiben).
*
* `min`/`max` begrenzen, wie weit der Nutzer herein- und herauszoomen darf.
* Der **Start**-Ausschnitt muss davon ausgenommen sein: Ein sehr hohes Bild in
* einem sehr breiten Format braucht rechnerisch mehr als das Achtfache — und
* ein sehr breites Format aus einem sehr hohen Bild weniger als 2 %. Griffe die
* Grenze dort, stimmte das Seitenverhältnis nicht mehr, und das Bild würde doch
* beschnitten, obwohl „Rand lassen" gewählt ist. `baseCrop` weitet die Grenzen
* deshalb auf das, was das Format tatsächlich braucht.
*/
export function clampCrop(c: CropRel, fit: FitMode = 'cover', limits: CropLimits = {}): CropRel {
const lo = limits.min ?? 0.02;
const hi = limits.max ?? 8;
if (fit === 'contain') {
const w = Math.min(hi, Math.max(lo, c.w));
const h = Math.min(hi, Math.max(lo, c.h));
return {
w, h,
x: Math.min(1 - 0.05 * w, Math.max(-w + 0.05 * w, c.x)),
y: Math.min(1 - 0.05 * h, Math.max(-h + 0.05 * h, c.y)),
};
}
const w = Math.min(Math.min(1, hi), Math.max(lo, c.w));
const h = Math.min(Math.min(1, hi), Math.max(lo, c.h));
return { w, h, x: Math.min(1 - w, Math.max(0, c.x)), y: Math.min(1 - h, Math.max(0, c.y)) };
}
/** Grenzen, die einen bestimmten Start-Ausschnitt garantiert durchlassen. */
export const limitsFor = (c: { w: number; h: number }): CropLimits =>
({ min: Math.min(0.02, c.w, c.h), max: Math.max(8, c.w, c.h) });
/**
* Der Ausschnitt, mit dem ein Bild in ein Zielformat startet.
* `around` erhält den Bildmittelpunkt, wenn der Nutzer schon verschoben hatte
* und danach das Format wechselt.
*/
export function baseCrop(
natW: number, natH: number, aspect: number, fit: FitMode = 'cover', around?: CropRel | null,
): CropRel {
const { w, h } = fit === 'contain' ? containSize(natW, natH, aspect) : coverSize(natW, natH, aspect);
// Die Zoom-Grenzen dürfen den Start-Ausschnitt nie verformen — sonst wäre das
// Seitenverhältnis dahin und es würde doch beschnitten (siehe clampCrop).
return clampCrop(centred(w, h, around), fit, limitsFor({ w, h }));
}
+72
View File
@@ -0,0 +1,72 @@
// Ratenbegrenzung und die Frage, wem man die Absender-Adresse glaubt.
//
// Bewusst ohne Abhängigkeit und im Speicher: Klarbild läuft als **ein** Node-
// Prozess. Käme je ein zweiter dazu, gehört das in die Datenbank oder nach
// Redis — dann ist diese Datei die eine Stelle, die man austauscht.
/**
* Die tatsächliche Adresse des Aufrufers.
*
* Wichtig und leicht zu übersehen: Astros `clientAddress` nimmt den **ersten**
* Wert aus `X-Forwarded-For`. Diesen Wert schickt der Angreifer selbst — ein
* Reverse Proxy hängt die echte Adresse hinten an. Jede Begrenzung, die auf
* `clientAddress` baut, lässt sich damit umgehen, indem man den Header pro
* Anfrage variiert.
*
* Deshalb wird von rechts gezählt: Bei `TRUSTED_PROXIES=1` (Standard, ein
* vorgelagerter Proxy wie Traefik) ist der letzte Eintrag der, den der Proxy
* selbst gesetzt hat — den kann niemand von außen fälschen. Ohne Proxy
* (`TRUSTED_PROXIES=0`) wird der Header gar nicht beachtet.
*/
export function echteIp(request: Request, fallback?: string | null): string {
const vertraut = Number(process.env.TRUSTED_PROXIES ?? '1');
if (!Number.isFinite(vertraut) || vertraut <= 0) return fallback || 'unbekannt';
const kette = (request.headers.get('x-forwarded-for') || '')
.split(',').map((s) => s.trim()).filter(Boolean);
if (!kette.length) return fallback || 'unbekannt';
// Von rechts so viele Sprünge zurück, wie es vertrauenswürdige Proxys gibt.
const i = Math.max(0, kette.length - vertraut);
return kette[i] || fallback || 'unbekannt';
}
interface Eintrag { n: number; bis: number }
const eimer = new Map<string, Eintrag>();
let letzteReinigung = 0;
/** Abgelaufene Einträge entfernen — gezielt, nicht alles auf einmal. */
function aufraeumen(jetzt: number): void {
if (jetzt - letzteReinigung < 60_000) return;
letzteReinigung = jetzt;
for (const [k, e] of eimer) if (e.bis < jetzt) eimer.delete(k);
// Notbremse, falls sich trotzdem zu viel ansammelt: die ältesten zuerst.
if (eimer.size > 50_000) {
const nach = [...eimer.entries()].sort((a, b) => a[1].bis - b[1].bis);
for (const [k] of nach.slice(0, eimer.size - 25_000)) eimer.delete(k);
}
}
/**
* Einen Versuch zählen. Liefert `true`, wenn die Grenze überschritten ist.
*
* Anders als vorher wird hier **nicht** die ganze Tabelle geleert, wenn sie
* groß wird — damit hätte ein Angreifer die Zähler aller anderen mit
* zurückgesetzt.
*/
export function zuOft(schluessel: string, max: number, fensterMs: number): boolean {
const jetzt = Date.now();
aufraeumen(jetzt);
const e = eimer.get(schluessel);
if (!e || e.bis < jetzt) { eimer.set(schluessel, { n: 1, bis: jetzt + fensterMs }); return false; }
e.n++;
return e.n > max;
}
/** Zähler zurücksetzen — nach einem erfolgreichen Vorgang. */
export const zuruecksetzen = (schluessel: string): void => { eimer.delete(schluessel); };
/** Wie viele Versuche im laufenden Fenster noch frei sind. */
export function restVersuche(schluessel: string, max: number): number {
const e = eimer.get(schluessel);
if (!e || e.bis < Date.now()) return max;
return Math.max(0, max - e.n);
}
+177
View File
@@ -0,0 +1,177 @@
// E-Mail-Versand über den eigenen Postfach-Anbieter (SMTP).
//
// Bewusst über den vorhandenen Postausgangsserver statt über einen Versanddienst:
// Die Domain liegt ohnehin dort, SPF und DKIM stimmen dadurch von selbst, und es
// kommt kein weiterer Anbieter ins Spiel, der die Mails zu sehen bekäme.
//
// Der Zugang steht verschlüsselt in `settings` (AES-256-GCM wie OpenRouter-Key
// und Picdrop-Passwort) und **nie** im Repository.
import nodemailer from 'nodemailer';
import { one } from './db.ts';
import { decrypt } from './crypto.ts';
export interface MailCfg {
host: string; port: number; secure: boolean;
user: string; password: string; from: string;
}
export interface MailQuelle { cfg: MailCfg; quelle: 'datenbank' | 'umgebung' }
/**
* SMTP-Zugang — erst aus den Einstellungen, sonst aus der Umgebung.
*
* Dieselbe Rangfolge wie beim OpenRouter-Schlüssel: Was jemand im Admin
* einträgt, gewinnt. Die Umgebungsvariablen sind der Startwert, damit der
* Versand nach einem frischen Aufsetzen sofort läuft, ohne dass jemand
* Zugangsdaten abtippen muss — und damit das Passwort **nur** dort und in der
* verschlüsselten Spalte liegt, nie im Repository.
*/
export async function loadMailSource(): Promise<MailQuelle | null> {
const s = await one<any>(
`SELECT smtp_host, smtp_port, smtp_secure, smtp_user, smtp_password, smtp_from FROM settings WHERE id=1`);
if (s?.smtp_host && s?.smtp_user && s?.smtp_password) {
try {
return { quelle: 'datenbank', cfg: {
host: s.smtp_host,
port: Number(s.smtp_port) || 465,
secure: s.smtp_secure !== false,
user: s.smtp_user,
password: decrypt(s.smtp_password),
from: s.smtp_from || s.smtp_user,
} };
} catch {
// Entschlüsselung gescheitert (z. B. nach einem Wechsel des
// ENCRYPTION_KEY) — dann lieber die Umgebung als gar nichts.
console.error('[mail] Gespeichertes Passwort nicht lesbar, weiche auf die Umgebung aus.');
}
}
const e = process.env;
if (e.SMTP_HOST && e.SMTP_USER && e.SMTP_PASSWORD) {
return { quelle: 'umgebung', cfg: {
host: e.SMTP_HOST,
port: Number(e.SMTP_PORT) || 465,
secure: e.SMTP_SECURE !== 'false',
user: e.SMTP_USER,
password: e.SMTP_PASSWORD,
from: e.SMTP_FROM || e.SMTP_USER,
} };
}
return null;
}
/** Nur der Zugang, ohne die Herkunft. */
export async function loadMailConfig(): Promise<MailCfg | null> {
return (await loadMailSource())?.cfg ?? null;
}
export const mailReady = async (): Promise<boolean> => !!(await loadMailConfig());
export interface MailInput {
to: string;
subject: string;
/** Fließtext. Wird auch als Textfassung verschickt. */
text: string;
/** Optionaler HTML-Körper; ohne Angabe wird der Text schlicht gerahmt. */
html?: string;
}
export const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/**
* Schlichter, gut lesbarer HTML-Rahmen. Keine externen Bilder, keine
* Zählpixel, keine Schriftarten von fremden Servern — das würde in vielen
* Postfächern ohnehin blockiert und wäre gegenüber dem Empfänger unhöflich.
*/
export function mailLayout(titel: string, absaetze: string[], knopf?: { text: string; url: string }): string {
const p = absaetze.map((a) => `<p style="margin:0 0 14px;line-height:1.6">${a}</p>`).join('');
const btn = knopf
? `<p style="margin:22px 0"><a href="${esc(knopf.url)}" style="display:inline-block;background:#1B3BE0;color:#fff;
text-decoration:none;padding:12px 20px;border-radius:4px;font-weight:600">${esc(knopf.text)}</a></p>
<p style="margin:0 0 14px;font-size:13px;color:#6A685D">Falls der Knopf nicht funktioniert:<br>
<a href="${esc(knopf.url)}" style="color:#1B3BE0;word-break:break-all">${esc(knopf.url)}</a></p>`
: '';
return `<!doctype html><html lang="de"><body style="margin:0;background:#EAEAE3;padding:24px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#16150F;font-size:15px">
<div style="max-width:560px;margin:0 auto;background:#FBFBF7;border:1px solid #D7D6CC;border-radius:4px;padding:28px">
<div style="font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:#6A685D;margin-bottom:18px">Klarbild</div>
<h1 style="font-size:20px;margin:0 0 16px;line-height:1.3">${esc(titel)}</h1>
${p}${btn}
</div>
<div style="max-width:560px;margin:14px auto 0;font-size:12px;color:#6A685D;text-align:center">
Diese Nachricht kommt von deiner eigenen Klarbild-Installation.
</div></body></html>`;
}
/**
* Verschickt eine Mail. Wirft, wenn kein Zugang konfiguriert ist — die
* Aufrufer entscheiden, ob das ein Fehler oder nur ein Hinweis ist.
*/
export async function sendMail(m: MailInput): Promise<void> {
const cfg = await loadMailConfig();
if (!cfg) throw new Error('Kein E-Mail-Zugang konfiguriert.');
const tx = nodemailer.createTransport({
host: cfg.host, port: cfg.port, secure: cfg.secure,
auth: { user: cfg.user, pass: cfg.password },
// Kein endloses Hängen, wenn der Server nicht antwortet.
connectionTimeout: 15000, greetingTimeout: 10000, socketTimeout: 20000,
});
try {
await tx.sendMail({
from: cfg.from, to: m.to, subject: m.subject,
text: m.text,
html: m.html || mailLayout(m.subject, m.text.split('\n\n').map(esc)),
});
} finally {
tx.close();
}
}
/** Wie `sendMail`, meldet Fehler aber nur ins Log. Für Benachrichtigungen. */
export async function trySendMail(m: MailInput): Promise<boolean> {
try { await sendMail(m); return true; }
catch (e: any) { console.error('[mail] Versand fehlgeschlagen:', e?.message || e); return false; }
}
/** Verbindung prüfen, ohne etwas zu verschicken — für den Testknopf im Admin. */
export async function testMail(): Promise<{ ok: boolean; message: string }> {
const cfg = await loadMailConfig();
if (!cfg) return { ok: false, message: 'Kein E-Mail-Zugang konfiguriert.' };
const tx = nodemailer.createTransport({
host: cfg.host, port: cfg.port, secure: cfg.secure,
auth: { user: cfg.user, pass: cfg.password },
connectionTimeout: 15000, greetingTimeout: 10000,
});
try {
await tx.verify();
return { ok: true, message: `Verbindung zu ${cfg.host}:${cfg.port} erfolgreich.` };
} catch (e: any) {
// Anbieter-Meldungen können den Benutzernamen enthalten — kurz halten.
const roh = String(e?.message || e);
const kurz = /auth/i.test(roh) ? 'Anmeldung abgelehnt — Benutzername oder Passwort prüfen.'
: /timeout|ETIMEDOUT|ECONNREFUSED/i.test(roh) ? 'Server nicht erreichbar — Host und Port prüfen.'
: /certificate|self.signed/i.test(roh) ? 'Zertifikatsfehler — Verschlüsselung prüfen (Port 465 = SSL, 587 = STARTTLS).'
: 'Verbindung fehlgeschlagen.';
return { ok: false, message: kurz };
} finally {
tx.close();
}
}
/**
* Grobe Plausibilitätsprüfung einer Adresse.
*
* Steuerzeichen werden am **rohen** Wert geprüft, nicht am getrimmten: In
* JavaScript passt `$` ohne `m`-Flag auch vor einem abschließenden
* Zeilenumbruch, und `trim()` würde ihn ohnehin wegnehmen. Beides zusammen
* hätte `"a@b.de\n"` durchgelassen — und genau damit fängt jede
* Kopfzeilen-Einschleusung an.
*/
export const istMail = (v: string): boolean => {
const roh = String(v ?? '');
if (/[\r\n\t\0]/.test(roh)) return false;
const s = roh.trim();
return !!s && s.length <= 254 && /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/.test(s);
};
+19
View File
@@ -110,6 +110,23 @@ export async function purgePrivate(olderThanMin = 60): Promise<{ deleted: number
return { deleted: rows.length };
}
/**
* Aufzeichnungen und abgelaufene Rücksetz-Links wegräumen.
*
* Beide Tabellen wuchsen vorher unbegrenzt. Bei `share_views` ist das nicht nur
* eine Platzfrage: Es sind Aufzeichnungen darüber, wann jemand ein Bild
* angesehen hat. Sie länger aufzuheben, als man sie braucht, wäre schlicht
* unnötig — 90 Tage reichen für „wurde das eigentlich angeschaut?".
*/
export async function purgeTracking(tage = 90): Promise<{ views: number; resets: number }> {
const v = await query<{ id: string }>(
`DELETE FROM share_views WHERE at < now() - ($1 || ' days')::interval RETURNING id`, [String(tage)]);
const r = await query<{ token_hash: string }>(
`DELETE FROM password_resets
WHERE expires_at < now() - interval '7 days' RETURNING token_hash`);
return { views: v.length, resets: r.length };
}
let timer: NodeJS.Timeout | null = null;
/** Wartungs-Lauf: Retention + private Sessions vergessen. */
export function startMaintenance(): void {
@@ -123,6 +140,8 @@ export function startMaintenance(): void {
}
const p = await purgePrivate(60);
if (p.deleted) console.log(`[maintenance] Privat: ${p.deleted} Ergebnisse vergessen.`);
const t = await purgeTracking(90);
if (t.views || t.resets) console.log(`[maintenance] Aufgeräumt: ${t.views} Zugriffe, ${t.resets} alte Rücksetz-Links.`);
} catch (e) { console.error('[maintenance]', e); }
};
timer = setInterval(tick, 30 * 60 * 1000); // alle 30 min (auch für private Purge)
+244
View File
@@ -0,0 +1,244 @@
// Dateinamen für Klarbild-Ergebnisse — bewusst **ohne jede Abhängigkeit**, damit
// die Regeln testbar bleiben und überall dieselben sind (Auftragsverarbeitung,
// Nachbenennung, Tests).
//
// Schema: `JJJJ-MM-TT_HHMMSS_<motiv>_<format>_<kürzel>.<ext>`
// 2026-08-20_143207_lea-badge_the-frame_a3f9.jpg
//
// Warum: Der Zeitstempel steht vorn, also sortiert jede Dateiliste — Picdrop,
// Finder, NAS — von allein chronologisch. Eindeutig wird der Name durch die
// Sekunde **und** ein aus der Item-ID abgeleitetes Kürzel. Vorher hieß es nur
// `JJJJ-MM-TT_<motiv>_<format>`, und zwei Badges desselben Motivs am selben Tag
// bekamen denselben Namen — beim Ausliefern überschrieb der zweite den ersten.
/** Klein, mit Bindestrichen, ohne Umlaut-Probleme. */
export function slugify(s: string): string {
return (s || '')
.toLowerCase()
.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c))
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
}
const GENERIC = new Set(['', 'foto', 'photo', 'bild', 'image', 'img', 'telegram', 'unbenannt', 'klarbild', 'screenshot', 'pxl', 'whatsapp']);
/** Ein von uns selbst erzeugter Name — Zeitstempel vorn, Kürzel hinten.
* Wird gebraucht, um beim Weiterverarbeiten den eigenen Stempel wieder abzuziehen
* und um zu erkennen, ob ein Name schon im neuen Schema steht. */
// Alle Bestandteile sind Slugs (a–z, 0–9, Bindestrich) und enthalten daher nie
// einen Unterstrich — das Muster zerlegt den Namen also eindeutig in fünf Teile.
export const KLARBILD_NAME =
/^(\d{4}-\d{2}-\d{2})_(\d{6})_([a-z0-9-]+)_([a-z0-9-]+)_([0-9a-z]{4})(?:-(\d+))?$/;
/** Motiv aus einem beliebigen (evtl. schon verarbeiteten) Namen herausschälen:
* Datumspräfixe, UUIDs und lange Hex-Ketten entfernen, damit keine Doppelungen entstehen. */
function cleanMotif(name: string | null): string {
let s = (name || '').replace(/\.[^.]+$/, '');
// Zuerst den eigenen Stempel gezielt abziehen — nur wenn der Name wirklich unserem
// Muster entspricht. Pauschal alle Sechsergruppen zu entfernen wäre zu grob und
// würde echte Motive wie „raum104235" zerlegen.
const m = KLARBILD_NAME.exec(s);
if (m) s = m[3];
// Ohne \b arbeiten — Wortgrenzen greifen an Unterstrichen nicht (…_2026-07-24…).
s = s.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ' '); // UUID
s = s.replace(/\d{4}-\d{2}-\d{2}/g, ' '); // Datum (auch mehrfach)
s = s.replace(/[0-9a-f]{16,}/gi, ' '); // lange Hex-Ketten
return slugify(s);
}
/** Zeitzone für den Stempel im Dateinamen. Bewusst die Zeit, in der Till lebt —
* ein Bild von 0:30 Uhr soll nicht auf den Vortag datiert sein (UTC wäre so). */
const TZ = process.env.KLARBILD_TZ || 'Europe/Berlin';
const STAMP_FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
});
/** „2026-08-20_143207" — lexikografisch sortiert = chronologisch sortiert. */
export function timeStamp(at: Date = new Date()): string {
const p: Record<string, string> = {};
for (const { type, value } of STAMP_FMT.formatToParts(at)) p[type] = value;
// Manche Node-Fassungen liefern „24" statt „00" für Mitternacht.
const hh = p.hour === '24' ? '00' : p.hour;
return `${p.year}-${p.month}-${p.day}_${hh}${p.minute}${p.second}`;
}
/** Vierstelliges Kürzel, **stabil aus der Item-ID abgeleitet**.
* Stabil ist wichtig: Ein zweiter Lauf der Umbenennung muss denselben Namen
* ergeben, sonst wandern die Dateien bei jedem Lauf erneut. */
export function shortTag(id?: string | null): string {
const hex = String(id || '').replace(/[^0-9a-f]/gi, '').toLowerCase();
if (hex.length >= 4) return hex.slice(0, 4);
return Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0');
}
export interface NameParts {
/** Ursprünglicher Dateiname, aus dem das Motiv geschält wird. */
originalName?: string | null;
formatToken?: string;
ext?: string;
/** Zeitpunkt für den Stempel — bei der Nachbenennung das `created_at` des Bildes. */
at?: Date;
/** Item-ID für das stabile Kürzel. */
id?: string | null;
/** Zähler, falls der Name trotz allem schon vergeben ist (2, 3, …). */
seq?: number;
}
/**
* Eindeutiger, chronologisch sortierbarer Dateiname:
* `JJJJ-MM-TT_HHMMSS_<motiv>_<format>_<kürzel>.<ext>`
* z. B. `2026-08-20_143207_lea_the-frame_a3f9.png`
*
* Warum so: Der Name beginnt mit dem Zeitstempel, also sortiert jede Dateiliste
* (Picdrop, Finder, NAS) von allein chronologisch. Eindeutig wird er durch die
* Sekunde **und** das aus der Item-ID abgeleitete Kürzel — zwei Bilder desselben
* Motivs im selben Format am selben Tag überschreiben sich damit nicht mehr.
*/
export function buildResultFilename(
originalName: string | null, formatToken: string, ext = 'png', opts: Omit<NameParts, 'originalName' | 'formatToken' | 'ext'> = {},
): string {
const raw = cleanMotif(originalName);
const motif = (!raw || GENERIC.has(raw)) ? 'klarbild' : raw;
const fmt = slugify(formatToken) || 'original';
const seq = opts.seq && opts.seq > 1 ? `-${opts.seq}` : '';
return `${timeStamp(opts.at)}_${motif}_${fmt}_${shortTag(opts.id)}${seq}.${ext}`;
}
export interface OldName { motif: string; format: string; ext: string }
/**
* Einen vorhandenen Namen in Motiv, Format und Endung zerlegen — für die
* Nachbenennung, damit die schon gewählten Bezeichnungen erhalten bleiben und
* nur der Zeitstempel plus Kürzel neu gesetzt werden.
*/
export function splitOldName(filename: string | null): OldName {
const raw = String(filename || '');
const dot = raw.lastIndexOf('.');
const ext = dot > 0 ? raw.slice(dot + 1).toLowerCase() : 'png';
const stem = dot > 0 ? raw.slice(0, dot) : raw;
// Schon im neuen Schema: Motiv und Format unverändert übernehmen.
const neu = KLARBILD_NAME.exec(stem);
if (neu) return { motif: neu[3], format: neu[4], ext };
const parts = stem.split('_').filter(Boolean);
if (/^\d{4}-\d{2}-\d{2}$/.test(parts[0] || '')) parts.shift(); // altes Datumspräfix
const format = parts.length > 1 ? slugify(parts.pop()!) : 'original';
const motif = slugify(parts.join('-')) || 'klarbild';
return { motif: motif || 'klarbild', format: format || 'original', ext };
}
/** Der Name, den ein bestehendes Bild nach dem neuen Schema tragen müsste. */
export function newNameFor(it: { id: string; filename: string | null; created_at: Date | string }, seq = 1): string {
const { motif, format, ext } = splitOldName(it.filename);
const at = it.created_at instanceof Date ? it.created_at : new Date(it.created_at);
const s = seq > 1 ? `-${seq}` : '';
return `${timeStamp(at)}_${motif}_${format}_${shortTag(it.id)}${s}.${ext}`;
}
/** Formatschlüssel für den Dateinamen (aus dem output_format). */
export function formatToken(outputFormat: string | undefined): string {
if (!outputFormat || outputFormat === 'keep') return 'original';
if (outputFormat === 'theframe') return 'the-frame';
if (outputFormat === 'hochformat') return 'hochformat';
return outputFormat.toLowerCase(); // 30x40, a4, sticker, …
}
/* ---------------------------------------------------------------------------
* Namen für Druckdateien
*
* Dieselbe Regel wie für Bibliotheksergebnisse, aus demselben Grund: Zwei
* Druckbogen am selben Tag hießen beide `2026-09-01_druckbogen.pdf` — der
* Browser hängte „(1)" an, und wer die Datei ins NAS oder in einen Ordner
* legte, überschrieb den ersten Bogen. Genau der Fehler, der im August für
* die Bilder behoben wurde.
* ------------------------------------------------------------------------ */
/** Kurzzeichen fürs Papier: „A4" → `a4`, sonst das Maß in mm (`210x297`). */
export function paperToken(paperId: string | null | undefined, wMm: number, hMm: number): string {
const s = slugify(String(paperId || ''));
if (s && s.length <= 12) return s;
return `${Math.round(wMm)}x${Math.round(hMm)}`;
}
/**
* Name für einen Druckbogen:
* `2026-09-01_143207_druck-9-teile_a4_4eca.pdf`
*
* Das Kürzel stammt aus der Kennung des Verlaufseintrags — die Datei lässt
* sich also einem Eintrag unter „Zuletzt gedruckt" zuordnen.
*/
export function buildSheetFilename(opts: {
/** Wunschname des Aufrufers, sonst wird aus Teilen und Bogen einer gebaut. */
name?: string | null;
paperId?: string | null;
wMm: number; hMm: number;
pieces: number; pages: number;
runId?: string | null;
at?: Date;
}): string {
const teile = opts.pieces === 1 ? 'druck-1-teil' : `druck-${opts.pieces}-teile`;
const bogen = opts.pages > 1 ? `-${opts.pages}-bogen` : '';
const motiv = slugify(String(opts.name || '')) || `${teile}${bogen}`;
return buildResultFilename(motiv, paperToken(opts.paperId, opts.wMm, opts.hMm), 'pdf',
{ id: opts.runId, at: opts.at });
}
/**
* Name für ein einzeln aufs Maß gebrachtes Bild:
* `2026-09-01_143207_lea-badge_13x18_25b3.jpg`
* Das Motiv kommt aus der Quelldatei, das Kürzel aus der Bild-Kennung.
*/
export function buildSingleFilename(opts: {
name?: string | null;
sourceName?: string | null;
wMm: number; hMm: number;
ext: string;
id?: string | null;
at?: Date;
}): string {
const motiv = String(opts.name || '') || String(opts.sourceName || '');
const mass = `${trimZahl(opts.wMm)}x${trimZahl(opts.hMm)}`;
return buildResultFilename(motiv, mass, opts.ext, { id: opts.id, at: opts.at });
}
/** 45 → „45", 112.5 → „112-5" (im Dateinamen ist ein Punkt die Endung). */
function trimZahl(n: number): string {
return String(Math.round(n * 10) / 10).replace('.', '-');
}
/**
* Dateinamen aus einem `Content-Disposition`-Kopf lesen.
*
* Der Browser bekommt den Namen vom Server — der Client soll ihn **nicht neu
* erfinden**, sonst gibt es zwei Wahrheiten. Genau das war der Fall: Der Server
* schickte einen Namen, und die Oberfläche überschrieb ihn mit einem eigenen,
* der nur das Datum enthielt.
*/
export function nameAusKopf(header: string | null | undefined, fallback: string): string {
const h = String(header || '');
// RFC 5987 (filename*=UTF-8''…) hat Vorrang vor dem einfachen filename=.
const stern = /filename\*=UTF-8''([^;]+)/i.exec(h);
if (stern) { try { return sicher(decodeURIComponent(stern[1])) || fallback; } catch { /* weiter unten */ } }
const einfach = /filename="?([^";]+)"?/i.exec(h);
if (einfach) return sicher(einfach[1]) || fallback;
return fallback;
}
/** Ein aus einem Kopf gelesener Name darf niemals einen Pfad enthalten. */
function sicher(n: string): string {
return n.replace(/[\\/\r\n\0]/g, '').replace(/^\.+/, '').trim().slice(0, 120);
}
/**
* Name für ein ZIP aus einer Auswahl in der Bibliothek:
* `2026-09-01_143207_auswahl-12-bilder_a3f9.zip`
*
* Vorher hieß jedes `klarbild_2026-09-01.zip` — zwei verschiedene Auswahlen am
* selben Tag im selben Ordner, und die zweite überschrieb die erste.
*/
export function buildAuswahlZipName(anzahl: number, at: Date = new Date()): string {
const was = anzahl === 1 ? 'auswahl-1-bild' : `auswahl-${anzahl}-bilder`;
return `${timeStamp(at)}_${was}_${shortTag(null)}.zip`;
}
+6 -2
View File
@@ -45,8 +45,12 @@ export async function testNas(cfg: NasCfg): Promise<{ ok: boolean; message: stri
}
async function uploadToNas(cfg: NasCfg, remoteDir: string, filename: string, buf: Buffer): Promise<void> {
const dir = posixpath.join(cfg.basePath || '/', remoteDir);
const finalPath = posixpath.join(dir, filename);
// Gleiche Prüfung wie bei Picdrop, aus demselben Grund: `posixpath.join` schützt
// nicht vor `..` — der Dateiname kommt aus der Datenbank und wird deshalb hier,
// unmittelbar vor dem Schreiben, geprüft.
const { safeSegment } = await import('./picdrop');
const dir = posixpath.join(cfg.basePath || '/', safeSegment(remoteDir, 'Galerie'));
const finalPath = posixpath.join(dir, safeSegment(filename, 'Dateiname'));
const { Readable } = await import('node:stream');
if (cfg.protocol === 'sftp') {
const SftpClient = (await import('ssh2-sftp-client')).default;
+98 -18
View File
@@ -13,12 +13,18 @@ export const PAPERS: PaperSize[] = [
{ id: 'A3', label: 'DIN A3', w: 297, h: 420, group: 'DIN' },
{ id: 'A3plus', label: 'DIN A3+ (Super A3)', w: 329, h: 483, group: 'DIN' },
{ id: 'A2', label: 'DIN A2', w: 420, h: 594, group: 'DIN' },
{ id: 'A1', label: 'DIN A1', w: 594, h: 841, group: 'DIN' },
// Fotopapier (Zuschnitte, wie sie im Fachhandel liegen)
{ id: 'F9x13', label: 'Fotopapier 9 × 13 cm', w: 90, h: 130, group: 'Fotopapier' },
{ id: 'F10x15', label: 'Fotopapier 10 × 15 cm', w: 100, h: 150, group: 'Fotopapier' },
{ id: 'F13x18', label: 'Fotopapier 13 × 18 cm', w: 130, h: 180, group: 'Fotopapier' },
{ id: 'F15x20', label: 'Fotopapier 15 × 20 cm', w: 150, h: 200, group: 'Fotopapier' },
{ id: 'F20x30', label: 'Fotopapier 20 × 30 cm', w: 200, h: 300, group: 'Fotopapier' },
{ id: 'F10x10', label: 'Fotopapier 10 × 10 cm', w: 100, h: 100, group: 'Fotopapier' },
{ id: 'F13x13', label: 'Fotopapier 13 × 13 cm', w: 130, h: 130, group: 'Fotopapier' },
{ id: 'F4x6', label: 'Fotopapier 4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Fotopapier' },
{ id: 'F5x7', label: 'Fotopapier 5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Fotopapier' },
{ id: 'F8x10', label: 'Fotopapier 8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Fotopapier' },
// US
{ id: 'Letter', label: 'US Letter', w: 215.9, h: 279.4, group: 'US' },
{ id: 'Legal', label: 'US Legal', w: 215.9, h: 355.6, group: 'US' },
@@ -28,38 +34,111 @@ export const PAPERS: PaperSize[] = [
export interface PhotoSize { id: string; label: string; w: number; h: number; group: string }
export const PHOTO_SIZES: PhotoSize[] = [
// Passbild / Ausweis
{ id: 'P35x45', label: 'Biometrisches Passbild 35 × 45 mm', w: 35, h: 45, group: 'Passbild' },
{ id: 'P50x50', label: 'Visum USA 50 × 50 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passbild' },
// Kleinformate (Kita, Schule, Portemonnaie)
// --- Passbild / Ausweis (Landesvorgaben) ---
{ id: 'P35x45', label: 'Passbild 35 × 45 mm (DE/EU, biometrisch)', w: 35, h: 45, group: 'Passbild' },
{ id: 'P33x48', label: 'Visum China 33 × 48 mm', w: 33, h: 48, group: 'Passbild' },
{ id: 'P50x50', label: 'Visum USA 51 × 51 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passbild' },
{ id: 'P50x70', label: 'Passbild Kanada 50 × 70 mm', w: 50, h: 70, group: 'Passbild' },
{ id: 'P45x35', label: 'Passbild quer 45 × 35 mm', w: 45, h: 35, group: 'Passbild' },
{ id: 'P25x35', label: 'Klein-Ausweis 25 × 35 mm', w: 25, h: 35, group: 'Passbild' },
// --- Kleinformate (Kita, Schule, Portemonnaie) ---
{ id: 'K20x30', label: '2 × 3 cm', w: 20, h: 30, group: 'Klein' },
{ id: 'K30x40', label: '3 × 4 cm', w: 30, h: 40, group: 'Klein' },
{ id: 'K35x50', label: '3,5 × 5 cm', w: 35, h: 50, group: 'Klein' },
{ id: 'K40x50', label: '4 × 5 cm', w: 40, h: 50, group: 'Klein' },
{ id: 'K45x60', label: '4,5 × 6 cm', w: 45, h: 60, group: 'Klein' },
{ id: 'K50x70', label: '5 × 7 cm', w: 50, h: 70, group: 'Klein' },
{ id: 'K60x80', label: '6 × 8 cm', w: 60, h: 80, group: 'Klein' },
{ id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Klein' },
// Klassische Fotoformate
{ id: 'K70x100', label: '7 × 10 cm', w: 70, h: 100, group: 'Klein' },
// --- Klassische Fotoformate ---
{ id: 'S9x13', label: '9 × 13 cm', w: 90, h: 130, group: 'Foto' },
{ id: 'S10x15', label: '10 × 15 cm', w: 100, h: 150, group: 'Foto' },
{ id: 'S11x15', label: '11 × 15 cm', w: 110, h: 150, group: 'Foto' },
{ id: 'S12x15', label: '12 × 15 cm', w: 120, h: 150, group: 'Foto' },
{ id: 'S13x18', label: '13 × 18 cm', w: 130, h: 180, group: 'Foto' },
{ id: 'S15x20', label: '15 × 20 cm', w: 150, h: 200, group: 'Foto' },
{ id: 'S15x21', label: '15 × 21 cm', w: 150, h: 210, group: 'Foto' },
{ id: 'S18x24', label: '18 × 24 cm', w: 180, h: 240, group: 'Foto' },
{ id: 'S20x25', label: '20 × 25 cm', w: 200, h: 250, group: 'Foto' },
{ id: 'S20x30', label: '20 × 30 cm', w: 200, h: 300, group: 'Foto' },
{ id: 'S30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Foto' },
{ id: 'S30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Foto' },
{ id: 'S40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Foto' },
{ id: 'S40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Foto' },
{ id: 'S50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Foto' },
// Quadrate
{ id: 'S24x30', label: '24 × 30 cm', w: 240, h: 300, group: 'Foto' },
{ id: 'S28x35', label: '28 × 35 cm', w: 280, h: 350, group: 'Foto' },
// --- Zollmaße (US-Fotolabore, Rahmen aus dem Ausland) ---
{ id: 'Z2_5x3_5', label: 'Wallet 2,5 × 3,5 in (64 × 89 mm)', w: 63.5, h: 88.9, group: 'Zoll (US)' },
{ id: 'Z3_5x5', label: '3,5 × 5 in (89 × 127 mm)', w: 88.9, h: 127, group: 'Zoll (US)' },
{ id: 'Z4x6', label: '4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Zoll (US)' },
{ id: 'Z5x7', label: '5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Zoll (US)' },
{ id: 'Z8x10', label: '8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Zoll (US)' },
{ id: 'Z11x14', label: '11 × 14 in (279 × 356 mm)', w: 279.4, h: 355.6, group: 'Zoll (US)' },
{ id: 'Z16x20', label: '16 × 20 in (406 × 508 mm)', w: 406.4, h: 508, group: 'Zoll (US)' },
{ id: 'Z24x36', label: '24 × 36 in (610 × 914 mm)', w: 609.6, h: 914.4, group: 'Zoll (US)' },
// --- Sofortbild-Nachbauten (Bildfläche, nicht Kartengröße) ---
{ id: 'I46x62', label: 'Instax mini · Bildfläche 46 × 62 mm', w: 46, h: 62, group: 'Sofortbild' },
{ id: 'I62x62', label: 'Instax Square · Bildfläche 62 × 62 mm', w: 62, h: 62, group: 'Sofortbild' },
{ id: 'I99x62', label: 'Instax Wide · Bildfläche 99 × 62 mm', w: 99, h: 62, group: 'Sofortbild' },
{ id: 'I79x79', label: 'Polaroid · Bildfläche 79 × 79 mm', w: 79, h: 79, group: 'Sofortbild' },
{ id: 'I88x107', label: 'Polaroid · ganze Karte 88 × 107 mm', w: 88, h: 107, group: 'Sofortbild' },
// --- Poster- und Rahmenformate ---
{ id: 'R30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Poster & Rahmen' },
{ id: 'R30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Poster & Rahmen' },
{ id: 'R40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Poster & Rahmen' },
{ id: 'R40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Poster & Rahmen' },
{ id: 'R45x60', label: '45 × 60 cm', w: 450, h: 600, group: 'Poster & Rahmen' },
{ id: 'R50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Poster & Rahmen' },
{ id: 'R60x80', label: '60 × 80 cm', w: 600, h: 800, group: 'Poster & Rahmen' },
{ id: 'R60x90', label: '60 × 90 cm', w: 600, h: 900, group: 'Poster & Rahmen' },
{ id: 'R70x100', label: '70 × 100 cm', w: 700, h: 1000, group: 'Poster & Rahmen' },
{ id: 'R80x120', label: '80 × 120 cm', w: 800, h: 1200, group: 'Poster & Rahmen' },
{ id: 'R100x140', label: '100 × 140 cm', w: 1000, h: 1400, group: 'Poster & Rahmen' },
// --- Quadrate ---
{ id: 'Q10x10', label: '10 × 10 cm', w: 100, h: 100, group: 'Quadrat' },
{ id: 'Q13x13', label: '13 × 13 cm', w: 130, h: 130, group: 'Quadrat' },
{ id: 'Q15x15', label: '15 × 15 cm', w: 150, h: 150, group: 'Quadrat' },
{ id: 'Q20x20', label: '20 × 20 cm', w: 200, h: 200, group: 'Quadrat' },
{ id: 'Q25x25', label: '25 × 25 cm', w: 250, h: 250, group: 'Quadrat' },
{ id: 'Q30x30', label: '30 × 30 cm', w: 300, h: 300, group: 'Quadrat' },
// DIN als Bildformat
{ id: 'Q40x40', label: '40 × 40 cm', w: 400, h: 400, group: 'Quadrat' },
{ id: 'Q50x50', label: '50 × 50 cm', w: 500, h: 500, group: 'Quadrat' },
{ id: 'Q70x70', label: '70 × 70 cm', w: 700, h: 700, group: 'Quadrat' },
// --- DIN ---
{ id: 'DA7', label: 'DIN A7 (7,4 × 10,5 cm)', w: 74, h: 105, group: 'DIN' },
{ id: 'DA6', label: 'DIN A6 (10,5 × 14,8 cm)', w: 105, h: 148, group: 'DIN' },
{ id: 'DA5', label: 'DIN A5 (14,8 × 21 cm)', w: 148, h: 210, group: 'DIN' },
{ id: 'DA4', label: 'DIN A4 (21 × 29,7 cm)', w: 210, h: 297, group: 'DIN' },
{ id: 'DA3', label: 'DIN A3 (29,7 × 42 cm)', w: 297, h: 420, group: 'DIN' },
{ id: 'DA3P', label: 'DIN A3+ (32,9 × 48,3 cm)', w: 329, h: 483, group: 'DIN' },
{ id: 'DA2', label: 'DIN A2 (42 × 59,4 cm)', w: 420, h: 594, group: 'DIN' },
{ id: 'DA1', label: 'DIN A1 (59,4 × 84,1 cm)', w: 594, h: 841, group: 'DIN' },
{ id: 'DA0', label: 'DIN A0 (84,1 × 118,9 cm)', w: 841, h: 1189, group: 'DIN' },
// --- Karten & Drucksachen ---
{ id: 'C85x55', label: 'Visitenkarte 85 × 55 mm', w: 85, h: 55, group: 'Karten' },
{ id: 'C148x105', label: 'Postkarte 14,8 × 10,5 cm (A6 quer)', w: 148, h: 105, group: 'Karten' },
{ id: 'C99x210', label: 'DIN lang 9,9 × 21 cm', w: 99, h: 210, group: 'Karten' },
{ id: 'C210x99', label: 'DIN lang quer 21 × 9,9 cm', w: 210, h: 99, group: 'Karten' },
{ id: 'C54x86', label: 'Scheckkarte 85,6 × 54 mm', w: 85.6, h: 54, group: 'Karten' },
{ id: 'C70x100', label: 'Lesezeichen 7 × 21 cm', w: 70, h: 210, group: 'Karten' },
// --- Seitenverhältnisse als Druckmaß (auf 30 cm gerechnet) ---
{ id: 'W16x9', label: '16:9 „The Frame" (30 cm breit)', w: 300, h: 168.75, group: 'Seitenverhältnis' },
{ id: 'W9x16', label: '9:16 Hochformat (30 cm hoch)', w: 168.75, h: 300, group: 'Seitenverhältnis' },
{ id: 'W21x9', label: '21:9 Kino (30 cm breit)', w: 300, h: 128.57, group: 'Seitenverhältnis' },
{ id: 'W2x1', label: '2:1 Panorama (30 cm breit)', w: 300, h: 150, group: 'Seitenverhältnis' },
{ id: 'W3x2', label: '3:2 (30 cm breit)', w: 300, h: 200, group: 'Seitenverhältnis' },
{ id: 'W2x3', label: '2:3 (30 cm hoch)', w: 200, h: 300, group: 'Seitenverhältnis' },
{ id: 'W4x3', label: '4:3 (30 cm breit)', w: 300, h: 225, group: 'Seitenverhältnis' },
{ id: 'W3x4', label: '3:4 (30 cm hoch)', w: 225, h: 300, group: 'Seitenverhältnis' },
{ id: 'W5x4', label: '5:4 (30 cm breit)', w: 300, h: 240, group: 'Seitenverhältnis' },
{ id: 'W4x5', label: '4:5 Instagram (30 cm hoch)', w: 240, h: 300, group: 'Seitenverhältnis' },
{ id: 'W1x1', label: '1:1 quadratisch (30 cm)', w: 300, h: 300, group: 'Seitenverhältnis' },
];
export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null;
@@ -80,11 +159,11 @@ export function parseSizeMm(input: string): { w: number; h: number } | null {
// Seitenverhältnis mit Zielkante: „4:3 / 15" oder „4:3 15cm"
const ratio = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*(?:[/@ ]\s*(\d+(?:\.\d+)?)\s*(mm|cm)?)?$/.exec(t);
if (ratio && ratio[3]) {
// a:b wird als Breite:Höhe gelesen — „3:4/15" ist also hochkant, „4:3/15" quer.
const a = parseFloat(ratio[1]), b = parseFloat(ratio[2]);
const long = ratio[4] === 'mm' ? parseFloat(ratio[3]) : parseFloat(ratio[3]) * 10;
if (!a || !b || !long) return null;
const [lo, hi] = a >= b ? [b, a] : [a, b];
return norm(long * (lo / hi), long);
return a >= b ? norm(long, long * (b / a)) : norm(long * (a / b), long);
}
const unit = /mm\s*$/.test(t) ? 1 : 10; // ohne Einheit: cm
@@ -108,9 +187,10 @@ function norm(w: number, h: number): { w: number; h: number } | null {
export const mmToPx = (mm: number, dpi: number) => Math.round((mm / 25.4) * dpi);
export const mmToPt = (mm: number) => (mm / 25.4) * 72;
/** Hübsche Beschriftung eines Maßes für die Oberfläche. */
/** Hübsche Beschriftung eines Maßes — ohne die echten Nachkommastellen zu verlieren. */
export function labelMm(w: number, h: number): string {
const f = (n: number) => (n % 10 === 0 ? String(n / 10) : String(Math.round(n) / 10).replace('.', ','));
return w < 100 && h < 100 ? `${fmt(w)} × ${fmt(h)} mm` : `${f(w)} × ${f(h)} cm`;
const mm = (n: number) => de(Math.round(n * 10) / 10);
const cm = (n: number) => de(Math.round(n * 100) / 1000);
return w < 100 && h < 100 ? `${mm(w)} × ${mm(h)} mm` : `${cm(w)} × ${cm(h)} cm`;
}
const fmt = (n: number) => String(Math.round(n * 10) / 10).replace('.', ',');
const de = (n: number) => String(n).replace('.', ',');
+28 -4
View File
@@ -1,8 +1,8 @@
// Picdrop-Auslieferung via SFTP (ssh2-sftp-client) oder FTPS (basic-ftp).
// Konfiguration kommt aus settings (verschlüsseltes Passwort).
import posixpath from 'node:path/posix';
import { one } from './db';
import { decrypt } from './crypto';
import { one } from './db.ts';
import { decrypt } from './crypto.ts';
export interface PicdropCfg {
host: string; protocol: 'ftps' | 'sftp'; port: number;
@@ -58,11 +58,35 @@ export async function testConnection(cfg: PicdropCfg): Promise<{ ok: boolean; me
}
}
/**
* Ein Pfadstück, das gefahrlos an den Basisordner angehängt werden kann.
*
* `posixpath.join` ist **kein** Schutz: `join('/kunden/lea', '../../../etc/cron.d')`
* ergibt `/etc/cron.d`, und der Upload legt das Verzeichnis vorher an. Galerienamen
* und Dateinamen stammen aus der Datenbank (Rezepte, Ordner, Einstellungen) und
* damit letztlich aus Nutzereingaben — sie werden deshalb **unmittelbar vor dem
* Schreiben** geprüft, nicht irgendwo weiter oben. Diese Stelle kann niemand
* umgehen, der ausliefert.
*/
export function safeSegment(value: string, was: 'Galerie' | 'Dateiname'): string {
const v = String(value || '').trim();
if (!v) return '';
// Bewusst eine Sperrliste der *gefährlichen* Konstrukte statt einer engen
// Erlaubnisliste: Ein Kundenordner darf „Müller & Söhne (2026)" heißen — das ist
// harmlos. Gefährlich sind nur Trennzeichen, Steuerzeichen und reine Punktnamen,
// denn allein damit lässt sich der Basisordner verlassen.
if (/[\/\\]/.test(v)) throw new Error(`Ungültiger ${was}: keine Schrägstriche erlaubt.`);
if (/[\u0000-\u001f\u007f]/.test(v)) throw new Error(`Ungültiger ${was}: Steuerzeichen.`);
if (/^\.+$/.test(v)) throw new Error(`Ungültiger ${was}: „.“ und „..“ sind keine Namen.`);
if (v.length > 128) throw new Error(`Ungültiger ${was}: zu lang.`);
return v;
}
/** Lädt einen Puffer direkt unter dem finalen Namen hoch.
* (Picdrop verträgt das Temp→Rename-Muster nicht — es blieben `.tmp-…`-Dateien liegen.) */
export async function uploadBuffer(cfg: PicdropCfg, gallery: string, filename: string, buf: Buffer): Promise<void> {
const dir = posixpath.join(cfg.basePath || '/', gallery || '');
const finalPath = posixpath.join(dir, filename);
const dir = posixpath.join(cfg.basePath || '/', safeSegment(gallery, 'Galerie'));
const finalPath = posixpath.join(dir, safeSegment(filename, 'Dateiname'));
const { Readable } = await import('node:stream');
if (cfg.protocol === 'sftp') {
-39
View File
@@ -82,42 +82,3 @@ export async function heicToPng(buffer: Buffer): Promise<Buffer> {
return Buffer.from(out);
}
/** Klein, mit Bindestrichen, ohne Umlaut-Probleme. */
export function slugify(s: string): string {
return (s || '')
.toLowerCase()
.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c))
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
}
const GENERIC = new Set(['', 'foto', 'photo', 'bild', 'image', 'img', 'telegram', 'unbenannt', 'klarbild', 'screenshot', 'pxl', 'whatsapp']);
/** Motiv aus einem beliebigen (evtl. schon verarbeiteten) Namen herausschälen:
* Datumspräfixe, UUIDs und lange Hex-Ketten entfernen, damit keine Doppelungen entstehen. */
function cleanMotif(name: string | null): string {
let s = (name || '').replace(/\.[^.]+$/, '');
// Ohne \b arbeiten — Wortgrenzen greifen an Unterstrichen nicht (…_2026-07-24…).
s = s.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ' '); // UUID
s = s.replace(/\d{4}-\d{2}-\d{2}/g, ' '); // Datum (auch mehrfach)
s = s.replace(/[0-9a-f]{16,}/gi, ' '); // lange Hex-Ketten
return slugify(s);
}
/** Logischer Dateiname: JJJJ-MM-TT_<motiv>_<format>.<ext>
* z. B. 2026-07-23_lea_the-frame.png */
export function buildResultFilename(originalName: string | null, formatToken: string, ext = 'png'): string {
const raw = cleanMotif(originalName);
const motif = (!raw || GENERIC.has(raw)) ? 'klarbild' : raw;
const date = new Date().toISOString().slice(0, 10);
const fmt = slugify(formatToken) || 'original';
return `${date}_${motif}_${fmt}.${ext}`;
}
/** Formatschlüssel für den Dateinamen (aus dem output_format). */
export function formatToken(outputFormat: string | undefined): string {
if (!outputFormat || outputFormat === 'keep') return 'original';
if (outputFormat === 'theframe') return 'the-frame';
if (outputFormat === 'hochformat') return 'hochformat';
return outputFormat.toLowerCase(); // 30x40, a4, sticker, …
}
+38 -10
View File
@@ -77,7 +77,7 @@ export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
const n = Math.max(0, Math.floor(s.count || 0));
if (!n) continue;
const fitsPlain = s.wMm <= availW + EPS && s.hMm <= availH + EPS;
const fitsRot = !!s.allowRotate && s.hMm <= availW + EPS && s.wMm <= availH + EPS;
const fitsRot = s.allowRotate !== false && s.hMm <= availW + EPS && s.wMm <= availH + EPS;
if (!fitsPlain && !fitsRot) {
unplaced.push({ specId: s.id, count: n, reason: 'größer als die Nutzfläche des Bogens' });
continue;
@@ -101,8 +101,14 @@ export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
return sp.allowRotate !== false && Math.abs(sp.wMm - sp.hMm) > EPS;
});
const tooBig = ids.length > 5 || units.length > 150;
// Bei sehr vielen Formaten/Stücken nicht alle Kombinationen durchprobieren —
// aber jedem Format trotzdem eine Ausrichtung geben, in der es überhaupt passt.
const variants: Record<string, boolean>[] = tooBig
? [Object.fromEntries(ids.map((id) => [id, false]))]
? [Object.fromEntries(ids.map((id) => {
const sp = specs.find((x) => x.id === id)!;
const plainFits = sp.wMm <= availW + EPS && sp.hMm <= availH + EPS;
return [id, !plainFits && sp.allowRotate !== false];
}))]
: combos(rotatable).map((set) => Object.fromEntries(ids.map((id) => [id, set.has(id)])));
let best: Page[] | null = null, bestScore = -Infinity;
@@ -125,8 +131,11 @@ export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
if (score > bestScore) { bestScore = score; best = cand; }
}
pages = best || maxRectsPack(units.map((u) => ({ ...u, rot: false })), availW, availH, gap);
const placed = pages.reduce((n, p) => n + p.placements.length, 0);
if (placed < units.length) unplaced.push({ specId: units[0].specId, count: units.length - placed, reason: 'kein Platz auf dem Bogen' });
// Fehlende Stücke je Bildformat zählen — nicht pauschal dem ersten zuschreiben.
const placedKeys = new Set(pages.flatMap((pg) => pg.placements.map((pl) => `${pl.specId}#${pl.copy}`)));
const missing = new Map<string, number>();
for (const u of units) if (!placedKeys.has(`${u.specId}#${u.copy}`)) missing.set(u.specId, (missing.get(u.specId) || 0) + 1);
for (const [id, n] of missing) unplaced.push({ specId: id, count: n, reason: 'kein Platz auf dem Bogen' });
}
// Auf dem Bogen ausrichten: Blockmitte oder linke obere Ecke.
@@ -156,10 +165,14 @@ function gridPack(units: { specId: string; copy: number; w: number; h: number }[
cols: Math.max(0, Math.floor((availW + gap + EPS) / (a + gap))),
rows: Math.max(0, Math.floor((availH + gap + EPS) / (b + gap))),
});
// Die vom Nutzer gewählte Ausrichtung hat Vorrang. Gedreht wird nur, wenn das
// die Zahl der Bogen tatsächlich senkt (oder ungedreht gar nichts passt).
let best = fit(w, h);
if (spec?.allowRotate !== false && Math.abs(w - h) > EPS) {
const alt = fit(h, w);
if (alt.cols * alt.rows > best.cols * best.rows) { best = alt; [w, h] = [h, w]; rot = true; }
const plainN = best.cols * best.rows, altN = alt.cols * alt.rows;
const pages = (n: number) => (n > 0 ? Math.ceil(units.length / n) : Infinity);
if (pages(altN) < pages(plainN)) { best = alt; [w, h] = [h, w]; rot = true; }
}
const perPage = best.cols * best.rows;
if (!perPage) return [];
@@ -188,6 +201,10 @@ function maxRectsPack(units: { specId: string; copy: number; w: number; h: numbe
const W = availW + gap, H = availH + gap; // aufgeblasene Fläche
const pages: Page[] = [];
// Notbremse gegen entartete Eingaben (viele Formate × viele Stück): der
// Packer läuft synchron im Serverprozess und darf ihn nicht blockieren.
let steps = 0;
const MAX_STEPS = 2_000_000;
while (todo.length) {
let free: FreeRect[] = [{ x: 0, y: 0, w: W, h: H }];
@@ -200,6 +217,7 @@ function maxRectsPack(units: { specId: string; copy: number; w: number; h: numbe
for (let i = 0; i < todo.length; i++) {
const u = todo[i];
const cw = u.w + gap, ch = u.h + gap;
if ((steps += free.length) > MAX_STEPS) break;
for (const r of free) {
if (cw > r.w + EPS || ch > r.h + EPS) continue;
const short = Math.min(r.w - cw, r.h - ch);
@@ -211,7 +229,7 @@ function maxRectsPack(units: { specId: string; copy: number; w: number; h: numbe
}
}
}
if (bestIdx < 0 || !bestRect) break;
if (bestIdx < 0 || !bestRect || steps > MAX_STEPS) break;
const u = todo.splice(bestIdx, 1)[0];
placements.push({ specId: u.specId, copy: u.copy, x: round(bestRect.x), y: round(bestRect.y), w: u.w, h: u.h, rotated: u.rot });
@@ -221,7 +239,7 @@ function maxRectsPack(units: { specId: string; copy: number; w: number; h: numbe
if (!placements.length) break; // nichts platzierbar → Abbruch
placements.sort((a, b) => a.y - b.y || a.x - b.x);
pages.push({ placements });
if (pages.length > 200) break; // Notbremse
if (pages.length > 200 || steps > MAX_STEPS) break; // Notbremse
}
return pages;
}
@@ -259,8 +277,16 @@ function splitFree(free: FreeRect[], used: FreeRect): FreeRect[] {
/** Wie viele Endformate passen maximal auf einen Bogen? (Kennzahl für die UI.) */
export function capacity(spec: Omit<PlaceSpec, 'count'>, sheet: SheetSpec): number {
const probe = layout([{ ...spec, count: 200 }], sheet);
return probe.pages[0]?.placements.length || 0;
// Direkt rechnen statt zu packen — sonst deckelt die Probe die Antwort.
const gap = effectiveGap(sheet);
const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0);
const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset;
const grid = (w: number, h: number) =>
Math.max(0, Math.floor((availW + gap + EPS) / (w + gap))) *
Math.max(0, Math.floor((availH + gap + EPS) / (h + gap)));
const plain = grid(spec.wMm, spec.hMm);
const rot = spec.allowRotate !== false ? grid(spec.hMm, spec.wMm) : 0;
return Math.max(plain, rot);
}
export interface MarkOptions {
@@ -279,7 +305,9 @@ export interface MarkOptions {
export function cutMarks(page: Page, sheet: SheetSpec, opt: MarkOptions): Line[] {
if (opt.mode === 'none') return [];
const len = opt.lengthMm ?? 4;
const off = opt.offsetMm ?? Math.max(2, (opt.bleedMm ?? 0) + 1);
// Der Versatz muss den gedruckten Beschnitt überspringen — sonst landet die
// Marke auf dem Motiv. Ein zu kleiner Wunschwert wird deshalb angehoben.
const off = Math.max(opt.offsetMm ?? 0, (opt.bleedMm ?? 0) + 1, 2);
const lines: Line[] = [];
if (opt.mode === 'corner') {
+124
View File
@@ -0,0 +1,124 @@
// Übergroße Bilder: was tun, wenn das Endmaß größer ist als der Bogen?
// 20 × 30 cm auf DIN-A4-Fotopapier ist der Alltagsfall — es fehlen genau 3 mm.
// Reine Mathematik, keine Abhängigkeiten (wie printlayout.ts), damit Browser und
// Server dieselbe Rechnung benutzen.
import type { Placement, SheetSpec } from './printlayout.ts';
const EPS = 1e-6;
const round = (n: number) => Math.round(n * 1000) / 1000;
export type Oversize =
| 'fit' // nichts tun, als „passt nicht" melden (Standard)
| 'overflow' // mittig aufs Blatt legen, der Überstand wird beim Drucken abgeschnitten
| 'tile'; // auf mehrere Blätter verteilen (Posterdruck zum Zusammenkleben)
/** Passt das Endmaß auf die Nutzfläche des Bogens — notfalls gedreht? */
export function fitsOnSheet(wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true): boolean {
const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0);
const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset;
const plain = wMm <= availW + EPS && hMm <= availH + EPS;
const rot = allowRotate && hMm <= availW + EPS && wMm <= availH + EPS;
return plain || rot;
}
export interface OverflowInfo {
/** Platzierung auf dem Bogen — darf negativ sein, das Bild ragt dann hinaus. */
placement: Placement;
rotated: boolean;
/** Wie viel je Seite verloren geht (mm), nach dem Drehen gerechnet. */
lostLeft: number; lostRight: number; lostTop: number; lostBottom: number;
lostMm: number;
}
/**
* Ein zu großes Bild mittig aufs **ganze Blatt** legen — bewusst nicht auf die
* Nutzfläche, sonst verschenkt man den Rand, den ein randloser Drucker sehr wohl
* bedrucken kann. Was über den Bogen ragt, fehlt hinterher; wie viel, steht in `lost*`.
*/
export function overflowPlacement(
specId: string, copy: number, wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true,
): OverflowInfo {
const loss = (w: number, h: number) => Math.max(0, w - sheet.wMm) + Math.max(0, h - sheet.hMm);
const rotated = !!allowRotate && loss(hMm, wMm) < loss(wMm, hMm) - EPS;
const w = rotated ? hMm : wMm, h = rotated ? wMm : hMm;
const x = round((sheet.wMm - w) / 2), y = round((sheet.hMm - h) / 2);
const lostLeft = round(Math.max(0, -x)), lostTop = round(Math.max(0, -y));
const lostRight = round(Math.max(0, x + w - sheet.wMm));
const lostBottom = round(Math.max(0, y + h - sheet.hMm));
return {
placement: { specId, copy, x, y, w, h, rotated },
rotated, lostLeft, lostRight, lostTop, lostBottom,
lostMm: round(lostLeft + lostRight + lostTop + lostBottom),
};
}
/**
* Beim Posterdruck steht auf jedem Blatt „Blatt 2/4 · Reihe 1, Spalte 2" — das ist
* die Montageanleitung und wird deshalb immer gedruckt (8 pt ab 3 mm Oberkante,
* braucht rund 6 mm). Ist der Papierrand knapper, muss die Kachel so weit nach
* unten rücken; damit sie unten nicht herausragt, wird der Bogen entsprechend
* niedriger gerechnet. Browser und Server benutzen dieselbe Funktion, sonst zeigte
* die Vorschau eine andere Blattzahl als das fertige PDF.
*/
export const NOTE_MM = 6;
export function noteInset(sheet: SheetSpec): number {
return Math.max(0, NOTE_MM - (sheet.marginMm || 0));
}
export function tileSheet(sheet: SheetSpec): SheetSpec {
const n = noteInset(sheet);
return n ? { ...sheet, hMm: sheet.hMm - n } : sheet;
}
export interface Tile {
col: number; row: number; // 0-basiert
xMm: number; yMm: number; // Ausschnitt im Zielbild, ab linker oberer Ecke
wMm: number; hMm: number;
glueRight: boolean; glueBottom: boolean; // dort schließt eine Nachbarkachel an
}
export interface TilePlan { cols: number; rows: number; tiles: Tile[]; overlapMm: number }
/**
* Posterdruck: ein Bild auf mehrere Blätter verteilen.
* Die Kacheln überlappen sich um `overlapMm` — dieser Streifen ist der Klebefalz.
* Er wird auf beiden Nachbarblättern gedruckt; beim Zusammensetzen legt man das
* eine Blatt entlang der gestrichelten Linie auf das andere.
*/
export function tilePlan(wMm: number, hMm: number, sheet: SheetSpec, overlapMm = 10): TilePlan {
const inset = sheet.marginMm || 0;
const availW = Math.max(10, sheet.wMm - 2 * inset);
const availH = Math.max(10, sheet.hMm - 2 * inset);
const o = Math.max(0, Math.min(overlapMm, Math.min(availW, availH) / 3));
const stepX = Math.max(1, availW - o), stepY = Math.max(1, availH - o);
const cols = Math.max(1, Math.ceil((wMm - o) / stepX));
const rows = Math.max(1, Math.ceil((hMm - o) / stepY));
const tiles: Tile[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = c * stepX, y = r * stepY;
const w = Math.min(availW, wMm - x), h = Math.min(availH, hMm - y);
if (w <= 0.5 || h <= 0.5) continue;
tiles.push({
col: c, row: r, xMm: round(x), yMm: round(y), wMm: round(w), hMm: round(h),
glueRight: c < cols - 1, glueBottom: r < rows - 1,
});
}
}
return { cols, rows, tiles, overlapMm: round(o) };
}
/** Ausschnitt einer Kachel, verschachtelt in den Zuschnitt des Nutzers. */
export function tileCrop(
crop: { x: number; y: number; w: number; h: number },
tile: Tile, wMm: number, hMm: number,
): { x: number; y: number; w: number; h: number } {
return {
x: crop.x + (tile.xMm / wMm) * crop.w,
y: crop.y + (tile.yMm / hMm) * crop.h,
w: (tile.wMm / wMm) * crop.w,
h: (tile.hMm / hMm) * crop.h,
};
}
+151 -54
View File
@@ -1,16 +1,34 @@
// Rendern des Druckbogens: Zuschnitt (sharp) + PDF-Aufbau (pdf-lib).
// Bewusst ohne KI — reine Geometrie und Skalierung, damit das Ergebnis
// vorhersagbar und wiederholbar ist.
import sharp from 'sharp';
import sharp, { type Sharp } from 'sharp';
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
import { mmToPt, mmToPx } from './paper';
import type { Line, Page, SheetSpec } from './printlayout';
import { mmToPt, mmToPx } from './paper.ts';
import type { Line, Page, SheetSpec } from './printlayout.ts';
import { baseCrop, type CropRel, type FitMode } from './cropmath.ts';
/** Zuschnitt relativ zur Quelle (0..1) — auflösungsunabhängig speicherbar. */
export interface CropRel { x: number; y: number; w: number; h: number }
export type { CropRel, FitMode };
export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 };
/* `FitMode`: `cover` = zuschneiden bis ausgefüllt (es fehlt etwas außen),
`contain` = ganzes Bild einpassen (außen bleibt die Randfarbe). Nie verzerren. */
/** Größter mittiger Ausschnitt im Zielverhältnis — füllt aus, schneidet ab. */
// Beide Einstiegspunkte rechnen über `src/lib/cropmath.ts` — dieselbe Datei, die
// auch die Browser-Vorschau benutzt. `tests/cropmath.test.ts` hält beide Wege
// gegeneinander, damit Vorschau und PDF nicht auseinanderlaufen können.
export const coverCrop = (natW: number, natH: number, aspect: number): CropRel =>
baseCrop(natW, natH, aspect, 'cover');
/**
* Kleinster Ausschnitt im Zielverhältnis, der das **ganze** Bild enthält.
* Ragt bewusst über den Bildrand hinaus (w bzw. h > 1) — dort entsteht der Rand.
*/
export const containCrop = (natW: number, natH: number, aspect: number): CropRel =>
baseCrop(natW, natH, aspect, 'contain');
/**
* Bringt einen Bildausschnitt exakt auf ein physisches Maß.
* `bleedMm` vergrößert das gerenderte Feld nach außen; die Trimmbox bleibt
@@ -23,79 +41,125 @@ export async function renderCell(
wMm: number,
hMm: number,
dpi: number,
opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string } = {},
opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string; fit?: FitMode } = {},
): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> {
const bleed = Math.max(0, opt.bleedMm || 0);
const meta = await sharp(input, { failOn: 'none' }).metadata();
const fit: FitMode = opt.fit === 'contain' ? 'contain' : 'cover';
const bg = /^#[0-9a-fA-F]{6}$/.test(opt.background || '') ? opt.background! : '#ffffff';
// EXIF-Ausrichtung anwenden, BEVOR gerechnet wird. Handyfotos tragen die
// Drehung nur als Metadatum; Browser und iPhone zeigen sie gedreht, sharp
// rechnet ohne diesen Schritt auf dem ungedrehten Raster — dann kommt das
// Bild quer und mit falschem Ausschnitt aus dem Drucker.
const meta0 = await sharp(input, { failOn: 'none' }).metadata();
const src = (meta0.orientation ?? 1) > 1
? await sharp(input, { failOn: 'none' }).rotate().toBuffer()
: input;
const meta = (meta0.orientation ?? 1) > 1 ? await sharp(src, { failOn: 'none' }).metadata() : meta0;
const nw = meta.width || 0, nh = meta.height || 0;
if (!nw || !nh) throw new Error('Bildmaße unbekannt.');
// Ohne Vorgabe: größtmöglicher mittiger Ausschnitt im Zielverhältnis
// („cover") — nie verzerren, das ist bei Druckmaßen die häufigste Falle.
const c = crop ? normCrop(crop) : coverCrop(nw, nh, wMm / hMm);
// Zuschnitt um die Beschnittzugabe erweitern (mittig), damit die Trimmbox
// exakt das bleibt, was in der Vorschau gerahmt wurde.
const c = crop ? normCrop(crop, fit) : (fit === 'contain'
? containCrop(nw, nh, wMm / hMm)
: coverCrop(nw, nh, wMm / hMm));
// Beschnittzugabe: Ausschnitt mittig erweitern, damit die Trimmbox exakt
// der gerahmte Bereich bleibt.
const fx = (wMm + 2 * bleed) / wMm;
const fy = (hMm + 2 * bleed) / hMm;
const cw = c.w * fx, ch = c.h * fy;
const cx = c.x - (cw - c.w) / 2, cy = c.y - (ch - c.h) / 2;
const want = { left: cx * nw, top: cy * nh, width: cw * nw, height: ch * nh };
const left = Math.round(want.left), top = Math.round(want.top);
const width = Math.max(1, Math.round(want.width)), height = Math.max(1, Math.round(want.height));
const ex = {
left: Math.min(Math.max(0, left), nw - 1),
top: Math.min(Math.max(0, top), nh - 1),
};
const exW = Math.max(1, Math.min(width + Math.min(0, left), nw - ex.left));
const exH = Math.max(1, Math.min(height + Math.min(0, top), nh - ex.top));
const padLeft = Math.max(0, ex.left - left);
const padTop = Math.max(0, ex.top - top);
const padRight = Math.max(0, width - exW - padLeft);
const padBottom = Math.max(0, height - exH - padTop);
const targetW = mmToPx(wMm + 2 * bleed, dpi);
const targetH = mmToPx(hMm + 2 * bleed, dpi);
if (targetW * targetH > MAX_TARGET_PX)
throw new Error(`Zielbild zu groß (${targetW}×${targetH} px). Bitte Maß oder Auflösung verringern.`);
let img = sharp(input, { failOn: 'none' }).extract({ left: ex.left, top: ex.top, width: exW, height: exH });
if (padLeft || padTop || padRight || padBottom) {
img = img.extend({ left: padLeft, top: padTop, right: padRight, bottom: padBottom, extendWith: 'copy' });
// Sichtbarer Teil des Ausschnitts (der Rest wird gefüllt, nicht gerendert).
const vx0 = Math.max(cx, 0), vy0 = Math.max(cy, 0);
const vx1 = Math.min(cx + cw, 1), vy1 = Math.min(cy + ch, 1);
if (vx1 - vx0 <= 0 || vy1 - vy0 <= 0) throw new Error('Ausschnitt liegt außerhalb des Bildes.');
const L = clampInt(Math.round(vx0 * nw), 0, nw - 1);
const T = clampInt(Math.round(vy0 * nh), 0, nh - 1);
const W = clampInt(Math.round((vx1 - vx0) * nw), 1, nw - L);
const H = clampInt(Math.round((vy1 - vy0) * nh), 1, nh - T);
// Abbildung Ausschnitt → Zielbild (Maßstab in px je Ausschnittsanteil).
const sx = targetW / (cw * nw), sy = targetH / (ch * nh);
let dx = clampInt(Math.round((vx0 - cx) * nw * sx), 0, targetW - 1);
let dy = clampInt(Math.round((vy0 - cy) * nh * sy), 0, targetH - 1);
const dw = clampInt(Math.round(W * sx), 1, targetW - dx);
const dh = clampInt(Math.round(H * sy), 1, targetH - dy);
const right = targetW - dx - dw, bottom = targetH - dy - dh;
// Eine einzige sharp-Kette: extract → resize → extend. Diese Reihenfolge ist
// sharps interne Reihenfolge, deshalb stimmen die Zahlen. Wichtig: erst nach
// dem Verkleinern auffüllen, sonst wächst das Zwischenbild ins Unermessliche.
let img = sharp(src, { failOn: 'none' })
.extract({ left: L, top: T, width: W, height: H })
.resize(dw, dh, { fit: 'fill' });
if (dx || dy || right || bottom) {
img = img.extend(fit === 'contain'
? { top: dy, left: dx, bottom, right, background: bg }
: { top: dy, left: dx, bottom, right, extendWith: 'copy' });
}
img = img.resize(targetW, targetH, { fit: 'fill' });
if (opt.rotate) img = img.rotate(90);
const hasAlpha = !!meta.hasAlpha;
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
const flat = hasAlpha && ext === 'jpg';
if (flat) img = img.flatten({ background: opt.background || '#ffffff' });
if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg });
const out = await (ext === 'png'
? img.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
: img.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true })
).toBuffer({ resolveWithObject: true });
const encode = (pipe: Sharp) => (ext === 'png'
? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
: pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }));
// Drehen erst im zweiten Durchgang: sharp würde eine Drehung sonst vor dem
// Auffüllen anwenden und die Ränder auf die falschen Seiten legen.
let out = await (opt.rotate ? img.png({ compressionLevel: 0 }) : encode(img))
.toBuffer({ resolveWithObject: true });
if (opt.rotate) out = await encode(sharp(out.data, { failOn: 'none' }).rotate(90))
.toBuffer({ resolveWithObject: true });
return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] };
}
/** Größter mittiger Ausschnitt mit dem Zielseitenverhältnis. */
export function coverCrop(natW: number, natH: number, aspect: number): CropRel {
const imgA = natW / natH;
const w = imgA > aspect ? aspect / imgA : 1;
const h = imgA > aspect ? 1 : imgA / aspect;
return { x: (1 - w) / 2, y: (1 - h) / 2, w, h };
}
/** Obergrenze fürs Zielbild — schützt vor Speicherexplosion durch extreme Maß/dpi-Kombinationen. */
const MAX_TARGET_PX = 300_000_000;
function normCrop(c: CropRel | null): CropRel {
const clampInt = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n | 0));
function normCrop(c: CropRel | null, fit: FitMode = 'cover'): CropRel {
if (!c) return FULL_CROP;
const cl = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : 0));
let w = cl(c.w, 0.001, 1), h = cl(c.h, 0.001, 1);
let x = cl(c.x, 0, 1 - w), y = cl(c.y, 0, 1 - h);
const num = (v: number, d = 0) => (Number.isFinite(v) ? v : d);
// Beim Einpassen darf der Ausschnitt größer als das Bild sein (dort entsteht der Rand),
// beim Zuschneiden muss er innerhalb des Bildes liegen.
const maxSide = fit === 'contain' ? 8 : 1;
const w = Math.min(maxSide, Math.max(0.001, num(c.w, 1)));
const h = Math.min(maxSide, Math.max(0.001, num(c.h, 1)));
if (fit === 'contain') {
// Nur verhindern, dass das Bild komplett aus dem Ausschnitt herausgeschoben wird.
const x = Math.min(1 - 0.05 * w, Math.max(-w + 0.05 * w, num(c.x)));
const y = Math.min(1 - 0.05 * h, Math.max(-h + 0.05 * h, num(c.y)));
return { x, y, w, h };
}
const x = Math.min(1 - w, Math.max(0, num(c.x)));
const y = Math.min(1 - h, Math.max(0, num(c.y)));
return { x, y, w, h };
}
export interface SheetCellImage { bytes: Buffer; ext: 'jpg' | 'png' }
/** Wo die Fußzeile steht. Vorne die Kante, hinten die Ausrichtung. */
export type FooterPos =
| 'unten-links' | 'unten-mitte' | 'unten-rechts'
| 'oben-links' | 'oben-mitte' | 'oben-rechts';
export interface FooterSpec { text: string; position?: FooterPos; sizePt?: number }
/** Zusätzliches je Seite — für den Posterdruck: Blattnummer und Klebefalz. */
export interface PageExtras { note?: string | null; dashed?: Line[] }
export interface SheetPdfInput {
sheet: SheetSpec;
pages: Page[];
@@ -103,7 +167,8 @@ export interface SheetPdfInput {
/** specId → fertig gerendertes Bild (inkl. Beschnittzugabe, ggf. gedreht). */
images: Record<string, SheetCellImage>;
title?: string;
footer?: string | null;
footer?: FooterSpec | null;
extrasPerPage?: PageExtras[];
markWidthPt?: number;
}
@@ -122,7 +187,8 @@ export async function buildSheetPdf(input: SheetPdfInput): Promise<Uint8Array> {
for (const [id, img] of Object.entries(images)) {
embedded[id] = img.ext === 'png' ? await pdf.embedPng(img.bytes) : await pdf.embedJpg(img.bytes);
}
const font = input.footer ? await pdf.embedFont(StandardFonts.Helvetica) : null;
const needsFont = !!input.footer || (input.extrasPerPage || []).some((e) => e?.note);
const font = needsFont ? await pdf.embedFont(StandardFonts.Helvetica) : null;
const bleed = sheet.bleedMm || 0;
const W = mmToPt(sheet.wMm), H = mmToPt(sheet.hMm);
@@ -146,12 +212,43 @@ export async function buildSheetPdf(input: SheetPdfInput): Promise<Uint8Array> {
color: rgb(0, 0, 0),
});
}
if (font && input.footer) {
page.drawText(input.footer, {
x: mmToPt(4), y: mmToPt(3), size: 6, font, color: rgb(0.45, 0.45, 0.42),
rotate: degrees(0),
// Klebefalz beim Posterdruck: gestrichelt, damit man sie nicht für eine
// Schnittkante hält.
const extras = input.extrasPerPage?.[i];
for (const l of extras?.dashed || []) {
page.drawLine({
start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) },
end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) },
thickness: 0.4, color: rgb(0.55, 0.55, 0.52), dashArray: [4, 3],
});
}
if (font) {
const put = (text: string, pos: FooterPos, size: number) => {
const w = font.widthOfTextAtSize(text, size);
const top = pos.startsWith('oben');
const y = top ? H - mmToPt(3) - size : mmToPt(3);
const x = pos.endsWith('mitte') ? (W - w) / 2
: pos.endsWith('rechts') ? W - mmToPt(4) - w
: mmToPt(4);
page.drawText(text, { x, y, size, font, color: rgb(0.45, 0.45, 0.42), rotate: degrees(0) });
};
// Fußzeile nur, wenn an der gewählten Kante wirklich Platz ist —
// sonst stünde sie mitten im Motiv.
if (input.footer?.text) {
const pos = input.footer.position || 'unten-links';
const size = input.footer.sizePt ?? 6;
const top = pos.startsWith('oben');
const free = pg.placements.length
? (top ? Math.min(...pg.placements.map((p) => p.y - bleed))
: sheet.hMm - Math.max(...pg.placements.map((p) => p.y + p.h + bleed)))
: sheet.hMm;
if (free >= size * 0.353 + 4) put(input.footer.text, pos, size);
}
// Blattnummer beim Posterdruck steht immer — sie ist die Montageanleitung.
if (extras?.note) put(extras.note, 'oben-links', 8);
}
});
return pdf.save();
+193
View File
@@ -0,0 +1,193 @@
// Druck-Historie — was zuletzt gedruckt wurde, und wie man es wiederholt.
//
// Der springende Punkt: Gespeichert wird die **Anleitung**, nicht das Ergebnis.
// Ein A3-Bogen als PDF ist schnell 20–80 MB; ein paar Dutzend davon füllen die
// Platte mit Dateien, die sich in Sekunden neu rechnen lassen. Also liegt hier
// der Anfragekörper, mit dem `/api/print/sheet` denselben Bogen erneut erzeugt
// — rund 1–3 KB je Eintrag.
//
// Das geht nur, weil das Druckmodul **deterministisch** ist: dieselben Quellen
// und dieselbe Einstellung ergeben dasselbe PDF. Bei den KI-Aufträgen wäre das
// Unsinn, dort käme jedes Mal etwas anderes heraus.
import { one, query } from './db.ts';
export const MAX_RUNS_JE_NUTZER = 100;
export interface PrintRun {
id: string;
created_by: string | null;
origin: string;
summary: string | null;
paper: string | null;
pages: number;
pieces: number;
bytes: number | null;
config: any;
created_at: string;
}
export interface RunEingang {
/** Vorgegebene Kennung — der Dateiname des PDFs trägt ihr Kürzel. */
id?: string | null;
userId: string | null;
origin?: string;
summary: string;
paper: string;
pages: number;
pieces: number;
bytes: number;
/** Der Anfragekörper, unverändert. */
config: any;
/** Kennungen der verwendeten Bibliotheksbilder. */
itemIds: string[];
}
/**
* Was am Bogen hing, in einem Satz.
*
* Die Liste kommt zellenweise herein — zwei Zellen mit demselben Maß ergäben
* sonst „10 × 15 cm, 10 × 15 cm". Gleiche Maße werden deshalb zusammengezogen
* und ihre Stückzahlen addiert; ein vorhandenes „×3" wird dabei mitgerechnet,
* damit nicht „×3 ×2" entsteht.
*/
export function kurzfassung(kinds: string[]): string {
if (!kinds.length) return 'Druckbogen';
// Nur die **abschließende** Stückzahl zählt: „35 × 45 mm ×8" hat zwei Mal ein
// ×, und ein gieriger Ausdruck machte aus „13 × 18 cm" prompt „13 cm ×18".
// Deshalb ans Ende verankert, mit optionalem Zusatz in Klammern dahinter.
const ZAHL = /^(.*?)\s*×\s*(\d+)(\s*\([^)]*\))?$/;
const summe = new Map<string, number>();
for (const k of kinds) {
const m = ZAHL.exec(k);
const grund = m ? `${m[1]}${m[3] || ''}` : k;
const anzahl = m ? Number(m[2]) : 1;
summe.set(grund, (summe.get(grund) || 0) + anzahl);
}
// „30 × 40 cm (Poster) ×3" läse sich schief — die Stückzahl gehört vor den
// Zusatz, dorthin, wo sie hereinkam.
const teile = [...summe].map(([grund, n]) => {
if (n <= 1) return grund;
const z = /^(.*?)(\s*\([^)]*\))$/.exec(grund);
return z ? `${z[1]} ×${n}${z[2]}` : `${grund} ×${n}`;
});
const s = teile.join(', ');
return s.length <= 120 ? s : `${s.slice(0, 117)}…`;
}
/**
* Einen Druck festhalten.
*
* Fehler hier dürfen den Druck **nie** scheitern lassen — das PDF ist zu dem
* Zeitpunkt längst fertig und unterwegs zum Nutzer. Deshalb fängt der Aufrufer
* ab, und diese Funktion meldet nur ins Log.
*/
export async function recordRun(e: RunEingang): Promise<string | null> {
const run = await one<{ id: string }>(
`INSERT INTO print_runs (id, created_by, origin, summary, paper, pages, pieces, bytes, config)
VALUES (COALESCE($1::uuid, gen_random_uuid()),$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
[e.id || null, e.userId, e.origin || 'web', e.summary, e.paper, e.pages, e.pieces, e.bytes,
JSON.stringify(e.config)]);
if (!run) return null;
const ids = [...new Set(e.itemIds.filter(Boolean))];
if (ids.length) {
// Bilder können zwischenzeitlich gelöscht worden sein — dann fällt die
// Verknüpfung weg, der Eintrag bleibt.
await query(
`INSERT INTO print_run_items (run_id, item_id)
SELECT $1, x FROM unnest($2::uuid[]) AS x
WHERE EXISTS (SELECT 1 FROM items WHERE id = x)
ON CONFLICT DO NOTHING`, [run.id, ids]).catch((err) =>
console.error('[printruns] Bildverweise nicht gespeichert:', err?.message || err));
}
// Alte Einträge desselben Nutzers wegräumen. Ohne das wächst die Liste
// unbegrenzt, und niemand scrollt bis zum Druck von vor zwei Jahren.
if (e.userId) {
await query(
`DELETE FROM print_runs
WHERE created_by = $1 AND id NOT IN (
SELECT id FROM print_runs WHERE created_by = $1
ORDER BY created_at DESC LIMIT $2)`,
[e.userId, MAX_RUNS_JE_NUTZER]).catch(() => {});
}
return run.id;
}
export interface RunListe extends PrintRun {
/** Bilder, die es noch gibt — nur damit lässt sich der Druck wiederholen. */
bilder: { id: string; filename: string | null }[];
/** Wie viele **Bibliotheksbilder** inzwischen gelöscht sind. */
fehlend: number;
/** Wie viele Zellen aus einer hochgeladenen Datei kamen — die sind weg. */
hochgeladen: number;
/** Lässt sich der Bogen unverändert neu erzeugen? */
wiederholbar: boolean;
}
/**
* Die letzten Drucke. Admins sehen alle, sonst nur die eigenen — dieselbe
* Regel wie bei den Teilen-Links.
*/
export async function listRuns(userId: string | null, istAdmin: boolean, limit = 30): Promise<RunListe[]> {
const runs = await query<PrintRun>(
`SELECT * FROM print_runs
WHERE $2::boolean OR created_by = $1
ORDER BY created_at DESC LIMIT $3`, [userId, istAdmin, Math.min(limit, 100)]);
if (!runs.length) return [];
const bilder = await query<{ run_id: string; id: string; filename: string | null }>(
`SELECT ri.run_id, i.id, i.filename
FROM print_run_items ri JOIN items i ON i.id = ri.item_id
WHERE ri.run_id = ANY($1::uuid[]) AND i.status = 'done' AND i.result_path IS NOT NULL`,
[runs.map((r) => r.id)]);
const nachRun = new Map<string, { id: string; filename: string | null }[]>();
for (const b of bilder) {
const l = nachRun.get(b.run_id) || [];
l.push({ id: b.id, filename: b.filename });
nachRun.set(b.run_id, l);
}
return runs.map((r) => {
const vorhanden = nachRun.get(r.id) || [];
const zellen: any[] = Array.isArray(r.config?.cells) ? r.config.cells : [];
// Nur Zellen aus der Bibliothek lassen sich wiederholen. Hochgeladene
// Dateien liegen unter `sources/…` und werden je nach Einstellung nach der
// Verarbeitung aufgeräumt — dann fehlt die Quelle.
const ausBibliothek = zellen.filter((c) => c?.src?.kind === 'item').length;
// Verschiedene Gründe, verschieden erklärt: Ein gelöschtes Bibliotheksbild
// ist etwas anderes als eine Datei, die nie in der Bibliothek lag. Beides
// in einen Zähler zu werfen führte zu „1 Bild ist nicht mehr da" für einen
// Bogen, dessen Quelle schlicht ein Upload war.
const eindeutige = new Set(zellen.filter((c) => c?.src?.kind === 'item')
.map((c) => String(c.src.id))).size;
return {
...r,
bilder: vorhanden,
fehlend: Math.max(0, eindeutige - vorhanden.length),
hochgeladen: zellen.length - ausBibliothek,
// Wiederholbar nur, wenn **alle** Zellen aus der Bibliothek kommen und
// alle Bilder noch da sind. Halb geht nicht: Ein Bogen mit fehlendem
// Bild wäre nicht derselbe Bogen.
wiederholbar: zellen.length > 0 && ausBibliothek === zellen.length && vorhanden.length === zellen.length,
};
});
}
/** Einen einzelnen Druck holen — für das erneute Erzeugen. */
export async function getRun(id: string, userId: string | null, istAdmin: boolean): Promise<PrintRun | null> {
return one<PrintRun>(
`SELECT * FROM print_runs WHERE id = $1 AND ($3::boolean OR created_by = $2)`,
[id, userId, istAdmin]);
}
export async function deleteRun(id: string, userId: string | null, istAdmin: boolean): Promise<boolean> {
const r = await query<{ id: string }>(
`DELETE FROM print_runs WHERE id = $1 AND ($3::boolean OR created_by = $2) RETURNING id`,
[id, userId, istAdmin]);
return r.length > 0;
}
+21 -9
View File
@@ -10,13 +10,28 @@ export interface SessionUser { uid: string | null; role: string }
/** Lädt die Bilddaten und prüft dabei die Zugriffsrechte wie /api/items/:id/file. */
export async function loadSource(src: PrintSource, user: SessionUser): Promise<Buffer> {
return (await loadSourceNamed(src, user)).buffer;
}
export interface GeladeneQuelle {
buffer: Buffer;
/** Ursprünglicher Dateiname, soweit bekannt — für den Namen der Ausgabedatei. */
filename: string | null;
/** Kennung des Bibliotheksbildes, sonst null — liefert das Kürzel im Namen. */
itemId: string | null;
}
/** Wie `loadSource`, gibt aber auch den Namen der Quelle zurück. */
export async function loadSourceNamed(src: PrintSource, user: SessionUser): Promise<GeladeneQuelle> {
if (!src || typeof src !== 'object') throw new Error('Quelle fehlt.');
if (src.kind === 'upload') {
// Nur der Upload-Bereich ist erreichbar — kein Weg zu results/ oder /etc.
const p = String(src.path || '');
if (!/^sources\/[0-9]{4}\/[A-Za-z0-9_-]+\.[A-Za-z0-9]{2,5}$/.test(p)) throw new Error('Ungültige Quelle.');
return getObject(p);
// \n bewusst ausschließen: JS-„$" matcht auch vor einem abschließenden Zeilenumbruch.
if (/[\r\n]/.test(p) || !/^sources\/[0-9]{4}\/[A-Za-z0-9_-]+\.[A-Za-z0-9]{2,5}$/.test(p))
throw new Error('Ungültige Quelle.');
return { buffer: await getObject(p), filename: p.split('/').pop() || null, itemId: null };
}
if (src.kind === 'item') {
@@ -30,15 +45,12 @@ export async function loadSource(src: PrintSource, user: SessionUser): Promise<B
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
if (item.private || s?.library_visibility !== 'shared') throw new Error('Kein Zugriff auf dieses Bild.');
}
return getObject(item.result_path);
return { buffer: await getObject(item.result_path), filename: item.filename ?? null, itemId: String(src.id) };
}
throw new Error('Unbekannte Quellenart.');
}
/** Sprechender Dateiname für den Download. */
export function sheetFilename(prefix: string, ext: string): string {
const date = new Date().toISOString().slice(0, 10);
const slug = (prefix || 'druckbogen').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
return `${date}_${slug || 'druckbogen'}.${ext}`;
}
// `sheetFilename` ist entfallen: Es setzte nur das Datum vor den Namen, also
// hießen zwei Bogen desselben Tages gleich. Die Namensregeln stehen jetzt
// vollständig in `naming.ts` — dieselben wie für Bibliotheksergebnisse.
+17 -2
View File
@@ -3,7 +3,10 @@ import { getObject, putObject, deleteObject, resultKey, thumbKey } from './stora
import { generateImage } from './openrouter';
import { buildPrompt, buildComposePrompt, buildGeneratePrompt, type Task } from './prompts';
import { resolveDimensions, aspectRatio, type Orientation } from './format';
import { finalizeToFormat, stickerContour, buildResultFilename, formatToken, type CropMode } from './pipeline';
import { finalizeToFormat, stickerContour, type CropMode } from './pipeline';
import { formatToken } from './naming';
import { uniqueFilename } from './renames';
import { embedProvenance, TRAINED_ALGORITHMIC_MEDIA, COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA } from './provenance';
import sharp from 'sharp';
const DEFAULT_MODEL = 'google/gemini-3.1-flash-image';
@@ -159,6 +162,15 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
contentType = 'image/jpeg';
}
// KI-Herkunft maschinenlesbar in die Datei schreiben (IPTC DigitalSourceType).
// „generate" ist vollständig erzeugt, alles andere ist ein verändertes Foto —
// dafür sieht die IPTC-Liste einen eigenen Code vor.
outBuf = embedProvenance(outBuf, {
sourceType: mode === 'generate' ? TRAINED_ALGORITHMIC_MEDIA : COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA,
model: modelUsed,
description: isPrivate ? null : prompt.slice(0, 300),
});
const key = resultKey(item.id);
await putObject(key, outBuf, contentType);
@@ -174,7 +186,10 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
} catch (e) { console.error('[process] Thumbnail fehlgeschlagen', e); }
}
const filename = buildResultFilename(item.filename, formatToken(r.output_format), ext);
// Eindeutig je Bild: Sekunde + Item-Kürzel im Namen, plus Gegenprobe in der
// Datenbank. Vorher konnten zwei gleichartige Bilder desselben Tages denselben
// Namen bekommen und sich bei Picdrop gegenseitig überschreiben.
const filename = await uniqueFilename(itemId, item.filename, formatToken(r.output_format), ext);
const outputPx = `${fin.width}x${fin.height}`;
// Private Aufträge: kein Delivery, kein Backup, kein gespeicherter Prompt.
const deliveryStatus = (!isPrivate && (r.delivery === 'picdrop' || r.delivery === 'both')) ? 'pending' : 'none';
+201
View File
@@ -0,0 +1,201 @@
// Maschinenlesbare Herkunftskennzeichnung für KI-erzeugte Bilder.
//
// Warum: Der EU AI Act (Art. 50) verlangt, dass KI-erzeugte Inhalte als solche
// **maschinenlesbar** gekennzeichnet sind. Der `.md`-Beileger, den Klarbild
// mitschickt, ist gute Dokumentation für Menschen — aber keine Kennzeichnung im
// Sinne der Norm. Der etablierte Weg ist das IPTC-Feld `DigitalSourceType` mit
// dem Wert `trainedAlgorithmicMedia`, eingebettet als XMP direkt in die Datei.
// (Das ersetzt keine Rechtsberatung; es setzt um, was Standard und Praxis sind.)
//
// Bewusst **ohne neue Abhängigkeit**: sharp 0.33 kann kein XMP schreiben, und ein
// Upgrade der nativen Bibliothek nur dafür wäre ein Deploy-Risiko (siehe die
// Learnings zu `tsx`). XMP in PNG und JPEG einzuhängen ist beides sauber
// spezifiziert und in wenigen Zeilen erledigt — und damit auch testbar.
// Die Kennung endet normgerecht auf ein Nullbyte. Als Escape geschrieben:
// Als rohes Steuerzeichen in der Quelldatei würde ein Formatter oder ein
// Editor daraus ein Leerzeichen machen — dann erkennt kein Werkzeug das
// Segment mehr als XMP, und kein Test schlüge an.
const XMP_NS = 'http://ns.adobe.com/xap/1.0/\u0000';
const BOM = '';
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/** Der IPTC-Code für „von einem trainierten Algorithmus erzeugt". */
export const TRAINED_ALGORITHMIC_MEDIA =
'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia';
/** Für Bilder, die ein Modell nur *verändert* hat (Bereinigen, Freistellen). */
export const COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA =
'http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia';
export interface Provenance {
/** IPTC-Code, siehe Konstanten oben. */
sourceType: string;
/** Verwendetes Modell, z. B. `google/gemini-3-pro-image`. */
model?: string | null;
/** Kurzbeschreibung dessen, was gemacht wurde. */
description?: string | null;
/** Erzeugungszeitpunkt. */
created?: Date;
}
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** XMP-Paket als Text — das, was gleich in die Datei wandert. */
export function buildXmp(p: Provenance): string {
const created = (p.created ?? new Date()).toISOString();
const tool = p.model ? `Klarbild (${p.model})` : 'Klarbild';
const desc = p.description
? `\n <dc:description><rdf:Alt><rdf:li xml:lang="x-default">${esc(p.description)}</rdf:li></rdf:Alt></dc:description>`
: '';
return `<?xpacket begin="${BOM}" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Klarbild">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:Iptc4xmpExt="http://iptc.org/std/Iptc4xmpExt/2008-02-29/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
Iptc4xmpExt:DigitalSourceType="${esc(p.sourceType)}"
xmp:CreatorTool="${esc(tool)}"
xmp:CreateDate="${created}">${desc}
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>`;
}
/* ---------------- PNG ---------------- */
const CRC_TABLE = (() => {
const t = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c;
}
return t;
})();
function crc32(buf: Buffer): number {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
/** PNG-Chunk bauen: Länge · Typ · Daten · CRC über Typ+Daten. */
function pngChunk(type: string, data: Buffer): Buffer {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0);
return Buffer.concat([len, body, crc]);
}
/**
* XMP als `iTXt`-Chunk hinter den IHDR-Chunk hängen. Die PNG-Spezifikation
* verlangt, dass Textchunks vor dem ersten `IDAT` stehen — direkt hinter IHDR
* ist die sichere Stelle.
*/
function embedPng(buf: Buffer, xmp: string): Buffer {
// 8 Byte Signatur, dann IHDR: 4 Länge + 4 Typ + Daten + 4 CRC
const ihdrLen = buf.readUInt32BE(8);
const insertAt = 8 + 4 + 4 + ihdrLen + 4;
const data = Buffer.concat([
Buffer.from('XML:com.adobe.xmp', 'latin1'),
// Nullbyte · Kompression 0 · Methode 0 · Sprache "" · übersetztes Schlüsselwort ""
Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00]),
Buffer.from(xmp, 'utf8'),
]);
return Buffer.concat([buf.subarray(0, insertAt), pngChunk('iTXt', data), buf.subarray(insertAt)]);
}
/* ---------------- JPEG ---------------- */
/**
* XMP als `APP1`-Segment einhängen. Ein vorhandenes APP0 (JFIF) und ein
* vorhandenes APP1 (Exif, von sharp für die dpi geschrieben) bleiben davor
* stehen — beide werden über ihre Kennung unterschieden, sie stören sich nicht.
*/
function embedJpeg(buf: Buffer, xmp: string): Buffer {
let pos = 2; // hinter SOI (FFD8)
while (pos + 4 <= buf.length && buf[pos] === 0xff) {
const marker = buf[pos + 1];
if (marker !== 0xe0 && marker !== 0xe1) break; // nur APP0/APP1 überspringen
pos += 2 + buf.readUInt16BE(pos + 2);
}
const payload = Buffer.concat([Buffer.from(XMP_NS, 'latin1'), Buffer.from(xmp, 'utf8')]);
const len = payload.length + 2;
if (len > 0xffff) return buf; // passt nicht in ein Segment — dann lieber gar nichts
const head = Buffer.alloc(4);
head.writeUInt16BE(0xffe1, 0);
head.writeUInt16BE(len, 2);
return Buffer.concat([buf.subarray(0, pos), head, payload, buf.subarray(pos)]);
}
/**
* Kennzeichnung in die Bilddatei schreiben. Erkennt PNG und JPEG selbst.
* Bei allem anderen (oder bei einem Fehler) kommt der Puffer unverändert
* zurück — eine fehlende Kennzeichnung ist ärgerlich, ein kaputtes Bild wäre
* schlimmer.
*/
export function embedProvenance(buf: Buffer, p: Provenance): Buffer {
try {
// Bereits vorhandene Kennzeichnung entfernen, statt eine zweite anzuhängen:
// Sonst stünde die **ältere** Modellangabe vorn, und Auslesewerkzeuge
// nähmen genau die. Bei einer Kennzeichnung nach Art. 50 AI Act soll aber
// das tatsächlich zuletzt verwendete Modell dort stehen.
const sauber = stripXmp(buf);
const xmp = buildXmp(p);
if (sauber.subarray(0, 8).equals(PNG_MAGIC)) return embedPng(sauber, xmp);
if (sauber[0] === 0xff && sauber[1] === 0xd8) return embedJpeg(sauber, xmp);
return sauber;
} catch {
return buf;
}
}
/** Vorhandene XMP-Pakete entfernen (PNG-iTXt bzw. JPEG-APP1). */
export function stripXmp(buf: Buffer): Buffer {
if (buf.subarray(0, 8).equals(PNG_MAGIC)) {
const teile: Buffer[] = [buf.subarray(0, 8)];
let pos = 8;
while (pos + 12 <= buf.length) {
const len = buf.readUInt32BE(pos);
const typ = buf.toString('latin1', pos + 4, pos + 8);
const ende = pos + 12 + len;
if (ende > buf.length) break;
const istXmp = typ === 'iTXt'
&& buf.toString('latin1', pos + 8, pos + 8 + 17) === 'XML:com.adobe.xmp';
if (!istXmp) teile.push(buf.subarray(pos, ende));
pos = ende;
if (typ === 'IEND') break;
}
return Buffer.concat(teile);
}
if (buf[0] === 0xff && buf[1] === 0xd8) {
const teile: Buffer[] = [buf.subarray(0, 2)];
let pos = 2;
while (pos + 4 <= buf.length && buf[pos] === 0xff) {
const marker = buf[pos + 1];
if (marker === 0xda) break; // ab hier kommen die Bilddaten
const len = buf.readUInt16BE(pos + 2);
const ende = pos + 2 + len;
if (ende > buf.length) break;
const istXmp = marker === 0xe1
&& buf.toString('latin1', pos + 4, pos + 4 + XMP_NS.length) === XMP_NS;
if (!istXmp) teile.push(buf.subarray(pos, ende));
pos = ende;
}
teile.push(buf.subarray(pos));
return Buffer.concat(teile);
}
return buf;
}
/** Liest die Kennzeichnung wieder aus — für Tests und zur Kontrolle. */
export function readXmp(buf: Buffer): string | null {
const i = buf.indexOf('<x:xmpmeta');
if (i < 0) return null;
const j = buf.indexOf('</x:xmpmeta>', i);
return j < 0 ? null : buf.subarray(i, j + 12).toString('utf8');
}
+103
View File
@@ -0,0 +1,103 @@
// Eindeutige Dateinamen — Vergabe für neue Bilder und Nachbenennung der Bibliothek.
//
// Hintergrund: Bis 20.08.2026 hieß ein Ergebnis `JJJJ-MM-TT_<motiv>_<format>.<ext>`.
// Wer am selben Tag zweimal dasselbe Motiv im selben Format erzeugte — etwa zwei
// Badges von Lea —, bekam zweimal denselben Namen. Bei Picdrop überschrieb das
// zweite Bild das erste. Seitdem steckt zusätzlich die Uhrzeit auf die Sekunde und
// ein aus der Item-ID abgeleitetes Kürzel im Namen.
import { query, one } from './db.ts';
import { buildResultFilename, newNameFor } from './naming.ts';
/** Ist dieser Name schon an ein *anderes* Bild vergeben? */
async function taken(name: string, exceptId: string): Promise<boolean> {
const row = await one<{ id: string }>(
'SELECT id FROM items WHERE filename = $1 AND id <> $2 LIMIT 1', [name, exceptId]);
return !!row;
}
/**
* Der Name ist durch Sekunde + Item-Kürzel praktisch schon eindeutig. Diese
* Schleife ist der Gürtel zum Hosenträger: Sie hängt `-2`, `-3` … an, falls der
* Name wider Erwarten doch vergeben ist. Bewusst als Code-Schleife und **nicht**
* als UNIQUE-Bedingung in der Datenbank — ein abgelehnter Schreibvorgang würde
* einen bereits bezahlten KI-Auftrag im letzten Schritt scheitern lassen.
*/
export async function uniqueFilename(
itemId: string, originalName: string | null, formatToken: string, ext: string, at?: Date,
/** Nur für Tests: sonst wird die Bibliothek befragt. */
istVergeben: (name: string) => Promise<boolean> = (n) => taken(n, itemId),
): Promise<string> {
for (let seq = 1; seq <= 50; seq++) {
const name = buildResultFilename(originalName, formatToken, ext, { at, id: itemId, seq });
if (!(await istVergeben(name))) return name;
}
// Unerreichbar, solange Item-IDs eindeutig sind — aber lieber ein hässlicher
// Name als ein überschriebenes Bild.
return buildResultFilename(originalName, formatToken, ext, { at, id: `${itemId}${Date.now()}` });
}
export interface RenameRow { id: string; alt: string; neu: string }
export interface RenameResult {
geprueft: number;
umbenannt: number;
unveraendert: number;
/** Namen, die vorher doppelt vergeben waren — genau der gemeldete Fehler. */
vorherDoppelt: number;
probelauf: boolean;
beispiele: RenameRow[];
}
/**
* Alle Bibliotheksbilder auf das neue Namensschema bringen.
* Idempotent: Zeitstempel kommt aus `created_at`, Kürzel aus der Item-ID — ein
* zweiter Lauf ändert nichts mehr. Die Dateien selbst bleiben unberührt, sie
* liegen im Speicher unter der Item-ID, nicht unter dem Namen.
*/
export async function renameLibrary(probelauf = true): Promise<RenameResult> {
const items = await query<{ id: string; filename: string | null; created_at: Date }>(
`SELECT id, filename, created_at FROM items
WHERE status='done' AND filename IS NOT NULL ORDER BY created_at`);
// Wie viele Namen waren vorher mehrfach vergeben?
const zaehler = new Map<string, number>();
for (const it of items) zaehler.set(it.filename!, (zaehler.get(it.filename!) || 0) + 1);
const vorherDoppelt = [...zaehler.values()].filter((n) => n > 1).reduce((a, n) => a + n - 1, 0);
const vergeben = new Set<string>();
const rows: RenameRow[] = [];
let umbenannt = 0, unveraendert = 0;
for (const it of items) {
let neu = newNameFor(it);
for (let seq = 2; vergeben.has(neu) && seq <= 50; seq++) neu = newNameFor(it, seq);
vergeben.add(neu);
if (neu === it.filename) { unveraendert++; continue; }
umbenannt++;
if (rows.length < 25) rows.push({ id: it.id, alt: it.filename!, neu });
if (!probelauf) await query('UPDATE items SET filename=$2 WHERE id=$1', [it.id, neu]);
}
return { geprueft: items.length, umbenannt, unveraendert, vorherDoppelt, probelauf, beispiele: rows };
}
/**
* Alle schon ausgelieferten Bilder erneut ausliefern — unter dem neuen Namen.
* Nötig, weil die Umbenennung nur die Bibliothek betrifft: Was bereits bei
* Picdrop liegt, heißt dort weiterhin alt. Die alten Dateien bleiben stehen und
* müssen bei Bedarf von Hand entfernt werden; automatisch etwas beim Kunden zu
* löschen wäre der falsche Reflex.
*/
export async function redeliverAll(): Promise<{ versucht: number; ok: number; fehler: number }> {
const items = await query<{ id: string }>(
`SELECT i.id FROM items i JOIN jobs j ON j.id = i.job_id
WHERE i.status='done' AND i.result_path IS NOT NULL
AND i.delivery_status IN ('delivered','failed') AND j.private = false
ORDER BY i.created_at`);
const { deliverItem } = await import('./delivery.ts');
let ok = 0, fehler = 0;
for (const it of items) {
const r = await deliverItem(it.id).catch(() => ({ ok: false }));
r.ok ? ok++ : fehler++;
}
return { versucht: items.length, ok, fehler };
}
+373
View File
@@ -0,0 +1,373 @@
// Teilen-Links: ein Bild oder eine Sammlung öffentlich zugänglich machen,
// ohne dass der Empfänger ein Konto braucht.
//
// Grundhaltung: Auf diesen Bildern sind Kinder. Ein Link, der für immer gilt und
// den jeder weiterleiten kann, ist deshalb die falsche Voreinstellung. Jeder Link
// bekommt darum von sich aus ein **Ablaufdatum**, lässt sich jederzeit
// **widerrufen**, kann mit einem **Zugangswort** versehen werden, und die Seite
// verbietet Suchmaschinen die Aufnahme.
import { randomBytes, createHash, createHmac, timingSafeEqual } from 'node:crypto';
import argon2 from 'argon2';
import { one, query } from './db.ts';
/** Ohne i, l, 0, O, 1 — damit ein vorgelesener Link nicht falsch ankommt. */
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
/** 12 Zeichen aus 31 = rund 59 Bit. Nicht zu erraten, noch vorlesbar. */
export function neuerSlug(laenge = 12): string {
const bytes = randomBytes(laenge * 2);
let s = '';
for (let i = 0; s.length < laenge && i < bytes.length; i++) {
const v = bytes[i];
// Rest-Werte verwerfen, sonst wären die vorderen Zeichen häufiger.
if (v < 256 - (256 % ALPHABET.length)) s += ALPHABET[v % ALPHABET.length];
}
return s.length === laenge ? s : neuerSlug(laenge);
}
export type ShareKind = 'item' | 'collection';
export interface Share {
id: string; slug: string; kind: ShareKind;
title: string | null; note: string | null;
created_by: string | null;
expires_at: string | null; revoked_at: string | null;
allow_download: boolean; password_hash: string | null;
views: number; downloads: number;
last_seen_at: string | null; created_at: string;
}
export interface ShareErstellen {
kind?: ShareKind;
itemIds: string[];
title?: string | null;
note?: string | null;
/** Tage bis zum Ablauf; 0 oder null = unbegrenzt. */
days?: number | null;
allowDownload?: boolean;
password?: string | null;
userId: string | null;
}
export const MAX_ITEMS = 500;
export const MAX_TITEL = 200;
export const MAX_NOTIZ = 2000;
const MAX_TAGE = 3650;
/**
* Tage bis zum Ablauf sauber lesen.
*
* `Number('abc')` ist `NaN`, und `NaN > 0` ist `false` — mit der naiven
* Schreibweise landete jede unsinnige Eingabe im Zweig „unbegrenzt". Genau die
* Voreinstellung, die diese Datei oben als falsch bezeichnet. Deshalb: alles,
* was keine sinnvolle Zahl ist, wird abgelehnt statt stillschweigend gedeutet.
*/
export function tageLesen(v: unknown, standard: number): number {
if (v === undefined || v === null || v === '') return standard;
// Nur Zahl oder Zeichenkette annehmen: `Number([])` ist 0 und `Number([7])`
// ist 7 — ein versehentlich mitgeschicktes Array würde sonst stillschweigend
// zu „unbegrenzt" oder zu einer erfundenen Frist.
if (typeof v !== 'number' && typeof v !== 'string')
throw new Error('Gültigkeit muss eine Zahl von Tagen sein.');
const n = Number(v);
if (!Number.isFinite(n)) throw new Error('Gültigkeit muss eine Zahl von Tagen sein.');
const t = Math.trunc(n);
if (t < 0 || t > MAX_TAGE) throw new Error(`Gültigkeit muss zwischen 0 und ${MAX_TAGE} Tagen liegen.`);
return t;
}
const kurz = (v: unknown, max: number): string | null => {
const t = String(v ?? '').trim();
return t ? t.slice(0, max) : null;
};
/**
* Link anlegen. Ohne ausdrückliche Angabe gilt die Voreinstellung aus den
* Einstellungen (30 Tage) — nicht „unbegrenzt".
*/
export async function createShare(input: ShareErstellen): Promise<Share> {
const ids = [...new Set(input.itemIds.filter(Boolean))].slice(0, MAX_ITEMS);
if (!ids.length) throw new Error('Keine Bilder ausgewählt.');
const s = await one<{ share_default_days: number }>('SELECT share_default_days FROM settings WHERE id=1');
const tage = tageLesen(input.days, s?.share_default_days ?? 30);
const expires = tage > 0 ? new Date(Date.now() + tage * 86400_000) : null;
const kind: ShareKind = input.kind === 'collection' || ids.length > 1 ? 'collection' : 'item';
const pw = input.password?.trim() ? await argon2.hash(input.password.trim()) : null;
const share = await one<Share>(
`INSERT INTO shares (slug, kind, title, note, created_by, expires_at, allow_download, password_hash)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[neuerSlug(), kind, kurz(input.title, MAX_TITEL), kurz(input.note, MAX_NOTIZ),
input.userId, expires, input.allowDownload !== false, pw]);
// In **einem** Schritt einfügen: Vorher waren es bis zu 500 einzelne Abfragen,
// und wenn eine davon scheiterte (etwa weil ein Bild zwischenzeitlich gelöscht
// wurde), blieb ein halb gefüllter Link stehen, während die Oberfläche einen
// Fehler meldete.
try {
await query(
`INSERT INTO share_items (share_id, item_id, position)
SELECT $1, x.id, x.pos FROM unnest($2::uuid[]) WITH ORDINALITY AS x(id, pos)
ON CONFLICT DO NOTHING`, [share!.id, ids]);
} catch (e) {
// Kein halber Link: Was nicht vollständig entstehen konnte, wird entfernt.
await query('DELETE FROM shares WHERE id = $1', [share!.id]).catch(() => {});
throw e;
}
return share!;
}
export type ShareStatus = 'ok' | 'nicht-gefunden' | 'widerrufen' | 'abgelaufen' | 'passwort';
/**
* Link auflösen und prüfen, ob er gerade gilt.
* Widerrufen und abgelaufen werden **unterschieden** — der Empfänger soll
* verstehen, warum nichts kommt, statt einen Tippfehler zu vermuten.
*/
export async function resolveShare(slug: string, passwort?: string | null): Promise<{
status: ShareStatus; share?: Share;
}> {
if (!/^[a-z0-9]{6,32}$/.test(String(slug || ''))) return { status: 'nicht-gefunden' };
const share = await one<Share>('SELECT * FROM shares WHERE slug=$1', [slug]);
if (!share) return { status: 'nicht-gefunden' };
if (share.revoked_at) return { status: 'widerrufen', share };
if (share.expires_at && new Date(share.expires_at).getTime() < Date.now()) return { status: 'abgelaufen', share };
if (share.password_hash) {
if (!passwort) return { status: 'passwort', share };
let ok = false;
try { ok = await argon2.verify(share.password_hash, passwort); } catch { ok = false; }
if (!ok) return { status: 'passwort', share };
}
return { status: 'ok', share };
}
export interface ShareBild {
id: string; filename: string | null; output_px: string | null; created_at: string;
}
/** Die Bilder hinter einem Link, in der gespeicherten Reihenfolge. */
export async function shareItems(shareId: string): Promise<ShareBild[]> {
return query<ShareBild>(
// Kein Join auf `jobs`: `items.job_id` ist laut Schema nullable, ein INNER
// JOIN könnte also Zeilen verschlucken — und gebraucht wurde davon ohnehin
// nichts.
`SELECT i.id, i.filename, i.output_px, i.created_at
FROM share_items si
JOIN items i ON i.id = si.item_id
WHERE si.share_id = $1 AND i.status = 'done' AND i.result_path IS NOT NULL
ORDER BY si.position, i.created_at`, [shareId]);
}
/** Gehört dieses Bild zu diesem Link? Gegenprobe vor jeder Auslieferung. */
export async function shareHasItem(shareId: string, itemId: string): Promise<boolean> {
const r = await one<{ item_id: string }>(
'SELECT item_id FROM share_items WHERE share_id=$1 AND item_id=$2', [shareId, itemId]);
return !!r;
}
/**
* Zugriff festhalten. Die IP wird **nur gehasht** gespeichert, gesalzen mit dem
* Server-Geheimnis: Damit lassen sich wiederkehrende Besucher zählen, aber die
* Adresse ist nicht zurückzurechnen und niemand ist identifizierbar.
*/
export async function trackShare(
shareId: string, kind: 'view' | 'download', ip?: string | null, ua?: string | null,
): Promise<void> {
const salz = process.env.SESSION_SECRET || 'klarbild';
// Der Hash bindet die Kennung des Links mit ein: Damit lässt sich derselbe
// Besucher **innerhalb** eines Links wiedererkennen, aber nicht über mehrere
// Links hinweg verfolgen. (Ehrlich gesagt: Das ist Pseudonymisierung, keine
// Anonymisierung — wer den Server-Schlüssel hat, kann den IPv4-Raum
// durchrechnen. Deshalb wird auch nur 90 Tage aufgehoben.)
const ipHash = ip
? createHash('sha256').update(`${salz}|${shareId}|${ip}`).digest('base64url').slice(0, 22)
: null;
const stunde = new Date().toISOString().slice(0, 13); // „2026-09-01T14"
try {
// Höchstens eine Zeile je Besucher und Stunde — sonst füllt ein einzelner
// Empfänger (oder ein <img>-Tag auf einer fremden Seite) die Tabelle.
const neu = await query<{ id: string }>(
`INSERT INTO share_views (share_id, kind, ip_hash, user_agent, stunde)
VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING RETURNING id`,
[shareId, kind, ipHash, geraet(ua), stunde]);
// Der Zähler zählt jeden Aufruf, die Besucherzahl nur neue Kennungen.
await query(
`UPDATE shares SET last_seen_at = now(),
views = views + CASE WHEN $2='view' THEN 1 ELSE 0 END,
downloads = downloads + CASE WHEN $2='download' THEN 1 ELSE 0 END,
besucher = besucher + CASE WHEN $3 AND $2='view' THEN 1 ELSE 0 END
WHERE id = $1`, [shareId, kind, neu.length > 0]);
} catch (e: any) {
// Zählen ist Beiwerk — es darf die Auslieferung nie verhindern.
console.error('[share] Zugriff nicht gezählt:', e?.message || e);
}
}
/**
* Aus dem User-Agent nur Browser und System behalten.
* Die vollständige Zeichenkette ist ein brauchbarer Fingerabdruck; für „womit
* wurde geöffnet" reicht „Safari · iPhone" völlig.
*/
export function geraet(ua?: string | null): string | null {
const s = String(ua || '');
if (!s) return null;
const browser = /Edg\//.test(s) ? 'Edge' : /OPR\//.test(s) ? 'Opera'
: /Chrome\//.test(s) ? 'Chrome' : /Firefox\//.test(s) ? 'Firefox'
: /Safari\//.test(s) ? 'Safari' : 'Browser';
const system = /iPhone/.test(s) ? 'iPhone' : /iPad/.test(s) ? 'iPad'
: /Android/.test(s) ? 'Android' : /Macintosh|Mac OS/.test(s) ? 'Mac'
: /Windows/.test(s) ? 'Windows' : /Linux/.test(s) ? 'Linux' : '';
return system ? `${browser} · ${system}` : browser;
}
export interface ShareStat extends Share {
anzahl: number; besucher: number;
}
/** Übersicht für den Eigentümer (Admins sehen alle). */
export async function listShares(userId: string | null, istAdmin: boolean): Promise<ShareStat[]> {
return query<ShareStat>(
// `anzahl` zählt dieselben Bedingungen wie `shareItems` — sonst nennt die
// Verwaltung eine andere Zahl als die Seite, die der Empfänger sieht.
// `besucher` kommt aus der gepflegten Spalte statt aus einer Aggregation
// über share_views: Bei vielen Zugriffen wurde diese Seite sonst zäh.
`SELECT s.*,
(SELECT count(*)::int FROM share_items si JOIN items i ON i.id = si.item_id
WHERE si.share_id = s.id AND i.status='done' AND i.result_path IS NOT NULL) AS anzahl,
s.besucher
FROM shares s
WHERE $2::boolean OR s.created_by = $1
ORDER BY s.created_at DESC LIMIT 200`, [userId, istAdmin]);
}
/** Widerrufen — der Link bleibt bestehen, liefert aber nichts mehr aus. */
export async function revokeShare(id: string, userId: string | null, istAdmin: boolean): Promise<boolean> {
const r = await query<{ id: string }>(
`UPDATE shares SET revoked_at = now()
WHERE id = $1 AND ($3::boolean OR created_by = $2) AND revoked_at IS NULL RETURNING id`,
[id, userId, istAdmin]);
return r.length > 0;
}
/** Wieder freigeben — praktisch, wenn man sich verklickt hat. */
export async function unrevokeShare(id: string, userId: string | null, istAdmin: boolean): Promise<boolean> {
const r = await query<{ id: string }>(
`UPDATE shares SET revoked_at = NULL
WHERE id = $1 AND ($3::boolean OR created_by = $2) RETURNING id`, [id, userId, istAdmin]);
return r.length > 0;
}
export interface ShareAendern {
title?: string | null; note?: string | null;
days?: number | null; allowDownload?: boolean;
/** Leerer Text entfernt das Zugangswort. */
password?: string | null;
}
export async function updateShare(
id: string, userId: string | null, istAdmin: boolean, a: ShareAendern,
): Promise<boolean> {
const eigen = await one<{ id: string }>(
'SELECT id FROM shares WHERE id=$1 AND ($3::boolean OR created_by=$2)', [id, userId, istAdmin]);
if (!eigen) return false;
const sets: string[] = []; const args: any[] = [id];
const set = (col: string, val: any) => { args.push(val); sets.push(`${col}=$${args.length}`); };
if (a.title !== undefined) set('title', kurz(a.title, MAX_TITEL));
if (a.note !== undefined) set('note', kurz(a.note, MAX_NOTIZ));
if (a.allowDownload !== undefined) set('allow_download', !!a.allowDownload);
if (a.days !== undefined) {
const t = tageLesen(a.days, 0);
set('expires_at', t > 0 ? new Date(Date.now() + t * 86400_000) : null);
}
if (a.password !== undefined) {
set('password_hash', a.password?.trim() ? await argon2.hash(a.password.trim()) : null);
}
if (!sets.length) return true;
await query(`UPDATE shares SET ${sets.join(',')} WHERE id=$1`, args);
return true;
}
export async function deleteShare(id: string, userId: string | null, istAdmin: boolean): Promise<boolean> {
const r = await query<{ id: string }>(
'DELETE FROM shares WHERE id=$1 AND ($3::boolean OR created_by=$2) RETURNING id', [id, userId, istAdmin]);
return r.length > 0;
}
/** Zeitkonstanter Vergleich — verrät nicht über die Dauer, wie weit man richtig lag. */
export function gleich(a: string, b: string): boolean {
const x = Buffer.from(String(a)); const y = Buffer.from(String(b));
if (x.length !== y.length) return false;
return timingSafeEqual(x, y);
}
/* ---------------- Namen im Sammelpaket ---------------- */
/**
* Eindeutige Dateinamen innerhalb eines ZIP.
*
* Zwei gleiche Namen im selben Archiv sind kein Fehler beim Packen — sie fallen
* erst beim **Auspacken** auf, wo das zweite das erste überschreibt oder der
* Entpacker nachfragt. Beides ärgerlich, beides vermeidbar.
*
* `vergeben` wird dabei fortgeschrieben; die Funktion ist also bewusst nicht rein,
* sondern arbeitet auf dem Zustand des laufenden Archivs.
*/
export function archivName(
roh: string | null | undefined, vergeben: Map<string, number>, standardEndung = 'jpg',
): string {
let name = String(roh || '').replace(/[/\\]/g, '_').replace(/[\u0000-\u001f]/g, '').trim()
|| `bild.${standardEndung}`;
if (!/\.[a-z0-9]{1,8}$/i.test(name)) name += `.${standardEndung}`;
const n = vergeben.get(name);
if (n == null) { vergeben.set(name, 0); return name; }
vergeben.set(name, n + 1);
return name.replace(/(\.[^.]+)$/, `_${n + 1}$1`);
}
/** Dateiname fürs Sammelpaket: Datum und Titel, auf Dateisystem-Zeichen reduziert. */
export function paketName(titel: string | null | undefined, am = new Date()): string {
const slug = String(titel || '').toLowerCase()
.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c))
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'klarbild';
return `${am.toISOString().slice(0, 10)}_${slug}.zip`;
}
/* ---------------- Nachweis für ein geprüftes Zugangswort ----------------
* `argon2.verify` kostet rund 130 ms und 64 MiB — und lief vorher bei **jeder**
* Anfrage, also auch für jedes einzelne Bild einer Galerie. Eine Seite mit 40
* Bildern hätte den Prozess in die Speichergrenze getrieben, und ein paar
* Dutzend Anfragen mit falschem Wort hätten die ganze Anwendung lahmgelegt.
*
* Deshalb: einmal prüfen, danach einen kurzlebigen, signierten Nachweis
* mitführen, der **nur für diesen einen Link** gilt. */
const NACHWEIS_DAUER = 6 * 60 * 60; // 6 Stunden
export const nachweisName = (slug: string) => `kb_s_${slug}`;
export function nachweisBauen(shareId: string): string {
const bis = Math.floor(Date.now() / 1000) + NACHWEIS_DAUER;
const sig = createHmac('sha256', process.env.SESSION_SECRET || 'klarbild')
.update(`${shareId}.${bis}`).digest('base64url');
return `${bis}.${sig}`;
}
export function nachweisGilt(shareId: string, wert?: string | null): boolean {
if (!wert) return false;
const [bisRoh, sig] = String(wert).split('.');
const bis = Number(bisRoh);
if (!Number.isFinite(bis) || bis < Math.floor(Date.now() / 1000)) return false;
const soll = createHmac('sha256', process.env.SESSION_SECRET || 'klarbild')
.update(`${shareId}.${bis}`).digest('base64url');
return !!sig && gleich(sig, soll);
}
/** Nachweis aus dem Cookie-Kopf ziehen. */
export function nachweisAusCookie(cookieHeader: string | null | undefined, slug: string): string | null {
const name = nachweisName(slug);
return cookieHeader?.split(/;\s*/).find((c) => c.startsWith(`${name}=`))?.slice(name.length + 1) || null;
}
+5 -3
View File
@@ -26,7 +26,7 @@ function mainKeyboard() {
return new Keyboard().text(BTN_NEW).row().text(BTN_RECIPES).text(BTN_HELP).resized().persistent();
}
// Druckbogen per Telegram: feste, knopfbare Auswahl — kein Freitext nötig.
const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S12x15', 'S13x18'];
const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S13x18', 'R30x40'];
const PRINT_COUNTS = [1, 2, 4, 8, 0]; // 0 = „Bogen füllen"
const HELP_TEXT =
@@ -35,7 +35,7 @@ const HELP_TEXT =
'🔀 *Kombinieren:* 2+ Bilder schicken, ins Bild-Textfeld eine Beschreibung wie „mit unserer Hündin Frieda" — danach „🔀 Kombinieren" antippen.\n' +
'✨ *Neues Bild:* unten „✨ Neues Bild" tippen und beschreiben, was entstehen soll.\n' +
'♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n' +
'📐 *Druckbogen (ohne KI):* Bild schicken → „📐 Druckbogen" → Maß und Anzahl antippen. ' +
'🖨 *Drucken (ohne KI):* Bild schicken → „🖨 Drucken" → Maß und Anzahl antippen. ' +
'Du bekommst ein PDF in 100 %-Größe mit Schnittmarken.\n\n' +
'Ich melde mich einmal, wenn alles fertig ist — mit Ergebnis und Link.';
@@ -135,7 +135,7 @@ export async function sweepDrafts(): Promise<void> {
const n = (d.file_refs || []).length;
// Ab 2 Bildern zusätzlich „Kombinieren" anbieten.
if (n >= 2) { kb.row(); kb.text('🔀 Zu einem Bild kombinieren', 'c:x'); }
kb.row(); kb.text('📐 Druckbogen (ohne KI)', 'p:menu');
kb.row(); kb.text('🖨 Drucken (ohne KI)', 'p:menu');
const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : '';
try {
@@ -438,6 +438,8 @@ function register(b: Bot) {
b.on('callback_query:data', async (ctx) => {
const [kind, recipeId, extra] = ctx.callbackQuery.data.split(':');
await ctx.answerCallbackQuery();
// Auch Knöpfe brauchen eine aktive Kopplung — sonst wirkt ein alter Chat weiter.
if (!(await linkedUser(ctx.chat!.id))) return ctx.editMessageText('⛔️ Nicht (mehr) gekoppelt. Bitte neuen Kopplungscode aus dem Admin eingeben.');
if (kind === 'p') { // Druckbogen ohne KI
const draft = await one<{ id: string }>(
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status IN ('awaiting_recipe','awaiting_print')
+46 -4
View File
@@ -1,9 +1,17 @@
import { defineMiddleware } from 'astro:middleware';
import { ensureInit } from './lib/init';
import { readSession } from './lib/auth';
import { readSession, verifySession } from './lib/auth';
import { one } from './lib/db';
const PUBLIC_PATHS = [/^\/login/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/, /^\/llms\.txt/];
const PUBLIC_PATHS = [
/^\/login/, /^\/vorstellung/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//,
/^\/api\/telegram\/webhook/, /^\/llms\.txt/,
// Passwort vergessen — muss ohne Anmeldung erreichbar sein, sonst ist es sinnlos.
/^\/passwort/, /^\/api\/auth\/reset/,
// Teilen-Links: Wer den Link hat, sieht die Bilder — dafür sind sie gedacht.
// Die Prüfung steckt im Link selbst (Ablauf, Widerruf, Zugangswort).
/^\/s\//, /^\/api\/s\//,
];
/** API-Token (Authorization: Bearer …) → synthetischer Admin-Nutzer für /api/*. */
async function tokenUser(header: string | null): Promise<any | null> {
@@ -19,7 +27,10 @@ export const onRequest = defineMiddleware(async (ctx, next) => {
// Health/Webhook dürfen laufen, auch wenn Init noch hakt — sonst blockiert nichts.
try { await ensureInit(); } catch (e) { if (ctx.url.pathname !== '/api/health') throw e; }
let user = readSession(ctx.request.headers.get('cookie'));
// Signatur und Alter prüft `readSession`; ob das Konto noch existiert, die
// Rolle noch stimmt und der Sitzungsstand noch gilt, entscheidet erst die
// Datenbank — sonst überlebte ein Cookie jeden Passwortwechsel.
let user = await verifySession(readSession(ctx.request.headers.get('cookie')));
// Programmatischer Zugriff per API-Token (nur für /api/*, außer Admin-Bereich).
if (!user && ctx.url.pathname.startsWith('/api/') && !ctx.url.pathname.startsWith('/api/admin')) {
user = await tokenUser(ctx.request.headers.get('authorization'));
@@ -38,5 +49,36 @@ export const onRequest = defineMiddleware(async (ctx, next) => {
return ctx.redirect('/');
}
}
return next();
return withSecurityHeaders(await next());
});
/**
* Sicherheits-Header. Die Content-Security-Policy selbst baut Astro beim Bauen
* (`csp` in `astro.config.mjs`) und hängt echte **Hashes** für jedes
* eigene inline-Skript an — also kein `'unsafe-inline'` für Skripte, das ist der
* wichtige Teil gegen eingeschleusten Code.
*
* `style-src` wird hier bewusst wieder auf `'unsafe-inline'` gesetzt: React setzt
* Stile als Attribut am Element (`style={{…}}`), und Attribute lassen sich nicht
* hashen. Gemessen: Mit reiner Hash-Policy blockt der Browser sie, und die
* Vorschau im Druckmodul verliert ihr Layout. Der verbleibende Rest — CSS-basierte
* Exfiltration — ist eine deutlich schmalere Klasse als Skript-Injektion, und die
* bleibt zu.
*/
function withSecurityHeaders(res: Response): Response {
const csp = res.headers.get('content-security-policy');
if (csp) {
res.headers.set('content-security-policy',
csp.replace(/style-src[^;]*/i, "style-src 'self' 'unsafe-inline'"));
}
// Kein MIME-Sniffing: Ergebnisdateien werden mit erkanntem Typ ausgeliefert,
// der Browser soll nicht selbst raten und etwas als Skript ausführen.
res.headers.set('x-content-type-options', 'nosniff');
// Beim Wechsel auf fremde Seiten keinen Pfad mitschicken (Galerie-Namen!).
res.headers.set('referrer-policy', 'strict-origin-when-cross-origin');
// Ältere Browser ohne CSP-frame-ancestors.
res.headers.set('x-frame-options', 'DENY');
// Keine dieser Schnittstellen wird gebraucht.
res.headers.set('permissions-policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
return res;
}
+92 -9
View File
@@ -56,37 +56,83 @@ import Base from '../layouts/Base.astro';
<li><b>Mehrere auf einmal</b>: Bilder markieren, dann als <b>ZIP laden</b> oder gesammelt <b>in einen Ordner</b>
verschieben.</li>
</ul>
<p class="hinweis"><b>Dateinamen sind eindeutig.</b> Jedes Ergebnis heißt
<code>JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</code>, also z. B.
<code>2026-08-20_143207_lea-badge_the-frame_a3f9.jpg</code>. Weil Datum und Uhrzeit vorn stehen,
sortiert jede Dateiliste — Picdrop, NAS, Finder — von allein chronologisch. Das Kürzel am Ende stammt
aus der Kennung des Bildes: <b>zwei Bilder können sich beim Ausliefern nicht mehr überschreiben</b>,
auch wenn du am selben Tag zweimal dasselbe Motiv im selben Format erzeugst.
(Vor dem 21.08.2026 war das anders — dort ging in so einem Fall das erste Bild verloren.
Unter <b>Admin → Dateinamen</b> lässt sich die vorhandene Bibliothek nachträglich umbenennen.)</p>
</section>
<section>
<h2>5 · Druck — exakte Maße und Schnittmarken (ohne KI)</h2>
<p class="lead">Der Reiter <b>Druck</b> ist der Fotolabor-Teil von Klarbild: kein Modell, keine Kosten,
keine Wartezeit. Nur Zuschnitt, Skalierung und Geometrie.</p>
<h2>5 · Drucken — exakte Maße und Schnittmarken (ohne KI)</h2>
<p class="lead">Der Reiter <b>Drucken</b> ist der Fotolabor-Teil von Klarbild: kein Modell, keine Kosten,
keine Wartezeit. Nur Zuschnitt, Skalierung und Geometrie — vom biometrischen Passbild bis zum
30 × 40-Poster.</p>
<ol>
<li>Bilder hochladen (ziehen, ⌘/Strg + V, oder <b>Aus Bibliothek</b> ein fertiges Ergebnis holen).</li>
<li>Je Bild das <b>Endmaß</b> wählen — biometrisches Passbild 35 × 45 mm, 3 × 4 cm, 9 × 13, 10 × 15,
12 × 15, 13 × 18 … oder <b>Eigenes Maß</b>: <code>12x15</code> (cm), <code>35x45mm</code>,
<code>5</code> (= 5 × 5 cm) oder <code>4:3/15</code> (Verhältnis 4:3, längere Kante 15 cm).</li>
<li>Bilder hochladen (ziehen, ⌘/Strg + V) — oder <b>Aus der Bibliothek wählen</b>: alles, was
Klarbild schon erzeugt hat, mit Suche, nach Ordner und mehrere auf einmal. Kein Umweg mehr
über einen erneuten Upload.</li>
<li>Je Bild das <b>Endmaß</b> wählen. Es sind <b>alle Formate der KI-Generierung</b> da —
biometrisches Passbild 35 × 45 mm, 2 × 3 bis 6 × 9 cm, 9 × 13 bis 24 × 30,
Poster- und Rahmenmaße 30 × 40 bis 70 × 100, Quadrate, DIN A6–A2 und die
Seitenverhältnisse (16:9 „The Frame", 9:16, 3:2, 4:3, 5:4) — oder <b>Eigenes Maß</b>:
<code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) oder
<code>4:3/15</code> (Verhältnis 4:3, längere Kante 15 cm).</li>
<li><b>Wenn das Bild nicht zum Format passt</b>, entscheidest du je Bild:
<b>Zuschneiden</b> (das Format wird ausgefüllt, außen fehlt etwas — Standard) oder
<b>Rand lassen</b> (das ganze Bild bleibt sichtbar, außen steht die <b>Randfarbe</b> —
weiß, schwarz, papierfarben oder frei gewählt). <b>Verzerrt wird nie.</b></li>
<li>Auf das Vorschaubild tippen → <b>Zuschnitt</b>: schieben und zoomen. Das Seitenverhältnis bleibt
fest am Zielformat, verzerrt wird nie.</li>
<li><b>Anzahl</b> je Bild setzen — so entsteht der Passbild- oder Kita-Satz.</li>
<li><b>Dasselbe Bild in zwei Maßen?</b> <b>Duplizieren</b> antippen — das Bild erscheint ein
zweites Mal in der Liste und bekommt dort sein eigenes Maß. Kein Umbenennen und erneutes
Hochladen mehr.</li>
<li><b>Papierformat</b> wählen: DIN A6–A2 inklusive <b>A3+ (329 × 483 mm)</b>, Fotopapier-Zuschnitte
9 × 13 bis 20 × 30, US Letter/Legal — oder ein eigenes Papiermaß.</li>
<li><b>Schnitthilfen</b>: <b>Eckmarken</b> (feine Marken außerhalb des Endformats, wie in Photoshop
und InDesign) oder <b>durchgehende Linien</b> über den ganzen Bogen fürs Schneidelineal.
Die durchgehenden Linien laufen nie durch ein anderes Motiv.</li>
<li><b>Ist das Endmaß größer als der Bogen</b> — etwa 20 × 30 cm auf DIN-A4-Fotopapier —
erscheint direkt am Bild die Frage, wie es weitergehen soll:
<b>Überstehen</b> legt das Bild mittig aufs ganze Blatt, der Überstand wird abgeschnitten
(bei 20 × 30 auf A4 fehlen genau 3 mm in der Höhe, und genau das steht auch dabei), oder
<b>Posterdruck</b> verteilt es auf mehrere Blätter zum Zusammenkleben.</li>
<li><b>Druck-PDF erzeugen</b>. Die PDF-Seite hat exakt das Bogenmaß.</li>
</ol>
<p><b>Zuletzt gedruckt.</b> Ganz unten steht, was du zuvor gedruckt hast — mit Maßen, Stückzahl
und den Bildern. <b>Nochmal drucken</b> erzeugt genau denselben Bogen ein zweites Mal, ohne dass
du irgendetwas neu einstellst; <b>Einstellung übernehmen</b> holt nur Papier, Marken und Ränder
zurück, für „wie damals, aber mit anderen Fotos".</p>
<p class="mini">Aufgehoben werden dabei <b>nicht die PDFs</b> — die sind riesig, ein A3-Bogen wären
schnell 20 bis 80 MB — sondern nur die Einstellung, ein paar Kilobyte. Weil das Druckmodul ohne
KI rechnet, ergibt derselbe Auftrag wieder exakt dasselbe Blatt. „Nochmal drucken" gibt es
deshalb nur, wenn alle Bilder aus der Bibliothek kamen und noch da sind; bei hochgeladenen
Dateien ist die Quelle nach dem Aufräumen weg, und dann steht das auch dabei.</p>
<p class="hinweis"><b>Wichtig beim Drucken:</b> im Druckdialog „Tatsächliche Größe" bzw. „100 %" wählen —
<b>nicht</b> „an Seitengröße anpassen". Sonst stimmt das Maß nicht mehr. Wer randlos drucken kann,
setzt den Rand auf 0; sonst 3–5 mm, sonst schneidet der Drucker die Marken weg.</p>
<ul>
<li><b>Beschnittzugabe</b> (0–10 mm): das Bild ragt über die Schnittkante hinaus, damit bei leicht
schiefem Schnitt kein weißer Rand entsteht. Der Abstand zwischen zwei Bildern wächst automatisch mit.</li>
<li><b>Posterdruck</b>: Beim Verteilen auf mehrere Blätter überlappen sich die Kacheln um einen
<b>Klebefalz</b> (Standard 10 mm, frei einstellbar). Der Falz wird auf beiden Nachbarblättern
gedruckt und mit einer gestrichelten Linie markiert — beim Zusammensetzen legt man das eine
Blatt entlang dieser Linie aufs andere. Jedes Blatt trägt oben seine Nummer samt Reihe und
Spalte. 30 × 40 cm auf A4 ergibt 2 × 2 Blätter.</li>
<li><b>Beschriftung</b>: Unter dem Bogen steht standardmäßig eine kleine Zeile mit Maßen, dpi und
dem Druckhinweis. Sie lässt sich <b>ausschalten</b>, mit <b>eigenem Text</b> versehen (z. B.
„Phoenix · August 2026") und an eine von sechs Stellen legen — oben oder unten, jeweils links,
mittig oder rechts. Ist an der gewählten Kante kein Platz frei, entfällt sie automatisch;
über ein Bild wird sie nie gedruckt.</li>
<li><b>Einzeln herunterladen</b>: ein Bild exakt auf Maß als PNG/JPG mit dpi-Metadaten — ohne Bogen.</li>
<li><b>Auflösungswarnung</b>: reicht die Quelle nicht für die gewählte dpi, steht es rot unter dem Bild.</li>
<li><b>Vorlagen</b>: die ganze Bogen-Einstellung (Papier, Marken, Formate, Stückzahlen) speichern.
Mitgeliefert sind <b>Kita-Satz (A4)</b> (1 × 13 × 18, 2 × 9 × 13, 8 × Passbild),
Mitgeliefert sind <b>Kita-Satz (A4)</b> (1 × 13 × 18, 2 × 9 × 13, 8 × Passbild — das ist
bewusst mehr, als auf ein Blatt geht; die Vorschau sagt dir, wie viele Bogen es werden),
<b>Schulsatz klein (A4)</b>, <b>Passbildbogen 35 × 45 (A4)</b> und
<b>2 × 10 × 7,5 auf Fotopapier 10 × 15</b>. Lädst du eine mehrformatige Vorlage bei nur
<i>einem</i> Bild, wird das Bild automatisch für jedes Format übernommen — ein Klick,
@@ -98,7 +144,11 @@ import Base from '../layouts/Base.astro';
<li><b>Automatische Anordnung</b>: gleich große Bilder ergeben ein sauberes Raster; bei gemischten
Größen werden die kleinen automatisch in die Restflächen neben den großen gesetzt.</li>
</ul>
<p class="mini">Auch per Telegram: Bild schicken → „📐 Druckbogen" → Maß und Anzahl antippen → PDF zurück.</p>
<p class="mini">Auch per Telegram: Bild schicken → „🖨 Drucken" → Maß und Anzahl antippen → PDF zurück.
Am Handy steht die Vorschau oben und „Druck-PDF erzeugen" liegt als fester Balken griffbereit
über der Navigation; am Rechner arbeitest du links und siehst rechts, was dabei herauskommt.
Wer das Werkzeug jemandem zeigen will, ohne dass etwas hochgeladen werden kann, schickt den
Link auf <a href="/vorstellung">die Vorstellungsseite</a>.</p>
</section>
<section>
@@ -124,11 +174,44 @@ import Base from '../layouts/Base.astro';
<p>Im Admin-Bereich lässt sich eine <b>zweite Sicherung aufs NAS</b> einrichten (SFTP/FTPS, auch über
Tailscale) — jedes fertige Bild wird dann zusätzlich gespiegelt und in der Bibliothek mit <b>„NAS ✓"</b>
markiert. Mit <b>„Alle vorhandenen Bilder aufs NAS sichern"</b> lassen sich auch ältere Bilder nachsichern.</p>
<p>Unter <b>„Dateinamen"</b> lässt sich die vorhandene Bibliothek auf das eindeutige Namensschema bringen.
<b>„Bibliothek prüfen"</b> zeigt zuerst, was sich ändern würde und wie viele Namen aktuell doppelt vergeben
sind; <b>„Jetzt umbenennen"</b> setzt es um. Die Bilddateien selbst bleiben dabei unangetastet — sie liegen
im Speicher unter ihrer Kennung, nicht unter ihrem Namen. Ein zweiter Lauf ändert nichts mehr.
<b>„Erneut ausliefern"</b> lädt danach alle schon ausgelieferten Bilder unter dem neuen Namen erneut hoch;
die alten Dateien bleiben bei Picdrop liegen und räumst du dort selbst weg.</p>
<p>Zum Aufräumen gibt es <b>„Bibliothek &amp; Aufträge zurücksetzen"</b>: löscht alle Bilder, Aufträge und Ordner
(Nutzer, Einstellungen, Modelle und Rezepte bleiben) — mit Sicherheitsabfrage. Am besten vorher einmal
aufs NAS sichern.</p>
</section>
<section>
<h2>Mein Konto, Teilen &amp; E-Mail</h2>
<ul>
<li><b><a href="/konto">Mein Konto</a></b>: E-Mail-Adresse hinterlegen — damit kannst du dir
ein neues Passwort schicken lassen, wenn du deines vergisst. Dort lässt sich auch einschalten,
dass du eine kurze Mail bekommst, sobald ein Auftrag fertig ist.</li>
<li><b>Teilen</b>: In der Bibliothek auf <b>Teilen</b> tippen — einzeln oder für mehrere markierte
Bilder auf einmal. Es entsteht ein Link, den auch jemand <b>ohne Klarbild-Konto</b> öffnen kann;
von dort lässt sich das Bild in voller Auflösung herunterladen.</li>
<li><b>Weitergeben</b> per <b>WhatsApp</b> (öffnet WhatsApp mit fertigem Text), über dein eigenes
Mailprogramm, oder direkt aus Klarbild heraus per E-Mail an mehrere Empfänger.</li>
<li><b>Alle auf einmal</b>: Enthält ein Link mehrere Bilder, steht oben auf der Seite
<b>„Alle N Bilder herunterladen"</b> — ein ZIP in voller Auflösung. Sehr große Sammlungen
werden bei 500 MB abgeschnitten; im Paket liegt dann ein Hinweis dazu.</li>
<li><b>Sicher bleiben</b>: Jeder Link läuft von selbst ab (Standard 30 Tage), lässt sich jederzeit
<b>abschalten</b> und auf Wunsch mit einem <b>Zugangswort</b> schützen. Das Wort schickst du dann
getrennt vom Link — sonst nützt es nichts.</li>
<li><b><a href="/teilen">Geteilte Links</a></b>: Übersicht über alles, was gerade offen ist —
mit Aufrufen und Downloads. Festgehalten wird <b>wann</b> und <b>womit</b> geöffnet wurde,
nicht <b>wer</b>: Die IP-Adresse liegt nur gehasht vor und lässt sich nicht zurückrechnen.</li>
<li><b>Passwort vergessen?</b> Auf der Anmeldeseite unten auf „Passwort vergessen?" — der Link
aus der Mail gilt eine Stunde und lässt sich nur einmal verwenden.</li>
</ul>
<p class="mini">Bilder aus einer <b>privaten Sitzung</b> lassen sich bewusst nicht teilen und lösen
auch keine Benachrichtigung aus.</p>
</section>
<section>
<h2>Tipps</h2>
<ul>
+15 -2
View File
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
import { randomBytes } from 'node:crypto';
import { one, query } from '../../../lib/db';
import { encrypt, maskSecret, decrypt } from '../../../lib/crypto';
import { loadMailSource } from '../../../lib/mail';
export const prerender = false;
const randomToken = () => 'klb_' + randomBytes(24).toString('hex');
@@ -10,6 +11,7 @@ const json = (b: unknown, s = 200) =>
export const GET: APIRoute = async () => {
const s = await one<any>('SELECT * FROM settings WHERE id=1');
const mail = await loadMailSource();
if (!s) return json({ settings: null });
let orMask = '';
if (s.openrouter_key_enc) { try { orMask = maskSecret(decrypt(s.openrouter_key_enc)); } catch { orMask = 'gesetzt'; } }
@@ -32,6 +34,14 @@ export const GET: APIRoute = async () => {
// NAS-Sicherung
nas_enabled: s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
nas_user: s.nas_user, nas_base_path: s.nas_base_path, nas_password_set: !!s.nas_password_enc,
// E-Mail-Versand (Passwort wird nie zurückgegeben, nur ob es gesetzt ist)
smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure,
smtp_user: s.smtp_user, smtp_from: s.smtp_from, smtp_password_set: !!s.smtp_password,
// Woher der gerade wirksame Zugang kommt: 'datenbank' | 'umgebung' | null
smtp_quelle: mail?.quelle ?? null,
smtp_wirksam: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null,
// Teilen
share_default_days: s.share_default_days,
} });
};
@@ -43,15 +53,18 @@ export const PATCH: APIRoute = async ({ request }) => {
if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key)));
if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password)));
if (b.nas_password) set('nas_password_enc', encrypt(String(b.nas_password)));
if (b.smtp_password) set('smtp_password', encrypt(String(b.smtp_password)));
if (b.generate_api_token) set('api_token', randomToken());
const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled', 'metadata_sidecar',
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'anonymous_generations', 'private_allowed', 'allow_nsfw'];
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
'smtp_secure'];
for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path',
'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url',
'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token',
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'output_ext',
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) {
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path',
'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_from', 'share_default_days']) {
if (col in b) {
const raw = b[col];
const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
+6 -1
View File
@@ -1,5 +1,6 @@
import type { APIRoute } from 'astro';
import { storageStats, runRetention, purgeSources, mirrorAllToNas, rebuildThumbs } from '../../../lib/maintenance';
import { renameLibrary, redeliverAll } from '../../../lib/renames';
import { one } from '../../../lib/db';
export const prerender = false;
@@ -8,12 +9,16 @@ const json = (b: unknown, s = 200) =>
export const GET: APIRoute = async () => json({ stats: await storageStats() });
// POST { action: 'retention' | 'purge_sources' }
// POST { action: 'retention' | 'purge_sources' | 'rename_preview' | 'rename_apply' | 'redeliver_all' | … }
export const POST: APIRoute = async ({ request }) => {
const b = await request.json().catch(() => ({}));
if (b.action === 'purge_sources') return json(await purgeSources());
if (b.action === 'mirror_all') return json(await mirrorAllToNas());
if (b.action === 'rebuild_thumbs') return json(await rebuildThumbs());
// Eindeutige Dateinamen: erst zeigen, was passieren würde, dann anwenden.
if (b.action === 'rename_preview') return json(await renameLibrary(true));
if (b.action === 'rename_apply') return json(await renameLibrary(false));
if (b.action === 'redeliver_all') return json(await redeliverAll());
if (b.action === 'retention') {
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
if (!s?.retention_days) return json({ error: 'Keine Aufbewahrungsfrist gesetzt.' }, 400);
+34
View File
@@ -0,0 +1,34 @@
import type { APIRoute } from 'astro';
import { testMail, sendMail, mailLayout, istMail } from '../../../lib/mail';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/**
* Ohne `to`: nur die Verbindung prüfen (verschickt nichts).
* Mit `to`: eine echte Testnachricht schicken — erst das beweist, dass die
* Mail auch ankommt und nicht im Spam landet.
*/
export const POST: APIRoute = async ({ request }) => {
const b = await request.json().catch(() => ({}));
const to = String(b.to || '').trim();
if (!to) return json(await testMail());
if (!istMail(to)) return json({ ok: false, message: 'Das ist keine gültige Adresse.' }, 400);
try {
await sendMail({
to,
subject: 'Klarbild — Testnachricht',
text: 'Wenn du das liest, funktioniert der E-Mail-Versand aus Klarbild.\n\n'
+ 'Damit können jetzt: Passwort-Rücksetzungen, Hinweise zu fertigen Aufträgen und Teilen-Links per Mail.',
html: mailLayout('Der Versand funktioniert', [
'Wenn du das liest, kommen E-Mails aus deiner Klarbild-Installation an.',
'Damit funktionieren jetzt: Passwort zurücksetzen, Hinweise zu fertigen Aufträgen und Teilen-Links per Mail.',
]),
});
return json({ ok: true, message: `Testnachricht an ${to} verschickt. Schau auch im Spam-Ordner nach.` });
} catch (e: any) {
console.error('[test-mail]', e?.message || e);
return json({ ok: false, message: 'Versand fehlgeschlagen — Zugangsdaten und Port prüfen.' });
}
};
+6 -1
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
import { hashPassword } from '../../../lib/auth';
import { hashPassword, bumpSessionEpoch } from '../../../lib/auth';
export const prerender = false;
const json = (b: unknown, s = 200) =>
@@ -16,6 +16,8 @@ export const POST: APIRoute = async ({ request }) => {
const b = await request.json();
if (b.id && b.password) {
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [b.id, await hashPassword(b.password)]);
// Neues Passwort heißt: alte Sitzungen gelten nicht mehr.
await bumpSessionEpoch(b.id);
return json({ ok: true });
}
if (b.id && 'private_forced' in b) {
@@ -24,6 +26,9 @@ export const POST: APIRoute = async ({ request }) => {
}
if (b.id && b.role) {
await query(`UPDATE users SET role=$2 WHERE id=$1`, [b.id, b.role === 'admin' ? 'admin' : 'user']);
// Rolle geändert → Sitzung entwerten, sonst behielte ein herabgestufter
// Admin seine Rechte bis zum Ablauf des Cookies.
await bumpSessionEpoch(b.id);
return json({ ok: true });
}
if (!b.username || !b.password) return json({ error: 'username + password nötig.' }, 400);
+16 -5
View File
@@ -1,10 +1,14 @@
import type { APIRoute } from 'astro';
import { login, makeSessionCookie, rateLimited, noteFailure, clearFailures } from '../../../lib/auth';
import { echteIp, zuOft } from '../../../lib/limit';
export const prerender = false;
export const POST: APIRoute = async ({ request, clientAddress }) => {
const ip = clientAddress || 'unknown';
// Nicht `clientAddress` direkt: Das ist der **erste** Wert aus
// X-Forwarded-For und damit vom Aufrufer selbst gesetzt — die Sperre nach
// fünf Fehlversuchen ließe sich sonst mit einem wechselnden Header umgehen.
const ip = echteIp(request, clientAddress);
if (rateLimited(ip)) {
return new Response(JSON.stringify({ error: 'Zu viele Versuche. Bitte in 15 Minuten erneut.' }),
{ status: 429, headers: { 'Content-Type': 'application/json' } });
@@ -19,15 +23,22 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
username = String(f.get('username') || ''); password = String(f.get('password') || '');
}
const user = await login(username.trim(), password);
if (!user) {
// Zusätzlich je Benutzername zählen — sonst hilft die IP-Sperre nichts, wenn
// der Angreifer aus vielen Netzen kommt.
if (zuOft('login-user:' + username.trim().toLowerCase(), 10, 15 * 60_000)) {
return new Response(JSON.stringify({ error: 'Zu viele Versuche. Bitte in 15 Minuten erneut.' }),
{ status: 429, headers: { 'Content-Type': 'application/json' } });
}
const angemeldet = await login(username.trim(), password);
if (!angemeldet) {
noteFailure(ip);
return new Response(JSON.stringify({ error: 'Benutzername oder Passwort falsch.' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } });
}
clearFailures(ip);
return new Response(JSON.stringify({ ok: true, role: user.role }), {
return new Response(JSON.stringify({ ok: true, role: angemeldet.user.role }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Set-Cookie': makeSessionCookie(user) },
headers: { 'Content-Type': 'application/json', 'Set-Cookie': makeSessionCookie(angemeldet.user, angemeldet.epoch) },
});
};
+91
View File
@@ -0,0 +1,91 @@
import type { APIRoute } from 'astro';
import { randomBytes, createHash } from 'node:crypto';
import { one, query } from '../../../lib/db';
import { hashPassword, bumpSessionEpoch } from '../../../lib/auth';
import { sendMail, mailLayout, mailReady, istMail, esc } from '../../../lib/mail';
import { zuOft, echteIp } from '../../../lib/limit';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
const GUELTIG_MINUTEN = 60;
const hashToken = (t: string) => createHash('sha256').update(t).digest('hex');
const basis = () => (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
/**
* Rücksetz-Link anfordern (`{ email }`) oder neues Passwort setzen
* (`{ token, password }`).
*
* Wichtig: Beim Anfordern antwortet die Route **immer gleich**, egal ob es die
* Adresse gibt. Sonst ließe sich hier ablesen, wer ein Konto hat.
*/
export const POST: APIRoute = async ({ request, clientAddress }) => {
const ip = echteIp(request, clientAddress);
const b = await request.json().catch(() => ({}));
// --- Neues Passwort setzen -------------------------------------------------
if (b.token) {
const token = String(b.token);
const pw = String(b.password || '');
if (pw.length < 10) return json({ error: 'Das Passwort muss mindestens 10 Zeichen haben.' }, 400);
if (zuOft('reset-einloesen:' + ip, 20, 15 * 60_000)) return json({ error: 'Zu viele Versuche. Bitte später erneut.' }, 429);
const r = await one<{ user_id: string }>(
`SELECT user_id FROM password_resets
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()`, [hashToken(token)]);
if (!r) return json({ error: 'Dieser Link ist abgelaufen oder wurde schon benutzt.' }, 400);
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [r.user_id, await hashPassword(pw)]);
// Einmalnutzung, und alle anderen offenen Links desselben Kontos verfallen mit.
await query('UPDATE password_resets SET used_at = now() WHERE user_id=$1 AND used_at IS NULL', [r.user_id]);
// Entscheidend: **alle bestehenden Sitzungen entwerten**. Wer das Passwort
// zurücksetzt, tut das meist, weil jemand anderes hineingekommen ist — ohne
// diesen Schritt bliebe dessen Cookie bis zu 30 Tage gültig und der ganze
// Vorgang wäre wirkungslos.
await bumpSessionEpoch(r.user_id);
return json({ ok: true });
}
// --- Rücksetz-Link anfordern ----------------------------------------------
const email = String(b.email || '').trim().toLowerCase();
if (!istMail(email)) return json({ error: 'Bitte eine gültige E-Mail-Adresse eingeben.' }, 400);
// Zwei Grenzen: je Absender **und** je Zieladresse. Ohne die zweite ließe sich
// ein bestimmtes Postfach von wechselnden Adressen aus zumüllen — auf Kosten
// des Zustellrufs der eigenen Domain.
if (zuOft('reset-ip:' + ip, 5, 15 * 60_000)) return json({ ok: true });
if (zuOft('reset-mail:' + email, 3, 60 * 60_000)) return json({ ok: true });
if (!(await mailReady())) return json({ error: 'Auf diesem Server ist kein E-Mail-Versand eingerichtet.' }, 503);
const u = await one<{ id: string; display_name: string | null; username: string }>(
'SELECT id, display_name, username FROM users WHERE lower(email) = $1', [email]);
if (u) {
const token = randomBytes(32).toString('base64url');
await query(
`INSERT INTO password_resets (token_hash, user_id, expires_at)
VALUES ($1, $2, now() + ($3 || ' minutes')::interval)`,
[hashToken(token), u.id, String(GUELTIG_MINUTEN)]);
const url = `${basis()}/passwort?token=${token}`;
const name = u.display_name || u.username;
// Nicht abwarten: Ein SMTP-Versand dauert Hunderte Millisekunden. Wartet die
// Antwort darauf, verrät allein die Dauer, ob es das Konto gibt — und die
// gleichlautende Antwort unten wäre wertlos.
void sendMail({
to: email,
subject: 'Passwort für Klarbild zurücksetzen',
text: `Hallo ${name},\n\nüber den folgenden Link kannst du dir ein neues Passwort für Klarbild setzen. `
+ `Der Link gilt ${GUELTIG_MINUTEN} Minuten und lässt sich nur einmal verwenden.\n\n${url}\n\n`
+ `Wenn du das nicht angefordert hast, kannst du diese Nachricht ignorieren — es ändert sich dann nichts.`,
html: mailLayout('Neues Passwort setzen', [
`Hallo ${esc(name)},`,
`über den Knopf unten setzt du dir ein neues Passwort für Klarbild. Der Link gilt <b>${GUELTIG_MINUTEN} Minuten</b> und lässt sich nur <b>einmal</b> verwenden.`,
`Wenn du das nicht angefordert hast, kannst du diese Nachricht ignorieren — es ändert sich dann nichts.`,
], { text: 'Neues Passwort setzen', url }),
}).catch((e) => console.error('[reset] Mail fehlgeschlagen:', e?.message || e));
}
// Immer dieselbe Antwort — sonst verrät sie, welche Adressen ein Konto haben.
return json({ ok: true });
};
+4 -2
View File
@@ -1,4 +1,5 @@
import type { APIRoute } from 'astro';
import { buildAuswahlZipName } from '../../../lib/naming';
import archiver from 'archiver';
import { query } from '../../../lib/db';
import { getObject } from '../../../lib/storage';
@@ -40,11 +41,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
done.catch(() => {});
},
});
const stamp = new Date().toISOString().slice(0, 10);
// Eindeutig statt nur datiert: Zwei Auswahlen desselben Tages hießen gleich.
const name = buildAuswahlZipName(rows.length);
return new Response(stream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="klarbild_${stamp}.zip"`,
'Content-Disposition': `attachment; filename="${name}"`,
},
});
};
+49 -5
View File
@@ -1,15 +1,59 @@
import type { APIRoute } from 'astro';
import { one } from '../../lib/db';
import { one, query } from '../../lib/db';
import { istMail, mailReady } from '../../lib/mail';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const s = await one<{ private_allowed: boolean }>('SELECT private_allowed FROM settings WHERE id=1');
const u = await one<{ private_forced: boolean }>('SELECT private_forced FROM users WHERE id=$1', [locals.user.uid]);
return new Response(JSON.stringify({
const s = await one<{ private_allowed: boolean; share_default_days: number }>(
'SELECT private_allowed, share_default_days FROM settings WHERE id=1');
const u = await one<{ private_forced: boolean; email: string | null; notify_jobs: boolean; display_name: string | null; username: string }>(
'SELECT private_forced, email, notify_jobs, display_name, username FROM users WHERE id=$1', [locals.user.uid]);
return json({
role: locals.user.role,
private_allowed: !!s?.private_allowed,
private_forced: !!u?.private_forced,
}), { headers: { 'Content-Type': 'application/json' } });
// Eigenes Konto: E-Mail und Benachrichtigungen
username: u?.username ?? null,
display_name: u?.display_name ?? null,
email: u?.email ?? null,
notify_jobs: !!u?.notify_jobs,
mail_ready: await mailReady(),
share_default_days: s?.share_default_days ?? 30,
});
};
/** Eigenes Konto pflegen: E-Mail hinterlegen und Benachrichtigungen schalten. */
export const PATCH: APIRoute = async ({ request, locals }) => {
if (!locals.user?.uid) return new Response('Unauthorized', { status: 401 });
const b = await request.json().catch(() => ({}));
if ('email' in b) {
const roh = String(b.email ?? '').trim().toLowerCase();
if (roh && !istMail(roh)) return json({ error: 'Das sieht nicht nach einer gültigen Adresse aus.' }, 400);
// Die Adresse ist der Weg, ein Passwort zurückzusetzen — sie darf deshalb
// nicht zweimal vergeben sein.
if (roh) {
const belegt = await one<{ id: string }>(
'SELECT id FROM users WHERE lower(email)=$1 AND id<>$2', [roh, locals.user.uid]);
if (belegt) return json({ error: 'Diese Adresse gehört schon zu einem anderen Konto.' }, 409);
}
try {
await query('UPDATE users SET email=$2 WHERE id=$1', [locals.user.uid, roh || null]);
} catch (e: any) {
// Zwei gleichzeitige Anfragen können an der Prüfung oben vorbeikommen —
// der eindeutige Index fängt es ab. Dann dieselbe verständliche Auskunft
// wie oben statt einer HTML-Fehlerseite.
if (e?.code === '23505') return json({ error: 'Diese Adresse gehört schon zu einem anderen Konto.' }, 409);
throw e;
}
}
if ('notify_jobs' in b) {
await query('UPDATE users SET notify_jobs=$2 WHERE id=$1', [locals.user.uid, !!b.notify_jobs]);
}
return json({ ok: true });
};
+18 -4
View File
@@ -8,10 +8,13 @@ const json = (b: unknown, s = 200) =>
/** Bogen-Vorlagen: Papier, Marken, Bildformate und Stückzahlen — ohne Bilder. */
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const s = await one<any>('SELECT anonymous_generations FROM settings WHERE id=1');
const hideName = !!s?.anonymous_generations && locals.user.role !== 'admin';
const rows = await query(
`SELECT p.id, p.name, p.config, p.created_at, u.display_name AS by_name
`SELECT p.id, p.name, p.config, p.created_at, p.created_by,
${hideName ? 'NULL' : 'u.display_name'} AS by_name
FROM print_presets p LEFT JOIN users u ON u.id = p.created_by
ORDER BY p.name ASC`);
ORDER BY p.name ASC LIMIT 200`);
return json({ presets: rows });
};
@@ -21,9 +24,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
const name = String(body?.name || '').trim();
if (!name) return json({ error: 'Name fehlt.' }, 400);
if (!body?.config || typeof body.config !== 'object') return json({ error: 'Konfiguration fehlt.' }, 400);
// Vorlagen werden allen Nutzern ausgeliefert — deshalb eine harte Größengrenze.
const cfg = JSON.stringify(body.config);
if (cfg.length > 20000) return json({ error: 'Vorlage zu groß.' }, 400);
const mine = await one<{ n: string }>(
`SELECT count(*)::text AS n FROM print_presets WHERE created_by IS NOT DISTINCT FROM $1`, [locals.user.uid]);
if (Number(mine?.n || 0) >= 100) return json({ error: 'Zu viele Vorlagen — bitte zuerst aufräumen.' }, 400);
const row = await one(
`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3) RETURNING id, name, config`,
[name.slice(0, 80), locals.user.uid, JSON.stringify(body.config)]);
[name.slice(0, 80), locals.user.uid, cfg]);
return json({ preset: row });
};
@@ -31,6 +40,11 @@ export const DELETE: APIRoute = async ({ url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const id = url.searchParams.get('id');
if (!id) return json({ error: 'id fehlt.' }, 400);
await query('DELETE FROM print_presets WHERE id=$1', [id]);
// Nur eigene Vorlagen (Admins dürfen alles) — sonst löscht jeder jedem den Kita-Satz.
const isAdmin = locals.user.role === 'admin';
const del = await query(
`DELETE FROM print_presets WHERE id=$1 AND ($2::bool OR created_by IS NOT DISTINCT FROM $3) RETURNING id`,
[id, isAdmin, locals.user.uid]);
if (!del.length) return json({ error: 'Vorlage nicht gefunden oder nicht deine.' }, 404);
return json({ ok: true });
};
+23
View File
@@ -0,0 +1,23 @@
import type { APIRoute } from 'astro';
import { listRuns, deleteRun } from '../../../lib/printruns';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Die letzten Drucke — Einstellung und Quellen, nie das PDF selbst. */
export const GET: APIRoute = async ({ locals, url }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const limit = Number(url.searchParams.get('limit')) || 30;
const runs = await listRuns(locals.user.uid, locals.user.role === 'admin', limit);
return json({ runs });
};
export const DELETE: APIRoute = async ({ locals, url }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const id = url.searchParams.get('id') || '';
if (!UUID.test(id)) return json({ error: 'Unbekannter Eintrag.' }, 404);
const ok = await deleteRun(id, locals.user.uid, locals.user.role === 'admin');
return ok ? json({ ok: true }) : json({ error: 'Unbekannter Eintrag.' }, 404);
};
+165 -24
View File
@@ -1,17 +1,22 @@
import type { APIRoute } from 'astro';
import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode } from '../../../lib/printlayout';
import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf } from '../../../lib/printrender';
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
import { randomUUID } from 'node:crypto';
import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode, type Page, type Line } from '../../../lib/printlayout';
import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset, type Oversize } from '../../../lib/printoversize';
import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf, type PageExtras, type FooterPos } from '../../../lib/printrender';
import { loadSource, type PrintSource } from '../../../lib/printsource';
import { buildSheetFilename } from '../../../lib/naming';
import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
import { cfgForKey } from '../../../lib/delivery';
import { uploadBuffer } from '../../../lib/picdrop';
import { one } from '../../../lib/db';
import { recordRun, kurzfassung } from '../../../lib/printruns';
export const prerender = false;
const MAX_CELLS = 40; // verschiedene Bilder je Bogen
const MAX_COPIES = 200; // Exemplare je Bild
const MAX_PAGES = 20;
const MAX_PAGES = 60; // Posterdruck braucht mehr Seiten als ein Passbildbogen
const MAX_PIECES = 500; // Summe aller Exemplare — schützt den Packer vor Entartung
interface CellIn {
id: string;
@@ -22,6 +27,10 @@ interface CellIn {
count?: number;
allowRotate?: boolean;
landscape?: boolean; // Bildformat quer statt hoch
fit?: 'cover' | 'contain';// abweichendes Seitenverhältnis: zuschneiden oder einpassen
bg?: string; // Randfarbe beim Einpassen
oversize?: Oversize; // größer als der Bogen: melden, überstehen lassen oder kacheln
overlapMm?: number; // Klebefalz beim Kacheln
}
const json = (b: unknown, s = 200) =>
@@ -43,26 +52,39 @@ export const POST: APIRoute = async ({ request, locals }) => {
const specs: PlaceSpec[] = [];
const resolved: { cell: CellIn; wMm: number; hMm: number }[] = [];
// Zellen, die nicht auf den Bogen passen und dafür einen eigenen Weg haben.
const big: { cell: CellIn; wMm: number; hMm: number; count: number; mode: Oversize }[] = [];
for (const [i, c] of cellsIn.entries()) {
const size = cellSize(c);
if (!size) return json({ error: `Bild ${i + 1}: Maß fehlt oder ist ungültig.` }, 400);
const id = String(c.id || `c${i}`);
specs.push({
id, wMm: size.w, hMm: size.h,
count: clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES),
allowRotate: c.allowRotate !== false,
});
const count = clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES);
const allowRotate = c.allowRotate !== false;
const mode: Oversize = c.oversize === 'overflow' || c.oversize === 'tile' ? c.oversize : 'fit';
if (mode !== 'fit' && !fitsOnSheet(size.w, size.h, sheet, allowRotate)) {
big.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h, count, mode });
continue;
}
specs.push({ id, wMm: size.w, hMm: size.h, count, allowRotate });
resolved.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h });
}
if (!specs.length && !big.length) return json({ error: 'Keine Bilder angegeben.' }, 400);
const plan = layout(specs, sheet);
if (!plan.pages.length) {
const totalPieces = specs.reduce((n, s) => n + s.count, 0) + big.reduce((n, b) => n + b.count, 0);
if (totalPieces > MAX_PIECES) return json({ error: `Zu viele Einzelbilder (${totalPieces}). Höchstens ${MAX_PIECES} je Bogen-Auftrag.` }, 422);
const plan = specs.length ? layout(specs, sheet) : { pages: [] as Page[], unplaced: [], perSheet: 0 };
if (!plan.pages.length && !big.length) {
return json({ error: 'Nichts platzierbar — Bildmaß größer als die Nutzfläche des Bogens.', unplaced: plan.unplaced }, 422);
}
if (plan.pages.length > MAX_PAGES) return json({ error: `Zu viele Bogen (${plan.pages.length}). Bitte Anzahl reduzieren.` }, 422);
// Für jedes Bild genau einmal rendern — auch bei 24 Exemplaren.
// Gedrehte Platzierungen brauchen eine eigene Fassung.
// Bild-Schlüssel bewusst NICHT aus der Zell-ID ableiten — eine Zelle namens
// „x::rot" würde sonst die gedrehte Fassung von „x" überschreiben.
const keyOf = (id: string, rot: boolean) => `${specs.findIndex((s) => s.id === id)}${rot ? 'r' : 'p'}`;
const images: Record<string, SheetCellImage> = {};
const needRotated = new Set<string>();
const needPlain = new Set<string>();
@@ -72,19 +94,19 @@ export const POST: APIRoute = async ({ request, locals }) => {
const buf = await loadSource(r.cell.src, locals.user as any);
if (needPlain.has(r.cell.id)) {
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: false });
images[r.cell.id] = { bytes: out.buffer, ext: out.ext };
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: false, fit: fitOf(r.cell), background: bgOf(r.cell) });
images[keyOf(r.cell.id, false)] = { bytes: out.buffer, ext: out.ext };
}
if (needRotated.has(r.cell.id)) {
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: true });
images[`${r.cell.id}::rot`] = { bytes: out.buffer, ext: out.ext };
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: true, fit: fitOf(r.cell), background: bgOf(r.cell) });
images[keyOf(r.cell.id, true)] = { bytes: out.buffer, ext: out.ext };
}
}
// Platzierungen auf die passende Bildfassung umbiegen.
const pages = plan.pages.map((pg) => ({
placements: pg.placements.map((p) => ({ ...p, specId: p.rotated ? `${p.specId}::rot` : p.specId })),
placements: pg.placements.map((p) => ({ ...p, specId: keyOf(p.specId, p.rotated) })),
}));
const marks = plan.pages.map((pg) =>
@@ -94,19 +116,82 @@ export const POST: APIRoute = async ({ request, locals }) => {
offsetMm: body?.marks?.offsetMm != null ? numOr(body.marks.offsetMm, 3, 0, 20) : undefined,
bleedMm: sheet.bleedMm,
}));
const extras: PageExtras[] = plan.pages.map(() => ({}));
const kinds = resolved.map((r) => `${labelMm(r.wMm, r.hMm)}${specs.find((s) => s.id === r.cell.id)!.count > 1 ? ` ×${specs.find((s) => s.id === r.cell.id)!.count}` : ''}`);
const footer = body?.footer === false ? null
: `Klarbild · ${labelMm(sheet.wMm, sheet.hMm)} · ${kinds.join(' · ')} · ${dpi} dpi · Druck bei 100 % (nicht „an Seite anpassen")`;
// --- Übergroße Bilder: überstehen lassen oder auf mehrere Blätter kacheln ---
const notes: string[] = [];
for (const [bi, b] of big.entries()) {
const buf = await loadSource(b.cell.src, locals.user as any);
const opts = { ext: wantExt, fit: fitOf(b.cell), background: bgOf(b.cell) };
if (b.mode === 'overflow') {
const info = overflowPlacement(b.cell.id, 1, b.wMm, b.hMm, sheet, b.cell.allowRotate !== false);
const key = `o${bi}`;
const out = await renderCell(buf, b.cell.crop ?? null, b.wMm, b.hMm, dpi, { ...opts, rotate: info.rotated });
images[key] = { bytes: out.buffer, ext: out.ext };
for (let c = 0; c < b.count; c++) {
pages.push({ placements: [{ ...info.placement, specId: key, copy: c + 1 }] });
marks.push([]); // Marken wären hier sinnlos, die Kanten liegen außerhalb
extras.push({});
}
if (info.lostMm > 0.05)
notes.push(`${labelMm(b.wMm, b.hMm)} ragt über den Bogen: es fehlen ${fmtMm(info.lostLeft + info.lostRight)} in der Breite und ${fmtMm(info.lostTop + info.lostBottom)} in der Höhe.`);
} else {
const noteMm = noteInset(sheet);
const tp = tilePlan(b.wMm, b.hMm, tileSheet(sheet), numOr(b.cell.overlapMm, 10, 0, 40));
if (tp.tiles.length * b.count > MAX_PAGES)
return json({ error: `Der Posterdruck bräuchte ${tp.tiles.length * b.count} Blätter — bitte kleineres Maß oder größeres Papier.` }, 422);
const inset = sheet.marginMm || 0;
for (const [ti, t] of tp.tiles.entries()) {
const key = `t${bi}_${ti}`;
const crop = tileCrop(b.cell.crop ?? { x: 0, y: 0, w: 1, h: 1 }, t, b.wMm, b.hMm);
const out = await renderCell(buf, crop, t.wMm, t.hMm, dpi, { ...opts, rotate: false });
images[key] = { bytes: out.buffer, ext: out.ext };
// Klebefalz sichtbar machen: gestrichelt dort, wo das Nachbarblatt anschließt.
const dashed: Line[] = [];
const top = inset + noteMm;
if (t.glueRight) { const x = inset + t.wMm - tp.overlapMm; dashed.push({ x1: x, y1: top, x2: x, y2: top + t.hMm }); }
if (t.glueBottom) { const y = top + t.hMm - tp.overlapMm; dashed.push({ x1: inset, y1: y, x2: inset + t.wMm, y2: y }); }
for (let c = 0; c < b.count; c++) {
pages.push({ placements: [{ specId: key, copy: c + 1, x: inset, y: inset + noteMm, w: t.wMm, h: t.hMm, rotated: false }] });
marks.push([]);
extras.push({
note: `Blatt ${ti + 1}/${tp.tiles.length} · Reihe ${t.row + 1}, Spalte ${t.col + 1}` +
(tp.overlapMm > 0 ? ` · ${fmtMm(tp.overlapMm)} Klebefalz (gestrichelt)` : ''),
dashed,
});
}
}
notes.push(`${labelMm(b.wMm, b.hMm)} auf ${tp.cols} × ${tp.rows} Blätter verteilt, ${fmtMm(tp.overlapMm)} Überlappung zum Kleben.`);
}
}
if (pages.length > MAX_PAGES) return json({ error: `Zu viele Bogen (${pages.length}). Bitte Anzahl reduzieren.` }, 422);
const kinds = [
...resolved.map((r) => `${labelMm(r.wMm, r.hMm)}${specs.find((s) => s.id === r.cell.id)!.count > 1 ? ` ×${specs.find((s) => s.id === r.cell.id)!.count}` : ''}`),
...big.map((b) => `${labelMm(b.wMm, b.hMm)}${b.count > 1 ? ` ×${b.count}` : ''}${b.mode === 'tile' ? ' (Poster)' : ' (überstehend)'}`),
];
const footer = readFooter(body, sheet, kinds, dpi);
const bytes = await buildSheetPdf({
sheet, pages, marksPerPage: marks, images,
sheet, pages, marksPerPage: marks, images, extrasPerPage: extras,
title: String(body?.title || 'Klarbild Druckbogen'),
footer,
markWidthPt: numOr(body?.marks?.widthPt, 0.25, 0.1, 2),
});
const filename = sheetFilename(body?.name || 'druckbogen', 'pdf');
// Die Kennung des Verlaufseintrags entsteht **vor** dem Dateinamen, damit
// beide dasselbe Kürzel tragen: Wer eine PDF-Datei in der Hand hat, findet
// den zugehörigen Eintrag unter „Zuletzt gedruckt" wieder.
const runId = randomUUID();
const filename = buildSheetFilename({
name: body?.name, paperId: typeof body?.paper === 'string' ? body.paper : body?.paper?.id,
wMm: sheet.wMm, hMm: sheet.hMm, pieces: totalPieces, pages: pages.length, runId,
});
// Optional: den fertigen Bogen zusätzlich ins gewohnte Ziel schieben
// (Standard-Picdrop, NAS oder ein Zusatzziel) — wie bei den Bildern.
@@ -114,10 +199,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
if (body?.deliver) {
const key = body.deliver.target === '' || body.deliver.target == null ? 'picdrop' : String(body.deliver.target);
try {
// Zuerst den Galerienamen prüfen: keine Schrägstriche, kein „..", sonst
// ließe sich per posixpath.join aus dem Basisordner des Ziels ausbrechen.
const wish = String(body.deliver.gallery || '').trim();
if (wish && (wish.includes('..') || !/^[\p{L}\p{N} _.\-]{1,64}$/u.test(wish)))
throw new Error('Ungültiger Galeriename.');
const cfg = await cfgForKey(key);
if (!cfg) throw new Error('Ziel nicht konfiguriert.');
const s = await one<{ picdrop_default_gallery: string | null }>('SELECT picdrop_default_gallery FROM settings WHERE id=1');
const gallery = String(body.deliver.gallery || '').trim() || s?.picdrop_default_gallery || 'POSTER LEA';
const gallery = wish || s?.picdrop_default_gallery || 'POSTER LEA';
await uploadBuffer(cfg, gallery, filename, Buffer.from(bytes));
delivered = '1';
deliveryMsg = `Bogen nach „${gallery}" ausgeliefert.`;
@@ -126,6 +216,27 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
}
// Den Druck festhalten — nur die Anleitung, nicht das PDF. Fehler dabei
// dürfen den Bogen nie kosten: Er ist fertig und geht gleich raus.
try {
await recordRun({
id: runId,
userId: locals.user.uid ?? null,
origin: String(body?.origin || 'web'),
summary: kurzfassung(kinds),
paper: `${labelMm(sheet.wMm, sheet.hMm)}`,
pages: pages.length,
pieces: totalPieces,
bytes: bytes.length,
config: body,
itemIds: [...cellsIn, ...big.map((b) => b.cell)]
.map((c: any) => (c?.src?.kind === 'item' ? String(c.src.id) : null))
.filter(Boolean) as string[],
});
} catch (e: any) {
console.error('[print] Historie nicht gespeichert:', e?.message || e);
}
return new Response(Buffer.from(bytes), {
headers: {
'Content-Type': 'application/pdf',
@@ -133,14 +244,24 @@ export const POST: APIRoute = async ({ request, locals }) => {
'X-Klarbild-Pages': String(pages.length),
'X-Klarbild-Per-Sheet': String(plan.perSheet),
'X-Klarbild-Delivered': delivered,
...(notes.length ? { 'X-Klarbild-Notes': encodeURIComponent(notes.join(' ')) } : {}),
...(deliveryMsg ? { 'X-Klarbild-Delivery-Msg': encodeURIComponent(deliveryMsg) } : {}),
},
});
} catch (e: any) {
return json({ error: e?.message || 'Druckbogen konnte nicht erzeugt werden.' }, 400);
// Interne Details (Pfade, Hostnamen der Auslieferungsziele) bleiben im Log.
console.error('[print/sheet]', e?.message || e);
return json({ error: userMessage(e) }, 400);
}
};
/** Nur selbst formulierte Meldungen nach außen geben, nichts aus der Tiefe. */
const SAFE = /^(Papierformat|Bild \d|Maß|Ungültige|Unbekannte|Bild nicht|Kein Zugriff|Quelle|Ausschnitt|Zielbild|Zu viele)/;
function userMessage(e: any): string {
const m = String(e?.message || '');
return SAFE.test(m) ? m : 'Druckbogen konnte nicht erzeugt werden.';
}
function readSheet(body: any): SheetSpec {
let w: number | null = null, h: number | null = null;
const p = body?.paper;
@@ -174,6 +295,26 @@ function cellSize(c: CellIn): { w: number; h: number } | null {
return c.landscape ? { w: s.h, h: s.w } : s;
}
/** Millimeter menschenlesbar: „3 mm", „1,5 mm", „0 mm". */
const fmtMm = (n: number) => `${String(Math.round(n * 10) / 10).replace('.', ',')} mm`;
const FOOTER_POS: FooterPos[] = ['unten-links', 'unten-mitte', 'unten-rechts', 'oben-links', 'oben-mitte', 'oben-rechts'];
/** Fußzeile: aus, automatisch oder eigener Text an frei wählbarer Stelle. */
function readFooter(body: any, sheet: SheetSpec, kinds: string[], dpi: number) {
const f = body?.footer;
if (f === false || f === null) return null;
const auto = `Klarbild · ${labelMm(sheet.wMm, sheet.hMm)} · ${kinds.join(' · ')} · ${dpi} dpi · Druck bei 100 % (nicht „an Seite anpassen")`;
if (f === true || f == null) return { text: auto, position: 'unten-links' as FooterPos, sizePt: 6 };
const text = typeof f.text === 'string' && f.text.trim() ? f.text.trim().slice(0, 200) : auto;
const position = FOOTER_POS.includes(f.position) ? f.position as FooterPos : 'unten-links';
return { text, position, sizePt: numOr(f.sizePt, 6, 4, 14) };
}
const fitOf = (c: CellIn): 'cover' | 'contain' => (c.fit === 'contain' ? 'contain' : 'cover');
/** Randfarbe nur als sicheres Hex zulassen. */
const bgOf = (c: CellIn): string => (/^#[0-9a-f]{6}$/i.test(String(c.bg || '')) ? String(c.bg) : '#ffffff');
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
const numOr = (v: any, def: number, lo: number, hi: number) => {
const n = Number(v);
+16 -5
View File
@@ -1,6 +1,7 @@
import type { APIRoute } from 'astro';
import { renderCell, type CropRel } from '../../../lib/printrender';
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
import { loadSourceNamed, type PrintSource } from '../../../lib/printsource';
import { buildSingleFilename } from '../../../lib/naming';
import { parseSizeMm, mmToPx, labelMm } from '../../../lib/paper';
import { dpiCheck } from '../../../lib/printlayout';
@@ -30,8 +31,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
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 out = await renderCell(buf, crop, size.w, size.h, dpi, { ext, bleedMm: Number(body?.bleedMm) || 0 });
const quelle = await loadSourceNamed(src, locals.user as any);
const buf = quelle.buffer;
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];
@@ -40,14 +45,20 @@ export const POST: APIRoute = async ({ request, locals }) => {
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)}"`,
'Content-Disposition': `attachment; filename="${buildSingleFilename({
name: body?.name, sourceName: quelle.filename,
wMm: size.w, hMm: size.h, ext: out.ext, id: quelle.itemId,
})}"`,
'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) {
return json({ error: e?.message || 'Bild konnte nicht erzeugt werden.' }, 400);
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);
}
};
+102
View File
@@ -0,0 +1,102 @@
import type { APIRoute } from 'astro';
import sharp from 'sharp';
import { one } from '../../../../lib/db';
import { getObject } from '../../../../lib/storage';
import { resolveShare, shareHasItem, trackShare, nachweisGilt, nachweisAusCookie } from '../../../../lib/shares';
export const prerender = false;
/**
* Bild hinter einem Teilen-Link ausliefern — **ohne Anmeldung**.
*
* Die Zugriffsprüfung hängt allein am Link, nicht am Bild: Nur was in
* `share_items` steht, kommt heraus. Damit kann niemand über einen gültigen Link
* fremde Bilder abrufen, indem er eine andere Bild-Kennung einsetzt.
*/
export const GET: APIRoute = async ({ params, url, request, clientAddress }) => {
const q = url.searchParams;
// Kein `?pw=` mehr: Bei geschützten Links zählt allein der Nachweis, den die
// Seite nach der einmaligen Prüfung als Cookie gesetzt hat. Sonst liefe für
// jedes einzelne Bild erneut argon2 (130 ms, 64 MiB) — bei einer Galerie mit
// 40 Bildern reicht das, um den Prozess in die Speichergrenze zu treiben.
let { status, share } = await resolveShare(params.slug!, null);
if (status === 'passwort' && share
&& nachweisGilt(share.id, nachweisAusCookie(request.headers.get('cookie'), params.slug!))) {
status = 'ok';
}
if (status !== 'ok' || !share) {
return new Response(
status === 'abgelaufen' ? 'Dieser Link ist abgelaufen.'
: status === 'widerrufen' ? 'Dieser Link wurde deaktiviert.'
: status === 'passwort' ? 'Zugangswort nötig.'
: 'Nicht gefunden.',
{ status: status === 'passwort' ? 401 : status === 'nicht-gefunden' ? 404 : 410 });
}
const itemId = q.get('item');
if (!itemId || !/^[0-9a-f-]{36}$/i.test(itemId)) return new Response('Nicht gefunden.', { status: 404 });
if (!(await shareHasItem(share.id, itemId))) return new Response('Nicht gefunden.', { status: 404 });
const item = await one<any>(
'SELECT result_path, thumb_path, filename, has_alpha FROM items WHERE id=$1', [itemId]);
if (!item?.result_path) return new Response('Nicht gefunden.', { status: 404 });
const download = q.get('download') === '1';
const wantThumb = q.get('thumb') === '1';
// Wer den Download abgeschaltet hat, gibt auch die Originaldatei nicht heraus —
// sonst wäre die Einstellung nur Zierde.
if (download && !share.allow_download) return new Response('Herunterladen ist für diesen Link aus.', { status: 403 });
const path = wantThumb ? (item.thumb_path || item.result_path) : item.result_path;
try {
let buf = await getObject(path);
const sniff = (b: Buffer): string =>
b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff ? 'image/jpeg'
: b.length > 12 && b.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp'
: 'image/png';
let contentType = path.endsWith('.webp') ? 'image/webp' : sniff(buf);
// Ansichtsfassung: kleiner und schneller, vor allem am Handy. Der Download
// liefert weiterhin die unveränderte Originaldatei in voller Auflösung.
//
// Wichtig: Das galt vorher **nicht** für freigestellte Motive — die liefen
// ungefiltert durch, in voller Auflösung, mit Rechtsklick speicherbar. Damit
// war „Herunterladen aus" genau bei Stickern und Freistellern wirkungslos.
// Alphabilder werden deshalb als PNG verkleinert statt übersprungen.
if (!download && !wantThumb) {
try {
const klein = sharp(buf, { failOn: 'none', limitInputPixels: 400_000_000 })
.resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true });
if (item.has_alpha) { buf = await klein.png({ compressionLevel: 8 }).toBuffer(); contentType = 'image/png'; }
else { buf = await klein.jpeg({ quality: 88, mozjpeg: true }).toBuffer(); contentType = 'image/jpeg'; }
} catch (e: any) {
// Nur bei gesperrtem Download ist das Original tabu — dann lieber nichts
// ausliefern als versehentlich die volle Auflösung.
console.error('[share] Ansichtsfassung fehlgeschlagen:', e?.message || e);
if (!share.allow_download) return new Response('Vorschau nicht verfügbar.', { status: 503 });
}
}
if (download) {
await trackShare(share.id, 'download', clientAddress, request.headers.get('user-agent'));
}
const headers: Record<string, string> = {
'Content-Type': contentType,
'Content-Length': String(buf.length),
// Nicht in Suchmaschinen und nicht in fremde Zwischenspeicher.
'X-Robots-Tag': 'noindex, nofollow, noarchive, noimageindex',
'Cache-Control': 'private, max-age=300',
};
if (download) {
const name = (item.filename || 'klarbild.jpg').replace(/[^\w.\-]/g, '_');
headers['Content-Disposition'] = `attachment; filename="${name}"`;
}
return new Response(new Uint8Array(buf), { headers });
} catch (e: any) {
// Ursache ins Log — sonst sucht der Betreiber bei einem Speicherausfall
// vergeblich, während alle Empfänger „Bild nicht mehr vorhanden" sehen.
console.error('[share] Auslieferung fehlgeschlagen', path, e?.message || e);
return new Response('Bild nicht mehr vorhanden.', { status: 404 });
}
};
+115
View File
@@ -0,0 +1,115 @@
import type { APIRoute } from 'astro';
import archiver from 'archiver';
import { Readable } from 'node:stream';
import { once } from 'node:events';
import { query } from '../../../../lib/db';
import { getObject } from '../../../../lib/storage';
import { resolveShare, shareItems, trackShare, nachweisGilt, nachweisAusCookie,
archivName, paketName } from '../../../../lib/shares';
import { zuOft, echteIp } from '../../../../lib/limit';
export const prerender = false;
/** Obergrenzen. Ein ZIP ist der teuerste Weg, Daten aus diesem Server zu ziehen. */
const MAX_DATEIEN = 200;
const MAX_BYTES = 500 * 1024 * 1024; // 500 MB entpackt
/**
* Alle Bilder eines Teilen-Links als ZIP.
*
* Bewusst **GET**: Damit ist es ein schlichter Link auf der Seite, der auch ohne
* JavaScript funktioniert — und der Empfänger ist oft am Handy.
*
* Anders als `/api/items/zip` (für angemeldete Nutzer) wird hier **eine Datei
* nach der anderen** gelesen und weitergereicht: Nach jedem `append` wird auf
* das `entry`-Ereignis gewartet. Sonst lägen bei zwanzig Postern schnell ein
* paar Gigabyte gleichzeitig im Speicher — und `mem_limit` ist 3 GB.
*/
export const GET: APIRoute = async ({ params, request, clientAddress }) => {
const slug = params.slug!;
let { status, share } = await resolveShare(slug, null);
if (status === 'passwort' && share
&& nachweisGilt(share.id, nachweisAusCookie(request.headers.get('cookie'), slug))) {
status = 'ok';
}
if (status !== 'ok' || !share) {
return new Response(
status === 'abgelaufen' ? 'Dieser Link ist abgelaufen.'
: status === 'widerrufen' ? 'Dieser Link wurde deaktiviert.'
: status === 'passwort' ? 'Zugangswort nötig.'
: 'Nicht gefunden.',
{ status: status === 'passwort' ? 401 : status === 'nicht-gefunden' ? 404 : 410 });
}
// Wer den Download gesperrt hat, meint auch das Sammelpaket.
if (!share.allow_download) {
return new Response('Herunterladen ist für diesen Link ausgeschaltet.', { status: 403 });
}
// Ein ZIP kostet Rechenzeit und Bandbreite — nicht im Sekundentakt.
const ip = echteIp(request, clientAddress);
if (zuOft(`zip:${share.id}:${ip}`, 5, 10 * 60_000)) {
return new Response('Gerade eben schon heruntergeladen. Bitte kurz warten.', { status: 429 });
}
const bilder = await shareItems(share.id);
if (!bilder.length) return new Response('Zu diesem Link gibt es keine Bilder.', { status: 404 });
if (bilder.length > MAX_DATEIEN) {
return new Response(`Dieses Paket wäre zu groß (${bilder.length} Bilder). Bitte einzeln herunterladen.`, { status: 413 });
}
const pfade = await query<{ id: string; result_path: string; filename: string | null }>(
`SELECT id, result_path, filename FROM items
WHERE id = ANY($1::uuid[]) AND result_path IS NOT NULL`,
[bilder.map((b) => b.id)]);
const nachId = new Map(pfade.map((p) => [p.id, p]));
const archive = archiver('zip', { zlib: { level: 6 } });
// Fehler dürfen den Prozess nicht mitnehmen; der Strom bricht dann ab.
archive.on('error', (e) => console.error('[share/zip]', e?.message || e));
archive.on('warning', (e) => console.error('[share/zip] Warnung:', e?.message || e));
// Handler stehen, **bevor** etwas hineingeschrieben wird.
const web = Readable.toWeb(archive) as ReadableStream<Uint8Array>;
(async () => {
const vergeben = new Map<string, number>();
let bytes = 0;
try {
for (const b of bilder) {
const p = nachId.get(b.id);
if (!p) continue;
let buf: Buffer;
try { buf = await getObject(p.result_path); }
catch (e: any) { console.error('[share/zip] Datei fehlt', p.result_path, e?.message || e); continue; }
bytes += buf.length;
if (bytes > MAX_BYTES) {
archive.append(
Buffer.from(
'Das Paket wurde bei 500 MB abgeschnitten.\r\n'
+ 'Die restlichen Bilder lassen sich auf der Seite einzeln herunterladen.\r\n', 'utf8'),
{ name: 'HINWEIS.txt' });
break;
}
archive.append(buf, { name: archivName(p.filename, vergeben) });
// Eine Datei nach der anderen: Erst weiterlesen, wenn diese im Strom ist.
await once(archive, 'entry');
}
} finally {
archive.finalize().catch(() => {});
}
})();
await trackShare(share.id, 'download', clientAddress, request.headers.get('user-agent'));
return new Response(web, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${paketName(share.title)}"`,
'X-Robots-Tag': 'noindex, nofollow',
'Cache-Control': 'no-store',
},
});
};
+49
View File
@@ -0,0 +1,49 @@
import type { APIRoute } from 'astro';
import { query } from '../../../lib/db';
import { updateShare, revokeShare, unrevokeShare, deleteShare } from '../../../lib/shares';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Die letzten Zugriffe auf einen Link — für die Auswertung im Frontend. */
export const GET: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
if (!UUID.test(params.id || '')) return json({ error: 'Unbekannter Link.' }, 404);
const istAdmin = locals.user.role === 'admin';
const rows = await query<any>(
`SELECT v.kind, v.at, v.user_agent
FROM share_views v JOIN shares s ON s.id = v.share_id
WHERE v.share_id = $1 AND ($3::boolean OR s.created_by = $2)
ORDER BY v.at DESC LIMIT 100`, [params.id, locals.user.uid, istAdmin]);
return json({ zugriffe: rows });
};
/** Titel, Ablauf, Zugangswort oder Download-Erlaubnis ändern; widerrufen/freigeben. */
export const PATCH: APIRoute = async ({ params, request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
if (!UUID.test(params.id || '')) return json({ error: 'Unbekannter Link.' }, 404);
const b = await request.json().catch(() => ({}));
const istAdmin = locals.user.role === 'admin';
if (b.action === 'revoke') {
return json({ ok: await revokeShare(params.id!, locals.user.uid, istAdmin) });
}
if (b.action === 'unrevoke') {
return json({ ok: await unrevokeShare(params.id!, locals.user.uid, istAdmin) });
}
const ok = await updateShare(params.id!, locals.user.uid, istAdmin, {
title: b.title, note: b.note, days: b.days,
allowDownload: b.allowDownload, password: b.password,
});
return ok ? json({ ok: true }) : json({ error: 'Unbekannter Link.' }, 404);
};
export const DELETE: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
if (!UUID.test(params.id || '')) return json({ error: 'Unbekannter Link.' }, 404);
const ok = await deleteShare(params.id!, locals.user.uid, locals.user.role === 'admin');
return ok ? json({ ok: true }) : json({ error: 'Unbekannter Link.' }, 404);
};
+70
View File
@@ -0,0 +1,70 @@
import type { APIRoute } from 'astro';
import { query } from '../../../lib/db';
import { createShare, listShares, MAX_ITEMS } from '../../../lib/shares';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** Eigene Teilen-Links auflisten (Admins sehen alle). */
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const shares = await listShares(locals.user.uid, locals.user.role === 'admin');
return json({ shares });
};
/**
* Neuen Link anlegen.
* Es dürfen nur Bilder geteilt werden, die man auch sehen darf — sonst wäre der
* Teilen-Knopf ein bequemer Weg an der Sichtbarkeitsregel vorbei.
*/
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json().catch(() => ({}));
const ids: string[] = Array.isArray(b.itemIds) ? b.itemIds.filter((x: any) => typeof x === 'string') : [];
if (!ids.length) return json({ error: 'Keine Bilder ausgewählt.' }, 400);
if (ids.length > MAX_ITEMS) return json({ error: `Höchstens ${MAX_ITEMS} Bilder je Link.` }, 422);
// Kennungen zuerst prüfen — eine ungültige würde Postgres sonst mit
// „invalid input syntax for type uuid" abbrechen lassen und der Aufrufer
// bekäme einen 500 statt einer klaren Auskunft.
if (ids.some((id) => !UUID.test(id))) return json({ error: 'Ungültige Bild-Kennung.' }, 400);
const istAdmin = locals.user.role === 'admin';
// Eine Abfrage statt einer je Bild: bei 500 Bildern waren das 500 Rundreisen.
const gefunden = await query<{ id: string; status: string; result_path: string | null; created_by: string | null; private: boolean; library_visibility: string }>(
`SELECT i.id, i.status, i.result_path, j.created_by, j.private,
(SELECT library_visibility FROM settings WHERE id=1) AS library_visibility
FROM items i JOIN jobs j ON j.id = i.job_id
WHERE i.id = ANY($1::uuid[])`, [ids]);
if (gefunden.length !== ids.length) return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404);
for (const it of gefunden) {
if (!it.result_path || it.status !== 'done') return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404);
// Private Aufträge werden bewusst nie geteilt — sie sollen ja gerade
// keine Spur hinterlassen.
if (it.private) return json({ error: 'Bilder aus privaten Sitzungen lassen sich nicht teilen.' }, 403);
const eigen = it.created_by === locals.user.uid;
if (!istAdmin && !eigen && it.library_visibility !== 'shared')
return json({ error: 'Kein Zugriff auf eines der Bilder.' }, 403);
}
try {
const share = await createShare({
itemIds: ids,
kind: b.kind === 'collection' ? 'collection' : undefined,
title: b.title, note: b.note,
days: b.days, allowDownload: b.allowDownload, password: b.password,
userId: locals.user.uid,
});
return json({ share });
} catch (e: any) {
// Eigene Meldungen (Gültigkeit, leere Auswahl) durchreichen, Datenbank-
// Interna nicht — die verraten Tabellen- und Spaltennamen.
const eigen = /Gültigkeit|ausgewählt|Bilder je Link/.test(String(e?.message || ''));
if (!eigen) console.error('[shares] Anlegen fehlgeschlagen:', e?.message || e);
return json({ error: eigen ? e.message : 'Link konnte nicht angelegt werden.' }, 400);
}
};
+96
View File
@@ -0,0 +1,96 @@
import type { APIRoute } from 'astro';
import { one } from '../../../lib/db';
import { sendMail, mailLayout, mailReady, istMail, esc } from '../../../lib/mail';
import { zuOft } from '../../../lib/limit';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
const basis = () => (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
const MAX_EMPFAENGER = 10;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Einen Teilen-Link per E-Mail verschicken.
*
* Bewusst **ohne Bildanhang**: Ein Poster in voller Auflösung sprengt jedes
* Postfach, und ein Anhang lässt sich später nicht mehr zurückziehen. Der Link
* dagegen schon — er hat ein Ablaufdatum und kann jederzeit deaktiviert werden.
*/
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json().catch(() => ({}));
const empfaenger: string[] = (Array.isArray(b.to) ? b.to : String(b.to || '').split(/[,;\s]+/))
.map((x: string) => String(x).trim().toLowerCase()).filter(Boolean);
if (!empfaenger.length) return json({ error: 'Keine Empfängeradresse angegeben.' }, 400);
if (empfaenger.length > MAX_EMPFAENGER) return json({ error: `Höchstens ${MAX_EMPFAENGER} Empfänger auf einmal.` }, 422);
const schlecht = empfaenger.filter((e) => !istMail(e));
if (schlecht.length) return json({ error: `Keine gültige Adresse: ${schlecht[0]}` }, 400);
if (!(await mailReady())) return json({ error: 'Es ist kein E-Mail-Versand eingerichtet (Admin → E-Mail).' }, 503);
// Ohne absolute Basisadresse stünde in der Mail ein relativer Pfad, den kein
// Postfach anklickbar macht — dann lieber gar nicht verschicken.
if (!/^https?:\/\//.test(basis()))
return json({ error: 'Die Basisadresse des Servers fehlt (PUBLIC_BASE_URL) — der Link in der Mail wäre unbrauchbar.' }, 503);
// Je Absender begrenzen, nicht je IP: Wer angemeldet ist, ist identifiziert.
if (zuOft('mailversand:' + locals.user.uid, 50, 60 * 60_000))
return json({ error: 'Für diese Stunde ist genug verschickt. Bitte später weitermachen.' }, 429);
if (!UUID.test(String(b.shareId || ''))) return json({ error: 'Unbekannter Link.' }, 404);
const istAdmin = locals.user.role === 'admin';
const share = await one<any>(
`SELECT s.*, (SELECT count(*)::int FROM share_items si WHERE si.share_id = s.id) AS anzahl
FROM shares s WHERE s.id = $1 AND ($3::boolean OR s.created_by = $2)`,
[b.shareId, locals.user.uid, istAdmin]);
if (!share) return json({ error: 'Unbekannter Link.' }, 404);
if (share.revoked_at) return json({ error: 'Dieser Link ist deaktiviert — erst wieder freigeben.' }, 409);
// Auch der Ablauf zählt: Sonst bekommt die Oma eine Mail auf eine Seite, die
// ihr „Dieser Link ist abgelaufen" zeigt.
if (share.expires_at && new Date(share.expires_at).getTime() < Date.now())
return json({ error: 'Dieser Link ist abgelaufen — erst die Gültigkeit verlängern.' }, 409);
const url = `${basis()}/s/${share.slug}`;
const absender = locals.user.name || locals.user.username || 'Jemand';
const titel = share.title || (share.anzahl > 1 ? `${share.anzahl} Bilder` : 'Ein Bild');
const nachricht = String(b.message || '').trim().slice(0, 1000);
const gueltig = share.expires_at
? `Der Link gilt bis zum ${new Date(share.expires_at).toLocaleDateString('de-DE')}.`
: 'Der Link gilt unbegrenzt.';
const wort = share.password_hash
? 'Zum Öffnen brauchst du außerdem das Zugangswort — das bekommst du getrennt.'
: '';
// `mailLayout` setzt Absätze bewusst roh ins HTML, damit <b> funktioniert —
// alles, was aus Nutzereingaben stammt, muss deshalb **hier** maskiert werden.
// Vorher war nur die Nachricht maskiert, der Titel nicht: Damit ließ sich über
// den eigenen, per SPF und DKIM signierten Mailserver ein fremder Link in
// vertrautem Gewand verschicken.
const absaetze = [
`${esc(absender)} hat dir etwas über Klarbild geschickt: <b>${esc(titel)}</b>.`,
...(nachricht ? [esc(nachricht).replace(/\n/g, '<br>')] : []),
[gueltig, wort].filter(Boolean).join(' '),
];
let ok = 0; const fehler: string[] = [];
for (const to of empfaenger) {
try {
await sendMail({
to,
subject: `${absender} teilt: ${titel}`,
text: `${absender} hat dir etwas über Klarbild geschickt: ${titel}.\n\n`
+ (nachricht ? `${nachricht}\n\n` : '')
+ `${url}\n\n${gueltig}${wort ? ' ' + wort : ''}`,
html: mailLayout(titel, absaetze, { text: 'Bilder ansehen', url }),
});
ok++;
} catch (e: any) {
console.error('[share/send] an', to, 'fehlgeschlagen:', e?.message || e);
fehler.push(to);
}
}
return json({ ok, fehler, url });
};
+5 -1
View File
@@ -31,11 +31,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
ext = meta.format === 'jpeg' ? 'jpg' : (meta.format || 'png');
}
const meta = await sharp(buf, { failOn: 'none' }).metadata();
// EXIF-Drehung einrechnen: der Browser zeigt das Bild orientiert an, also
// müssen auch die gemeldeten Maße orientiert sein (Zuschnitt im Druckmodul).
const turned = (meta.orientation ?? 1) >= 5;
const key = sourceKey(randomUUID(), ext);
await putObject(key, buf, `image/${ext === 'jpg' ? 'jpeg' : ext}`);
out.push({
source_path: key, filename: file.name,
width: meta.width, height: meta.height,
width: turned ? meta.height : meta.width,
height: turned ? meta.width : meta.height,
source_quality: 'original',
});
} catch (e: any) {
+123
View File
@@ -9,6 +9,129 @@ import Base from '../layouts/Base.astro';
</div>
<p class="lead">Was in Klarbild neu ist. Neueste Änderungen oben.</p>
<section>
<div class="ver"><span class="tag">01.09.2026</span><h2>Drucken: Bilder aus der Bibliothek, und was du zuletzt gedruckt hast</h2></div>
<ul>
<li><b>Bilder aus der Bibliothek in den Druckbereich.</b> Bisher musstest du eine Datei hochladen,
auch wenn Klarbild sie längst hatte. Jetzt steht im Druckbereich <b>„Aus der Bibliothek wählen"</b> —
mit Suche, nach Ordner, und mehrere auf einmal.</li>
<li><b>Zuletzt gedruckt.</b> Ganz unten im Druckbereich steht, was du zuvor gedruckt hast: Maße,
Stückzahl, die Bilder. <b>Nochmal drucken</b> erzeugt genau denselben Bogen ein zweites Mal, ohne
dass du irgendetwas neu einstellst. <b>Einstellung übernehmen</b> holt nur Papier, Marken und
Ränder zurück — für „wie damals, aber mit anderen Fotos".</li>
<li><b>Ohne die Platte vollzumachen:</b> Aufgehoben werden nicht die PDFs — ein A3-Bogen wären
schnell 20 bis 80 MB — sondern nur die Einstellung, ein paar Kilobyte. Weil der Druckbereich
ohne KI rechnet, ergibt derselbe Auftrag wieder exakt dasselbe Blatt. „Nochmal drucken" gibt es
deshalb nur, wenn alle Bilder aus der Bibliothek kamen und noch da sind; sonst steht am Eintrag,
woran es liegt. Aufgehoben werden die letzten 100 Drucke.</li>
<li><b>Am Handy</b> stehen die vier Karten jetzt in ihrer eigenen Reihenfolge — vorher rutschte die
Vorschau nach oben, und man scrollte an allem vorbei, ehe man ein Bild hinzufügen konnte.</li>
<li><b>E-Mail läuft von selbst.</b> Der Zugang zum Absender-Postfach ist fest hinterlegt; unter
Admin → E-Mail muss dafür nichts mehr eingetragen werden. Dort steht jetzt auch, welcher Zugang
gerade wirklich benutzt wird.</li>
<li><b>Behoben: Druckdateien hießen alle gleich.</b> Zwei Bogen am selben Tag waren beide
<code>2026-09-01_druckbogen.pdf</code> — im selben Ordner überschrieb der zweite den ersten.
Jetzt <code>2026-09-01_143207_druck-9-teile_a4_4eca.pdf</code>: Datum, Uhrzeit auf die Sekunde,
was auf dem Bogen liegt, das Papier und ein Kürzel. Das Kürzel gehört zum Verlaufseintrag —
du siehst also, welcher Druck welche Datei war. Einzelbilder heißen jetzt nach ihrem Motiv.</li>
<li><b>Behoben:</b> Dasselbe galt für ZIPs aus der Bibliothek — jedes hieß
<code>klarbild_2026-09-01.zip</code>.</li>
</ul>
</section>
<section>
<div class="ver"><span class="tag">01.09.2026</span><h2>Bilder teilen, E-Mail, Passwort vergessen</h2></div>
<ul>
<li><b>Teilen-Links.</b> In der Bibliothek auf <b>Teilen</b> — einzeln oder für mehrere markierte
Bilder auf einmal. Es entsteht ein Link, den auch jemand <b>ohne Klarbild-Konto</b> öffnen kann;
von dort lässt sich das Bild in voller Auflösung herunterladen.</li>
<li><b>Weitergeben per WhatsApp</b> (öffnet WhatsApp mit fertigem Text), über dein eigenes
Mailprogramm, oder direkt aus Klarbild heraus per E-Mail an mehrere Empfänger auf einmal.</li>
<li><b>Sicher voreingestellt:</b> Jeder Link <b>läuft von selbst ab</b> (Standard 30 Tage, im Admin
änderbar), lässt sich jederzeit <b>abschalten</b> und wieder freigeben, auf Wunsch mit einem
<b>Zugangswort</b> schützen, und Herunterladen kann man ganz sperren. Suchmaschinen bekommen die
Seiten nie zu sehen. Bilder aus privaten Sitzungen lassen sich gar nicht teilen.</li>
<li><b>Alle Bilder auf einmal:</b> Bei mehreren Bildern steht über der Seite ein Knopf
<b>„Alle N Bilder herunterladen"</b> — ein ZIP in voller Auflösung. Bei zwanzig Bildern
musste der Empfänger vorher zwanzigmal tippen.</li>
<li><b>Übersicht unter <a href="/teilen">Geteilte Links</a>:</b> Aufrufe, Downloads und die Zahl der
Besuchenden je Link. Festgehalten wird <b>wann</b> und mit welcher Art Gerät geöffnet wurde —
nicht <b>wer</b>. Nach 90 Tagen wird das gelöscht.</li>
<li><b>E-Mail-Versand</b> über dein eigenes Postfach (Admin → E-Mail). Damit funktionieren:
<b>Passwort vergessen</b>, ein <b>Hinweis wenn ein Auftrag fertig ist</b> (unter
<a href="/konto">Mein Konto</a> einschaltbar) und der Versand von Teilen-Links.</li>
<li><b>Passwort vergessen?</b> Auf der Anmeldeseite unten. Der Link aus der Mail gilt eine Stunde
und lässt sich nur einmal verwenden. Dafür muss unter <b>Mein Konto</b> eine E-Mail-Adresse
hinterlegt sein.</li>
<li><b>Behoben:</b> Ein neues Passwort beendet jetzt auch alle noch offenen Anmeldungen. Vorher
blieb eine bestehende Sitzung bis zu 30 Tage gültig — wer sein Passwort zurücksetzte, weil
jemand anderes hineingekommen war, hatte davon also nichts.</li>
<li><b>Behoben:</b> Bei freigestellten Motiven wirkte „Herunterladen sperren" nicht.</li>
<li><b>KI-Bilder tragen jetzt eine maschinenlesbare Kennzeichnung</b> (IPTC/XMP) samt verwendetem
Modell — direkt in der Bilddatei.</li>
<li>Kleinigkeiten: farbige Markierung liegt nicht mehr auf dem Auswahlkreis; die Kopfleiste passt
wieder aufs Tablet; der Admin-Bereich ist am Handy bedienbar.</li>
</ul>
</section>
<section>
<div class="ver"><span class="tag">21.08.2026</span><h2>Eindeutige Dateinamen — Bilder überschreiben sich nicht mehr</h2></div>
<ul>
<li><b>Behoben: Bilder haben sich bei Picdrop gegenseitig überschrieben.</b> Wer am selben Tag zweimal
dasselbe Motiv im selben Format erzeugt hat — etwa zwei Badges —, bekam zweimal denselben Dateinamen
(<code>2026-07-23_lea_the-frame.png</code>). Beim Ausliefern ersetzte das zweite Bild dann stillschweigend
das erste. Das kann jetzt nicht mehr passieren.</li>
<li><b>Der neue Name:</b> <code>2026-08-20_143207_lea-badge_the-frame_a3f9.jpg</code> — also Datum, Uhrzeit
auf die Sekunde, Motiv, Format und ein kurzes Kürzel. Weil der Zeitstempel vorn steht, sortiert
<b>jede Dateiliste von allein chronologisch</b> — bei Picdrop, im Finder, auf dem NAS. Das Kürzel stammt
aus der Bild-Kennung und ist damit garantiert eindeutig.</li>
<li><b>Uhrzeit in deutscher Zeit</b>, nicht in UTC. Ein Bild von 0:30 Uhr trägt jetzt das richtige Datum
und nicht mehr das des Vortags.</li>
<li><b>Rückwirkend für die ganze Bibliothek:</b> Unter <b>Admin → Dateinamen</b> zeigt „Bibliothek prüfen"
erst an, was sich ändern würde (samt der Zahl der aktuell doppelt vergebenen Namen); „Jetzt umbenennen"
setzt es um. Die Bilddateien selbst bleiben dabei unangetastet. Ein zweiter Lauf ändert nichts mehr.</li>
<li><b>„Erneut ausliefern"</b> lädt alle schon ausgelieferten Bilder unter dem neuen Namen erneut hoch.
Die alten Dateien bleiben am Ziel liegen — die räumst du bei Picdrop selbst weg, damit nichts
versehentlich beim Kunden verschwindet.</li>
</ul>
</section>
<section>
<div class="ver"><span class="tag">20.08.2026</span><h2>Übergroße Bilder, Bild duplizieren, freie Beschriftung &amp; neuer Aufbau</h2></div>
<ul>
<li><b>20 × 30 auf DIN-A4-Papier — jetzt geht das.</b> Wenn das Wunschmaß größer ist als der Bogen, sagt das Werkzeug nicht mehr nur „passt nicht", sondern bietet zwei Wege an: <b>Überstehen</b> legt das Bild mittig aufs ganze Blatt — was hinausragt, fehlt hinterher, und wie viel das ist, steht vorher dabei (bei 20 × 30 cm auf A4 sind es genau 3 mm in der Höhe). <b>Posterdruck</b> verteilt das Bild auf mehrere Blätter; die Kacheln überlappen sich um einen Klebefalz, der gestrichelt markiert wird, und jedes Blatt trägt seine Nummer mit Reihe und Spalte. 30 × 40 cm auf A4 ergibt so 2 × 2 Blätter.</li>
<li><b>Bild duplizieren.</b> Dasselbe Bild lässt sich mit einem Knopf kopieren und dann in einem zweiten Maß auf denselben Bogen setzen — etwa ein Porträt einmal in 10 × 15 und einmal als Passbild auf ein Blatt Fotopapier. Kein Umbenennen und erneutes Hochladen mehr.</li>
<li><b>Beschriftung selbst bestimmen.</b> Die kleine Zeile unter dem Bogen lässt sich ausschalten, mit eigenem Text versehen und an eine von sechs Stellen legen (oben oder unten, links, mittig oder rechts). Ist an der gewählten Kante kein Platz frei, entfällt sie automatisch — über ein Bild wird nie gedruckt.</li>
<li><b>Neuer Aufbau am Rechner:</b> links arbeiten, rechts die Vorschau, die beim Scrollen mitläuft. Die Einstellungsgruppen liegen zugeklappt nebeneinander und klappen über die volle Breite auf. Keine endlose Spalte neben einer leeren mehr.</li>
<li><b>Am Handy wie eine App:</b> Die Vorschau steht oben, „Druck-PDF erzeugen" liegt als fester Balken über der Navigation, jede Bildzeile zeigt Vorschau und Regler nebeneinander, und alle Knöpfe sind daumengroß. Tablet-Breiten sind eigens geprüft.</li>
<li><b>Öffentliche Vorstellungsseite</b> unter <code>/vorstellung</code> — beschreibt das Druckmodul ohne Login und ohne Bedienung, gedacht zum Herzeigen und für eine mögliche Veröffentlichung als Open Source.</li>
</ul>
</section>
<section>
<div class="ver"><span class="tag">19.08.2026</span><h2>Aus „Passbilder" wird „Drucken" — 86 Bildmaße, Zuschnitt repariert</h2></div>
<ul>
<li><b>Der Bereich heißt jetzt „Drucken"</b> (vorher „Passbilder") — er kann ja weit mehr als Passbilder. Alte Verweise leiten automatisch weiter.</li>
<li><b>86 Bildmaße in 10 Gruppen:</b> Passbild, Klein, Foto, Zoll (US), Sofortbild, Poster &amp; Rahmen, Quadrat, DIN, Karten und reine Seitenverhältnisse — dazu 19 Papierformate und jederzeit ein freies Maß.</li>
<li><b>Behoben: Der Zuschnitt zeigte das Bild verzerrt.</b> Der Rahmen im Zuschnitt-Fenster hatte nicht das Ziel-Seitenverhältnis, dadurch wurde das Bild hineingequetscht (gemessen: 8,3 % zu breit). Rahmen und Bild rechnen jetzt beide mit demselben Verhältnis.</li>
<li><b>Behoben: „Ich kann das Bild nicht verschieben."</b> Technisch ging es, aber bei voll ausgefülltem Rahmen sitzt der Ausschnitt in einer Richtung am Anschlag — Ziehen bewirkte dort nichts. Jetzt steht dabei, wohin sich das Bild noch bewegen lässt; Doppeltipp zoomt, Pfeiltasten schieben, und der Zoomregler zeigt seinen Faktor.</li>
</ul>
</section>
<section>
<div class="ver"><span class="tag">18.08.2026</span><h2>Neu: Drucken — Bilder auf exakte Maße bringen, ganz ohne KI</h2></div>
<ul>
<li><b>Exakte Maße statt „ungefähr".</b> Ein Passbild ist 35 × 45 mm, ein Rahmen 13 × 18 cm. Das Werkzeug rechnet in Millimetern und erzeugt ein PDF, dessen Seite exakt das Bogenmaß hat und in dem jedes Bild exakt sein Zielmaß hat. Beim Drucken „Tatsächliche Größe / 100 %" wählen — nicht „an Seitengröße anpassen".</li>
<li><b>Mehrere Bilder auf einen Bogen.</b> Gleich große Bilder kommen in ein exaktes Raster, gemischte Formate ordnet ein Packer so an, dass möglichst wenige Bogen nötig sind. Ein 13 × 18, ein 9 × 13 und sechs Passbilder passen zusammen auf ein DIN-A4-Blatt.</li>
<li><b>Schnittmarken wie in der Druckvorstufe:</b> feine Eckmarken außerhalb jedes Bildes oder durchgehende Linien über den ganzen Bogen — unterbrochen, wo ein anderes Motiv liegt. Länge, Abstand und Beschnittzugabe sind einstellbar.</li>
<li><b>Zuschneiden oder Rand lassen.</b> Passt das Seitenverhältnis nicht, entscheidest du: zuschneiden bis alles ausgefüllt ist, oder das ganze Bild einpassen und außen eine Randfarbe stehen lassen. Verzerrt wird nie. Der Ausschnitt lässt sich im Zuschnitt-Fenster verschieben und zoomen; die Drehung aus Handyfotos (EXIF) wird vorab eingerechnet.</li>
<li><b>Papier frei wählbar:</b> DIN A7 bis A0, Fotopapier 9 × 13 bis 20 × 30, US-Maße — oder ein eigenes Papiermaß. Randlos, 5 mm oder 10 mm Rand.</li>
<li><b>Vorlagen</b> für wiederkehrende Bogen (Kita-Satz, Schulsatz, Passbildbogen, 2 × 10 × 7,5 auf Fotopapier) — eigene lassen sich sichern.</li>
<li><b>Auch über Telegram und den MCP-Server</b> erreichbar, und der fertige Bogen kann direkt an Picdrop oder ein Sicherungsziel ausgeliefert werden.</li>
<li><b>Ohne KI, ohne Kosten:</b> reine Geometrie im Haus. Kein Modell, kein Schlüssel, keine Bilder, die den Server verlassen.</li>
</ul>
</section>
<section>
<div class="ver"><span class="tag">24.07.2026</span><h2>JPG-Auslieferung, eigene Formate, fehlgeschlagene Aufträge & KI-Doku</h2></div>
<ul>
+2 -14
View File
@@ -1,16 +1,4 @@
---
import Base from '../layouts/Base.astro';
import PrintApp from '../components/PrintApp.tsx';
// Alte Adresse — die Funktion heißt jetzt „Drucken".
return Astro.redirect('/drucken', 301);
---
<Base title="Druck · Klarbild">
<h1 class="seiten-titel">Druck</h1>
<p class="seiten-lead">
Bilder ohne KI auf exakte Maße bringen und mehrere davon in 100 %-Größe mit
Schnittmarken auf einen Bogen setzen — Passbildsatz, Kita-Bilder, Sticker.
</p>
<PrintApp client:load />
<style>
.seiten-titel{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
.seiten-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}
</style>
</Base>
+18
View File
@@ -0,0 +1,18 @@
---
import Base from '../layouts/Base.astro';
import PrintApp from '../components/PrintApp.tsx';
---
<Base title="Drucken · Klarbild">
<h1 class="seiten-titel">Drucken</h1>
<p class="seiten-lead">
Ohne KI: Bilder auf exakte Maße bringen und mehrere davon in 100-%-Größe mit
Schnittmarken auf einen Bogen setzen — vom biometrischen Passbild über den
Kita-Satz bis zum 30 × 40-Poster.
</p>
<PrintApp client:load />
<style>
.seiten-titel{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
.seiten-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}
@media(max-width:560px){.seiten-titel{font-size:1.35rem;}.seiten-lead{font-size:12.5px;}}
</style>
</Base>
+20
View File
@@ -0,0 +1,20 @@
---
import Base from '../layouts/Base.astro';
import KontoApp from '../components/KontoApp';
---
<Base title="Mein Konto · Klarbild">
<section class="seite">
<header class="seitenkopf">
<h1>Mein Konto</h1>
<p class="lead">E-Mail-Adresse und Benachrichtigungen.</p>
</header>
<KontoApp client:load />
</section>
<style>
.seite { max-width: var(--maxw); margin: 0 auto; padding: 26px 20px 80px; }
.seitenkopf { margin-bottom: 18px; }
.seitenkopf h1 { font-size: clamp(1.4rem, 4vw, 1.9rem); margin: 0 0 6px; letter-spacing: -0.02em; }
.lead { margin: 0; color: var(--ink-soft); line-height: 1.6; font-size: 0.95rem; }
@media (max-width: 640px) { .seite { padding: 18px 14px 90px; } }
</style>
</Base>
+88 -6
View File
@@ -13,11 +13,13 @@ const BODY = `# Klarbild
> saubere, druckfertige Bilder (bereinigen, freistellen, exakte Fotoformate,
> The-Frame-Querformat, Sticker mit Kontur), liefert an Picdrop (SFTP/FTPS)
> aus und sichert auf beliebige Backup-Ziele (z. B. NAS). Zusätzlich ein
> KI-freies Druckmodul: Bilder exakt auf physische Maße bringen und mehrere
> KI-freies Druckmodul (/drucken): Bilder exakt auf physische Maße bringen und mehrere
> davon in 100-%-Größe mit Schnittmarken auf einen Druckbogen setzen (PDF).
> Bedienung über Web, Telegram-Bot und HTTP-API/MCP.
Basis-URL: https://klarbild.heidrich-digital.de
Öffentliche Vorstellung des Druckmoduls (ohne Login, reine Beschreibung): /vorstellung
Öffentliche Teilen-Links: /s/<slug> (ohne Login, nur was der Link freigibt)
Sprache der Oberfläche: Deutsch. Stack: Astro 5 (SSR) · Postgres · sharp · pg-boss.
## Authentifizierung (für Agenten)
@@ -59,13 +61,21 @@ Sprache der Oberfläche: Deutsch. Stack: Astro 5 (SSR) · Postgres · sharp · p
- GET /api/items/:id/file — Ergebnisbild. Query: ?thumb=1 (Vorschau), ?preview=1 (kleines JPG fürs Vollbild),
?download=1 (als Datei), ?src=1 (Quelle). Content-Type wird aus den Magic Bytes bestimmt (PNG/JPG/WEBP).
## Druckmodul (ohne KI, /druck)
## Drucken — exakte Maße & Druckbogen (ohne KI, /drucken)
Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kosten.
- Maßangaben: \`12x15\` = 12×15 cm · \`35x45mm\` · \`5\` = 5×5 cm · \`4:3/15\` = Verhältnis 4:3, längere Kante 15 cm.
- Papierformate (id): A6,A5,A4,A3,A3plus(329×483 mm),A2, F9x13,F10x15,F13x18,F15x20,F20x30, Letter, Legal — oder freies Maß.
- Bildformate (id): P35x45 (biometrisch), P50x50, K20x30,K30x40,K40x50,K45x60,K60x90,
S9x13,S10x15,S12x15,S13x18,S15x20,S18x24,S20x30,S30x40,S30x45,S40x50,S40x60,S50x70,
Q10x10,Q13x13,Q20x20,Q30x30, DA6,DA5,DA4,DA3.
- Bildformate: 86 Vorgaben in 10 Gruppen (Passbild, Klein, Foto, Zoll (US), Sofortbild,
Poster & Rahmen, Quadrat, DIN, Karten, Seitenverhältnis) — oder freies Maß. Auswahl der ids:
P35x45 (biometrisch), P50x50 · K20x30,K30x40,K40x50,K45x60,K60x80,K60x90 ·
S9x13,S10x15,S11x15,S12x15,S13x18,S15x20,S18x24,S20x25,S20x30,S24x30 ·
R30x40,R30x45,R40x50,R40x60,R50x70,R60x80,R60x90,R70x100 (Poster & Rahmen) ·
Q10x10,Q13x13,Q20x20,Q30x30 · DA6,DA5,DA4,DA3,DA2 ·
W16x9,W9x16,W3x2,W4x3,W5x4 (Seitenverhältnisse als Druckmaß, u. a. The Frame).
Damit sind alle Formate der KI-Generierung (src/lib/format.ts) auch hier druckbar.
- Passt das Bild nicht zum Format (fit je Zelle): \`cover\` = zuschneiden bis ausgefüllt (Standard,
nichts bleibt leer, außen fehlt etwas) oder \`contain\` = ganzes Bild einpassen, außen bleibt die
Randfarbe \`bg\` (#rrggbb, Standard #ffffff). Verzerrt wird nie.
- Schnitthilfen (marks.mode): \`none\` · \`corner\` (Eckmarken außerhalb des Endformats, Standard 4 mm lang,
3 mm Versatz, 0,25 pt Haarlinie) · \`grid\` (durchgehende Linien über den Bogen, laufen nie durch ein Motiv).
- Beschnittzugabe (bleedMm): das Bild ragt je Seite darüber hinaus; die Trimmbox bleibt exakt der gewählte Ausschnitt.
@@ -73,6 +83,17 @@ Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kos
- Platzierung: gleich große Bilder → exaktes Raster; gemischte Größen → MaxRects-Packer mit fester
Ausrichtung je Format (probiert alle Ausrichtungs-Kombinationen und nimmt die mit den wenigsten Bogen).
- Die PDF-Seite hat exakt das Bogenmaß. Beim Drucken „Tatsächliche Größe / 100 %" wählen — nicht „an Seite anpassen".
- **Größer als der Bogen** (z. B. 20 × 30 cm auf DIN-A4-Fotopapier) — je Zelle über \`oversize\`:
\`fit\` = melden, dass es nicht passt (Standard) · \`overflow\` = mittig aufs ganze Blatt legen,
der Überstand wird beim Drucken abgeschnitten (20 × 30 auf A4: genau 3 mm in der Höhe) ·
\`tile\` = Posterdruck über mehrere Blätter mit Klebefalz (\`overlapMm\`, Standard 10 mm, gestrichelt markiert;
jedes Blatt trägt Nummer, Reihe und Spalte). 30 × 40 cm auf A4 ergibt 2 × 2 Blätter.
- **Beschriftung** (\`footer\`): \`false\` = aus · \`true\` = automatisch (Maße, dpi, Druckhinweis) ·
\`{ text, position, sizePt }\` = eigener Text. Position: \`unten-links|unten-mitte|unten-rechts|
oben-links|oben-mitte|oben-rechts\`. Die Zeile entfällt automatisch, wenn an der gewählten
Kante kein Platz frei ist — sie wird nie über ein Bild gedruckt.
- Dasselbe Bild darf mehrfach im \`cells\`-Feld stehen (verschiedene \`id\`, gleiche \`src\`), um es
in mehreren Maßen auf einen Bogen zu setzen. In der Weboberfläche macht das der Knopf „Duplizieren".
- POST /api/print/single — ein Bild exakt auf Maß. Body: { src:{kind:'upload',path}|{kind:'item',id},
crop?:{x,y,w,h} (relativ 0..1; ohne Angabe mittiger Ausschnitt), size|wMm+hMm, landscape?, dpi?, ext?(jpg|png), bleedMm? }.
@@ -80,8 +101,10 @@ Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kos
- GET /api/print/single?size=12x15&dpi=300 — nur rechnen: mm → Pixel.
- POST /api/print/sheet — Druckbogen als PDF. Body: { paper:{id}|{size}|{wMm,hMm}, landscape?, marginMm?,
gapMm?, bleedMm?, center?, dpi?, ext?, footer?, marks:{mode,lengthMm?,offsetMm?},
cells:[{ id, src, crop?, size|wMm+hMm, landscape?, count?, allowRotate? }] }.
cells:[{ id, src, crop?, size|wMm+hMm, landscape?, count?, allowRotate?, fit?, bg?,
oversize?(fit|overflow|tile), overlapMm? }] }.
Antwort: application/pdf. Header: X-Klarbild-Pages, X-Klarbild-Per-Sheet.
Grenzen: 40 Zellen, 200 Exemplare je Zelle, 500 Einzelbilder und 60 Bogen je Auftrag.
- GET/POST/DELETE /api/print/presets — Bogen-Vorlagen (Papier, Marken, Formate, Stückzahlen).
Mitgeseedet: „Kita-Satz (A4)", „Schulsatz klein (A4)", „Passbildbogen 35×45 (A4)",
„2 × 10×7,5 auf Fotopapier 10×15". Eine Vorlage mit mehreren Formaten und nur einem Bild
@@ -98,6 +121,65 @@ Werkzeug \`klarbild\` (mcp/klarbild-mcp.mjs), nutzt denselben API-Token:
- exact_size — OHNE KI: ein Bild exakt auf ein Maß bringen, Datei lokal speichern.
- print_sheet — OHNE KI: Druckbogen (mehrere Bilder × Stückzahl, Schnittmarken) als PDF lokal speichern.
## Teilen-Links (öffentlich, ohne Konto)
Ein Bild oder eine Sammlung unter \`/s/<slug>\` zugänglich machen — der Empfänger braucht kein Konto.
- Voreinstellungen sind bewusst vorsichtig (auf den Bildern sind Kinder): **Ablauf** (Standard 30 Tage,
\`settings.share_default_days\`), jederzeit **widerrufbar** und wieder freigebbar, optionales
**Zugangswort** (argon2), **Herunterladen abschaltbar**, Seiten und Dateien tragen \`X-Robots-Tag: noindex\`.
- Der Zugriff hängt am **Link**, nicht am Bild: Eine fremde \`item\`-Kennung an einem gültigen Link
liefert 404. Bilder aus privaten Sitzungen lassen sich gar nicht teilen.
- Zustände: 200 (gültig) · 401 (Zugangswort nötig oder falsch) · 404 (unbekannt) · 410 (abgelaufen oder
widerrufen). Das Zugangswort geht per **POST**, danach quittiert ein kurzlebiges Cookie je Link.
- \`GET /api/s/<slug>/file?item=<uuid>\` — Ansichtsfassung (auf 2000 px verkleinert).
\`&download=1\` liefert das Original; bei gesperrtem Download 403. \`&thumb=1\` für die Übersicht.
- \`GET /api/s/<slug>/zip\` — **alle** Bilder des Links als ZIP (voller Auflösung).
Bewusst GET, damit es ein schlichter Link ohne JavaScript ist. Achtet auf Download-Sperre
(403) und Zugangswort (401). Grenzen: 200 Dateien, 500 MB entpackt (danach schließt das
Archiv mit einer \`HINWEIS.txt\`), 5 Pakete je Link und Adresse in 10 Minuten. Wird als
**ein** Download gezählt. Es wird eine Datei nach der anderen gelesen und weitergereicht,
nicht alles auf einmal in den Speicher.
- \`GET|POST /api/shares\` — eigene Links auflisten bzw. anlegen. Body: \`{ itemIds[], title?, note?,
days?, allowDownload?, password? }\`. \`days\` muss eine Zahl von 0–3650 sein (0 = unbegrenzt);
alles andere wird **abgelehnt**, nicht als „unbegrenzt" gedeutet.
- \`GET|PATCH|DELETE /api/shares/<id>\` — Zugriffe ansehen, ändern (\`action: 'revoke'|'unrevoke'\`,
\`title\`, \`note\`, \`days\`, \`allowDownload\`, \`password\`), löschen.
- \`POST /api/shares/send\` — Link per Mail verschicken: \`{ shareId, to, message? }\`, höchstens 10
Empfänger je Anfrage, 50 Mails je Stunde und Absender. **Kein Anhang** — ein Link lässt sich
zurückziehen, ein Anhang nicht.
- WhatsApp läuft über einen \`wa.me\`-Link im Browser (kein Server-Versand; die WhatsApp-Business-API
bräuchte Meta-Konto, genehmigte Vorlagen und Gebühren).
- Gezählt wird **wann** und mit welcher Art Gerät geöffnet wurde, nicht **wer**: von der IP nur ein
Hash, gesalzen mit Server-Geheimnis **und** Link-Kennung (also nicht über Links hinweg verknüpfbar),
höchstens eine Zeile je Besucher und Stunde, Löschung nach 90 Tagen.
## E-Mail
Versand über das eigene Postfach (SMTP, Zugang verschlüsselt in \`settings\`; Admin → E-Mail).
Gebraucht für: Passwort zurücksetzen, Hinweis bei fertigen Aufträgen (je Nutzer schaltbar unter
\`/konto\`), Teilen-Links verschicken. Ohne eingerichteten Versand melden die betroffenen Routen 503.
- \`POST /api/auth/reset { email }\` — Rücksetz-Link anfordern. Antwortet **immer gleich**, egal ob es
die Adresse gibt. \`POST /api/auth/reset { token, password }\` — neues Passwort setzen; Token gilt
60 Minuten, genau einmal, und entwertet alle offenen Sitzungen des Kontos.
- \`GET|PATCH /api/me\` — eigenes Konto: \`email\`, \`notify_jobs\`.
## KI-Kennzeichnung
Ergebnisse tragen IPTC \`DigitalSourceType\` als XMP in der Datei:
\`trainedAlgorithmicMedia\` (aus Text erzeugt) bzw. \`compositeWithTrainedAlgorithmicMedia\`
(vorhandenes Foto verändert), dazu \`xmp:CreatorTool\` mit dem verwendeten Modell.
Auslesbar mit exiftool. Das Druckmodul erzeugt nichts mit KI und kennzeichnet folglich nichts.
## Dateinamen der Ergebnisse
Schema: \`JJJJ-MM-TT_HHMMSS_<motiv>_<format>_<kürzel>.<ext>\` — z. B. \`2026-08-20_143207_lea-badge_the-frame_a3f9.jpg\`.
- Der Zeitstempel steht vorn, deshalb sortiert jede Dateiliste (Picdrop, NAS, Finder) von allein chronologisch.
- Das vierstellige Kürzel stammt aus der Item-ID. Dadurch sind zwei Bilder **nie** namensgleich —
auch nicht bei gleichem Motiv, gleichem Format und gleichem Tag. (Vor dem 21.08.2026 fehlten Uhrzeit
und Kürzel; gleichartige Bilder desselben Tages überschrieben sich beim Ausliefern.)
- Zeitzone: \`KLARBILD_TZ\`, Standard \`Europe/Berlin\`.
- Motiv wird aus dem Ursprungsnamen geschält (Datum, UUIDs und der eigene Stempel fallen weg);
generische Namen wie \`IMG\`, \`foto\`, \`telegram\` werden zu \`klarbild\`.
- Nachbenennung der ganzen Bibliothek: Admin → „Dateinamen" bzw.
\`POST /api/admin/storage { action: 'rename_preview' | 'rename_apply' | 'redeliver_all' }\` (nur per Login-Session,
nicht per API-Token). Idempotent — Stempel aus \`created_at\`, Kürzel aus der Item-ID.
## Auslieferung
- Picdrop = SFTP/FTPS-Ziel; Galerie = Unterordner unter dem Basispfad. Voller Pfad = Basisordner + Galerie
(wird erst beim Ausliefern zusammengesetzt). Standard-Galerie „POSTER LEA"; „The Frame" → „TheFrame-Backgrounds".
+3
View File
@@ -11,6 +11,7 @@ if (Astro.locals.user) return Astro.redirect('/');
<label>Passwort<input name="password" type="password" autocomplete="current-password" required /></label>
<button type="submit">Anmelden</button>
<p id="err" class="err" hidden></p>
<a class="vergessen" href="/passwort">Passwort vergessen?</a>
</form>
</section>
<style>
@@ -25,6 +26,8 @@ if (Astro.locals.user) return Astro.redirect('/');
button { margin-top: 4px; padding: 11px; border: none; border-radius: var(--radius-sm);
background: var(--accent); color: white; font-size: 1rem; font-weight: 600; cursor: pointer; }
.err { color: var(--err); font-size: 0.9rem; margin: 4px 0 0; }
.vergessen { font-size: 0.85rem; color: var(--ink-soft); text-align: center; text-decoration: none; padding: 4px; }
.vergessen:hover { color: var(--accent); }
</style>
<script>
const f = document.getElementById('f'); const err = document.getElementById('err');
+4
View File
@@ -0,0 +1,4 @@
---
// Alte Adresse — die Funktion heißt jetzt „Drucken".
return Astro.redirect('/drucken', 301);
---
+97
View File
@@ -0,0 +1,97 @@
---
// „Passwort vergessen" — ohne Anmeldung erreichbar.
// Zwei Zustände in einer Seite: ohne Token die Adresse abfragen, mit Token das
// neue Passwort setzen.
import Base from '../layouts/Base.astro';
const token = Astro.url.searchParams.get('token') || '';
---
<Base title="Passwort · Klarbild">
<main class="pw">
<h1 class="display">Klarbild</h1>
{token ? (
<form id="setzen" class="karte" data-token={token}>
<h2>Neues Passwort setzen</h2>
<label>Neues Passwort
<input name="password" type="password" autocomplete="new-password" required minlength="10"
placeholder="mindestens 10 Zeichen" autofocus />
</label>
<label>Noch einmal
<input name="password2" type="password" autocomplete="new-password" required minlength="10" />
</label>
<button type="submit">Passwort speichern</button>
<p class="hinweis">Ein langer Satz ist sicherer und leichter zu merken als ein kurzes Kauderwelsch.</p>
<p id="msg" class="msg" hidden></p>
</form>
) : (
<form id="anfordern" class="karte">
<h2>Passwort vergessen</h2>
<p class="lead">Trag deine E-Mail-Adresse ein. Wenn es dazu ein Konto gibt, kommt gleich ein Link,
mit dem du dir ein neues Passwort setzen kannst.</p>
<label>E-Mail-Adresse
<input name="email" type="email" autocomplete="email" required autofocus placeholder="du@beispiel.de" />
</label>
<button type="submit">Link schicken</button>
<p id="msg" class="msg" hidden></p>
</form>
)}
<p class="zurueck"><a href="/login">← Zur Anmeldung</a></p>
</main>
<style>
.pw { max-width: 400px; margin: 8vh auto 0; padding: 0 18px 60px; text-align: center; }
.display { font-family: var(--font-display); font-size: 2.2rem; margin: 0 0 26px; letter-spacing: -0.02em; }
.karte { display: grid; gap: 14px; text-align: left; background: var(--surface);
border: 1px solid var(--line); border-radius: var(--radius); padding: 22px; box-shadow: var(--shadow); }
.karte h2 { font-size: 1.1rem; margin: 0; }
.lead { margin: 0; font-size: 0.9rem; color: var(--ink-soft); line-height: 1.6; }
label { display: grid; gap: 6px; font-size: 0.9rem; color: var(--ink-soft); }
input { padding: 11px 12px; border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--paper); color: var(--ink); font-size: 16px; font-family: inherit; }
button { margin-top: 4px; padding: 12px; border: none; border-radius: var(--radius-sm);
background: var(--accent); color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; font-family: inherit; }
button:disabled { background: var(--paper); color: var(--soft); border: 1px solid var(--line); cursor: not-allowed; }
.hinweis { margin: 0; font-size: 0.8rem; color: var(--soft); line-height: 1.5; }
.msg { margin: 4px 0 0; font-size: 0.9rem; line-height: 1.5; }
.msg.ok { color: var(--ok); }
.msg.err { color: var(--err); }
.zurueck { margin-top: 22px; font-size: 0.9rem; }
</style>
<script>
const zeig = (el: HTMLElement, text: string, ok: boolean) => {
el.textContent = text; el.className = 'msg ' + (ok ? 'ok' : 'err'); (el as HTMLElement).hidden = false;
};
const post = (body: unknown) =>
fetch('/api/auth/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) }).then((r) => r.json().catch(() => ({})));
const anfordern = document.getElementById('anfordern') as HTMLFormElement | null;
anfordern?.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = document.getElementById('msg')!;
const btn = anfordern.querySelector('button')!;
btn.disabled = true;
const email = (new FormData(anfordern).get('email') || '').toString();
const r = await post({ email });
btn.disabled = false;
if (r.error) zeig(msg, r.error, false);
else zeig(msg, 'Wenn es zu dieser Adresse ein Konto gibt, ist die Nachricht unterwegs. Schau auch im Spam nach.', true);
});
const setzen = document.getElementById('setzen') as HTMLFormElement | null;
setzen?.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = document.getElementById('msg')!;
const f = new FormData(setzen);
const pw = (f.get('password') || '').toString();
if (pw !== (f.get('password2') || '').toString()) { zeig(msg, 'Die beiden Eingaben stimmen nicht überein.', false); return; }
const btn = setzen.querySelector('button')!;
btn.disabled = true;
const r = await post({ token: setzen.dataset.token, password: pw });
if (r.ok) { zeig(msg, 'Passwort gespeichert. Du wirst zur Anmeldung geleitet …', true); setTimeout(() => (location.href = '/login'), 1400); }
else { btn.disabled = false; zeig(msg, r.error || 'Das hat nicht geklappt.', false); }
});
</script>
</Base>
+203
View File
@@ -0,0 +1,203 @@
---
// Öffentliche Ansicht eines Teilen-Links — ohne Anmeldung erreichbar.
//
// Bewusst eine eigene, schlanke Seite statt der App: Wer den Link bekommt, soll
// die Bilder sehen und herunterladen können, sonst nichts. Keine Navigation,
// kein Zugang zur Bibliothek, kein Hinweis darauf, was es sonst noch gibt.
import Base from '../../layouts/Base.astro';
import { resolveShare, shareItems, trackShare, nachweisBauen, nachweisGilt, nachweisAusCookie, nachweisName } from '../../lib/shares';
import { zuOft, echteIp } from '../../lib/limit';
const slug = Astro.params.slug!;
// Zugangswort kommt per POST (nicht als `?pw=` in der Adresse — das landete
// sonst im Verlauf, in Proxy-Protokollen und in jedem weitergeleiteten Link).
// Nach erfolgreicher Prüfung merkt sich ein kurzlebiges, auf diesen Link
// beschränktes Cookie das Ergebnis: `argon2.verify` kostet 130 ms und 64 MiB
// und lief sonst bei **jedem** Bildaufruf erneut.
let eingabe: string | null = null;
let falsch = false;
let gesperrt = false;
if (Astro.request.method === 'POST') {
// Durchprobieren begrenzen: Ein Zugangswort, das man am Telefon durchgibt,
// ist kurz — ohne Bremse wäre Raten aussichtsreich, und jede Prüfung kostet
// 130 ms und 64 MiB.
if (zuOft(`wort:${slug}:${echteIp(Astro.request, Astro.clientAddress)}`, 10, 15 * 60_000)) {
gesperrt = true;
} else {
const f = await Astro.request.formData().catch(() => null);
eingabe = String(f?.get('pw') || '') || null;
}
}
const cookieKopf = Astro.request.headers.get('cookie');
let { status, share } = await resolveShare(slug, eingabe);
// Bereits nachgewiesen? Dann ohne erneutes Hashen durchlassen.
if (status === 'passwort' && share && nachweisGilt(share.id, nachweisAusCookie(cookieKopf, slug))) {
status = 'ok';
}
if (status === 'ok' && share?.password_hash && eingabe) {
Astro.response.headers.append('Set-Cookie',
`${nachweisName(slug)}=${nachweisBauen(share.id)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=21600`);
}
if (status === 'passwort' && eingabe) falsch = true;
let bilder: Awaited<ReturnType<typeof shareItems>> = [];
if (status === 'ok' && share) {
bilder = await shareItems(share.id);
await trackShare(share.id, 'view', Astro.clientAddress, Astro.request.headers.get('user-agent'));
}
// Diese Seite gehört nie in einen Suchindex.
Astro.response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, noimageindex');
if (status !== 'ok') Astro.response.status = status === 'nicht-gefunden' ? 404 : status === 'passwort' ? 401 : 410;
const titel = share?.title || (bilder.length > 1 ? 'Bilder' : 'Bild');
const dl = (id: string) => `/api/s/${slug}/file?item=${id}&download=1`;
const gross = (id: string) => `/api/s/${slug}/file?item=${id}`;
const klein = (id: string) => `/api/s/${slug}/file?item=${id}&thumb=1`;
const meldung: Record<string, { h: string; t: string }> = {
'nicht-gefunden': { h: 'Diesen Link gibt es nicht', t: 'Vielleicht hat sich beim Kopieren ein Zeichen verloren. Frag am besten noch einmal nach.' },
'abgelaufen': { h: 'Dieser Link ist abgelaufen', t: 'Teilen-Links gelten nur eine begrenzte Zeit. Wer ihn dir geschickt hat, kann einen neuen erzeugen.' },
'widerrufen': { h: 'Dieser Link wurde deaktiviert', t: 'Er wurde bewusst abgeschaltet. Frag noch einmal nach, wenn du die Bilder brauchst.' },
};
---
<Base title={`${titel} · Klarbild`}>
<main class="teilen">
{status === 'ok' && share ? (
<>
<header class="kopf">
<p class="marke"><span class="regmark"></span> geteilt über Klarbild</p>
<h1>{titel}</h1>
{share.note && <p class="notiz">{share.note}</p>}
<p class="mono-label">
{bilder.length === 1 ? '1 Bild' : `${bilder.length} Bilder`}
{share.expires_at && <> · gültig bis {new Date(share.expires_at).toLocaleDateString('de-DE')}</>}
</p>
</header>
{share.allow_download && bilder.length > 1 && (
<div class="alle">
<a class="knopf gross" href={`/api/s/${slug}/zip`}>
Alle {bilder.length} Bilder herunterladen
</a>
<span class="alle-fein">als ZIP-Datei, in voller Auflösung</span>
</div>
)}
{share.allow_download ? (
<p class="hinweis">
<b>Herunterladen</b> speichert die Datei in voller Auflösung.
Am Handy geht es auch so: lange auf ein Bild drücken und speichern.
{bilder.length > 1 && <> Oder alle auf einmal, oben.</>}
</p>
) : (
<p class="hinweis">
Diese Bilder sind zum Ansehen gedacht. Wenn du eine Datei brauchst, frag bei
der Person nach, die dir den Link geschickt hat.
</p>
)}
{bilder.length === 0 ? (
<p class="leer">Hier ist gerade nichts zu sehen. Vielleicht wurden die Bilder inzwischen
entfernt — frag am besten noch einmal bei der Person nach, die dir den Link geschickt hat.</p>
) : (
<div class={bilder.length === 1 ? 'raster einzeln' : 'raster'}>
{bilder.map((b) => (
<figure class="bild">
<a href={gross(b.id)} target="_blank" rel="noopener">
{/* In der Übersicht das Vorschaubild — sonst rechnet der Server
für jede Kachel ein Vollbild neu. Das große Bild kommt erst
beim Antippen. */}
<img src={bilder.length > 1 ? klein(b.id) : gross(b.id)}
alt={b.filename || 'Bild'} loading="lazy" />
</a>
<figcaption>
<span class="mass">{b.output_px?.replace('x', ' × ') || ''}</span>
{share.allow_download
? <a class="knopf" href={dl(b.id)}>Herunterladen</a>
: <span class="fein">Nur ansehen</span>}
</figcaption>
</figure>
))}
</div>
)}
<p class="datenschutz">
Wer dir diesen Link geschickt hat, sieht, <b>wann</b> und mit welcher Art Gerät er geöffnet
wurde. Nicht, <b>wer</b> das war: Von deiner Internet-Adresse wird nur ein unkenntlicher
Zahlenwert gespeichert, und der wird nach 90 Tagen gelöscht.
</p>
</>
) : status === 'passwort' ? (
<section class="tor">
<h1>Dieser Link ist mit einem Wort geschützt</h1>
<p>Gib das Zugangswort ein, das du zusammen mit dem Link bekommen hast.</p>
<form method="post" class="tor-form">
<input type="password" name="pw" placeholder="Zugangswort" aria-label="Zugangswort" autofocus required />
<button type="submit">Ansehen</button>
</form>
{gesperrt
? <p class="fehler">Zu viele Versuche. Bitte in einer Viertelstunde noch einmal.</p>
: falsch && <p class="fehler">Das war leider nicht richtig.</p>}
</section>
) : (
<section class="tor">
<h1>{meldung[status]?.h ?? 'Nicht verfügbar'}</h1>
<p>{meldung[status]?.t ?? ''}</p>
</section>
)}
</main>
<style>
.teilen { max-width: 1000px; margin: 0 auto; padding: 40px 20px 72px; }
.kopf { margin-bottom: 26px; }
.marke { display: flex; align-items: center; gap: 8px; margin: 0 0 10px;
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--soft); }
.kopf h1 { font-size: clamp(1.5rem, 4vw, 2rem); margin: 0 0 8px; letter-spacing: -0.02em; }
.notiz { margin: 0 0 10px; color: var(--ink-soft); max-width: 60ch; line-height: 1.6; }
.raster { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 18px; }
.raster.einzeln { grid-template-columns: minmax(0, 1fr); max-width: 720px; }
.bild { margin: 0; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
/* Nur der **direkte** Bildlink, nicht jeder Link in der Figur: `.bild a`
traf auch den Herunterladen-Knopf in der Unterzeile und übermalte ihn mit
Papierfarbe — heller Text auf hellem Grund, praktisch unsichtbar. */
.bild > a { display: block; line-height: 0; background: var(--paper); }
.bild img { width: 100%; height: auto; display: block; }
figcaption { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; }
.mass { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; color: var(--soft); }
.knopf { background: var(--accent); color: var(--card); text-decoration: none; font-size: 13px; font-weight: 600;
padding: 8px 14px; border-radius: 3px; white-space: nowrap; }
/* Der Sammel-Download steht über den Bildern: Bei zwanzig Kacheln findet
ihn unten niemand, und einzeln tippen will man dann erst recht nicht. */
.alle { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 0 0 18px; }
.knopf.gross { font-size: 15px; padding: 13px 22px; border-radius: var(--radius-sm); }
.alle-fein { font-size: 12.5px; color: var(--soft); }
.fein { font-size: 12px; color: var(--soft); }
.leer { color: var(--soft); }
/* Steht über den Bildern: Unter zwanzig Kacheln würde ihn niemand lesen. */
.hinweis { margin: 0 0 20px; padding: 11px 13px; border: 1px solid var(--line);
border-radius: var(--radius-sm); background: var(--card);
font-size: 13px; color: var(--ink-soft); line-height: 1.6; max-width: 62ch; }
.datenschutz { margin: 30px 0 0; padding-top: 16px; border-top: 1px solid var(--line);
font-size: 12px; color: var(--soft); line-height: 1.6; max-width: 62ch; }
.tor { max-width: 460px; margin: 8vh auto 0; text-align: center; }
.tor h1 { font-size: 1.4rem; margin: 0 0 12px; }
.tor p { color: var(--ink-soft); line-height: 1.6; margin: 0 0 20px; }
.tor-form { display: flex; gap: 8px; }
.tor-form input { flex: 1; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--card); font-size: 16px; font-family: inherit; color: var(--ink); }
.tor-form button { border: none; border-radius: var(--radius-sm); background: var(--accent); color: #fff;
padding: 12px 20px; font-weight: 600; font-family: inherit; font-size: 15px; cursor: pointer; }
.fehler { color: var(--err); margin-top: 14px; font-size: 14px; }
@media (max-width: 640px) {
.teilen { padding: 24px 14px 56px; }
.raster { grid-template-columns: 1fr; gap: 14px; }
.knopf { padding: 11px 16px; }
}
</style>
</Base>
+21
View File
@@ -0,0 +1,21 @@
---
import Base from '../layouts/Base.astro';
import SharesApp from '../components/SharesApp';
---
<Base title="Geteilte Links · Klarbild">
<section class="seite">
<header class="seitenkopf">
<h1>Geteilte Links</h1>
<p class="lead">Links, mit denen andere deine Bilder ansehen können — ohne Konto.
Hier siehst du, wie oft sie geöffnet wurden, und kannst sie jederzeit abschalten.</p>
</header>
<SharesApp client:load />
</section>
<style>
.seite { max-width: var(--maxw); margin: 0 auto; padding: 26px 20px 80px; }
.seitenkopf { margin-bottom: 20px; }
.seitenkopf h1 { font-size: clamp(1.4rem, 4vw, 1.9rem); margin: 0 0 6px; letter-spacing: -0.02em; }
.lead { margin: 0; color: var(--ink-soft); line-height: 1.6; max-width: 64ch; font-size: 0.95rem; }
@media (max-width: 640px) { .seite { padding: 18px 14px 90px; } }
</style>
</Base>
+315
View File
@@ -0,0 +1,315 @@
---
// Öffentliche Vorstellungsseite für das Druckmodul.
// Bewusst ohne Login und ohne Bedienelemente: Sie zeigt, was das Werkzeug kann,
// man kann es hier aber nicht benutzen. Gedacht als Landeseite für ein
// Open-Source-Release (Gitea/GitHub) und zum Herzeigen.
import Base from '../layouts/Base.astro';
import { PHOTO_SIZES, PAPERS } from '../lib/paper';
import { layout, cutMarks, type SheetSpec } from '../lib/printlayout';
const anzahlFormate = PHOTO_SIZES.length;
const anzahlPapiere = PAPERS.length;
const anzahlGruppen = new Set(PHOTO_SIZES.map((p) => p.group)).size;
const koennen = [
{
kicker: 'Maßhaltig',
titel: 'Millimeter statt „ungefähr“',
text: 'Jedes Endmaß wird in Millimetern gerechnet und erst beim Rendern in Pixel umgesetzt. ' +
'Das PDF hat exakt das Bogenmaß, jedes Bild darin exakt sein Zielmaß. Nachgemessen wird nicht geschätzt, ' +
'sondern gemessen: Wir rastern die fertigen PDFs mit 254 dpi und zählen die Pixelkanten nach.',
beleg: '13 × 18 cm → gemessen 130,1 × 180,1 mm',
},
{
kicker: 'Platz',
titel: 'Mehrere Bilder auf einen Bogen',
text: 'Gleich große Bilder kommen in ein exaktes Raster. Bei gemischten Formaten sucht ein Packer ' +
'(MaxRects, „Best Short Side Fit“) die Anordnung mit den wenigsten Bogen — und probiert dafür jede ' +
'Kombination aus Hoch- und Querlage durch. Ein Passbild-Satz und ein 13 × 18 passen so auf dasselbe Blatt.',
beleg: 'Kita-Satz: 9 Teile statt 3 auf einem A4-Bogen',
},
{
kicker: 'Schneiden',
titel: 'Schnittmarken wie in der Druckvorstufe',
text: 'Feine Eckmarken außerhalb jedes Bildes oder durchgehende Rasterlinien über den ganzen Bogen. ' +
'Die Linien werden dort unterbrochen, wo ein anderes Motiv liegt — es wird also nie quer über ein ' +
'Bild gezeichnet. Länge und Abstand sind einstellbar, Beschnittzugabe ebenso.',
beleg: 'Eckmarken · durchgehendes Raster · Beschnittzugabe 0–10 mm',
},
{
kicker: 'Zu groß',
titel: '20 × 30 auf A4 — trotzdem',
text: 'Der Alltagsfall: Es soll ein 20 × 30 werden, im Haus liegt aber nur A4-Fotopapier. ' +
'Statt „geht nicht“ gibt es zwei Wege. Überstehen legt das Bild mittig aufs ganze Blatt; ' +
'was hinausragt, fehlt hinterher — und wie viel das ist, steht vorher da.',
beleg: '20 × 30 cm auf A4: es fehlen genau 3 mm in der Höhe',
},
{
kicker: 'Poster',
titel: 'Ein Bild, mehrere Blätter',
text: 'Der zweite Weg für große Maße: Das Bild wird auf mehrere Blätter verteilt. Die Kacheln ' +
'überlappen sich um einen Klebefalz, der auf beiden Nachbarblättern gedruckt und gestrichelt ' +
'markiert wird. Jedes Blatt trägt seine Nummer samt Reihe und Spalte.',
beleg: '30 × 40 cm auf A4 → 2 × 2 Blätter, 10 mm Klebefalz',
},
{
kicker: 'Zuschnitt',
titel: 'Der Ausschnitt bleibt deiner',
text: 'Passt das Seitenverhältnis nicht, entscheidest du: zuschneiden oder Rand lassen. ' +
'Im Zuschnitt-Fenster verschiebst und zoomst du das Original im Zielrahmen — mit Maus, Finger, ' +
'Doppeltipp oder Pfeiltasten. Der Ausschnitt wird relativ gespeichert und überlebt jede Auflösung.',
beleg: 'EXIF-Drehung wird vorab eingerechnet — Handyfotos stehen richtig',
},
];
const daten = [
{ zahl: String(anzahlFormate), label: 'Bildformate', fein: `in ${anzahlGruppen} Gruppen — Passbild bis 100 × 140 cm` },
{ zahl: String(anzahlPapiere), label: 'Papierformate', fein: 'DIN A7–A0, Fotopapier, US-Maße, freies Maß' },
{ zahl: '0', label: 'KI im Spiel', fein: 'reine Geometrie, kein Modell, kein Upload nach außen' },
{ zahl: '1:1', label: 'Ausgabe', fein: 'PDF in Originalgröße, 72–1200 dpi' },
];
// Der Beispielbogen unten wird nicht gezeichnet, sondern gerechnet — mit genau
// demselben Packer, den das Werkzeug benutzt. Ändert sich der Algorithmus,
// ändert sich das Bild auf dieser Seite mit.
const musterBogen: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 8, bleedMm: 0, center: true };
const musterPlan = layout([
{ id: 'gross', wMm: 130, hMm: 180, count: 1, allowRotate: true },
{ id: 'klein', wMm: 90, hMm: 130, count: 1, allowRotate: true },
{ id: 'pass', wMm: 35, hMm: 45, count: 6, allowRotate: true },
], musterBogen);
const musterSeite = musterPlan.pages[0];
const musterMarken = musterSeite ? cutMarks(musterSeite, musterBogen, { mode: 'corner', lengthMm: 3, offsetMm: 1 }) : [];
const musterFarbe: Record<string, string> = {
gross: 'oklch(45% 0.23 267 / 0.09)', klein: 'oklch(45% 0.23 267 / 0.20)', pass: 'oklch(45% 0.23 267 / 0.34)',
};
const musterAnzahl = musterSeite ? musterSeite.placements.length : 0;
/** Kompaktes Maß fürs Kästchen: unter 10 cm in mm, sonst in cm. */
const kurzMass = (w: number, h: number) => w < 100 && h < 100
? `${Math.round(w)}×${Math.round(h)}`
: `${Math.round(w / 10)}×${Math.round(h / 10)} cm`;
const schritte = [
{ n: '01', t: 'Bilder wählen', d: 'Hochladen, aus der Bibliothek holen oder ein vorhandenes Bild duplizieren, um es in zwei Maßen zu drucken.' },
{ n: '02', t: 'Maße setzen', d: 'Je Bild ein Zielmaß, Anzahl, Hoch oder Quer, zuschneiden oder Rand lassen.' },
{ n: '03', t: 'Bogen einstellen', d: 'Papier, Schnittmarken, Ränder, Auflösung. Die Vorschau zeigt sofort, wie viele Bogen es werden.' },
{ n: '04', t: 'Drucken', d: 'PDF erzeugen und im Druckdialog „Tatsächliche Größe / 100 %“ wählen. Fertig.' },
];
---
<Base title="Klarbild Drucken — Bilder auf exakte Maße bringen">
<main class="vs">
<header class="held">
<p class="mono-label">Klarbild · Druckmodul</p>
<h1>Bilder auf exakte Maße bringen.<br /><span class="ohne">Ohne KI.</span></h1>
<p class="lead">
Ein Passbild ist 35 × 45 Millimeter. Ein Rahmen ist 13 × 18 Zentimeter. Beides sind Maße,
keine Meinungen — und trotzdem ist es erstaunlich mühsam, ein Foto genau so aus dem Drucker
zu bekommen. Dieses Werkzeug macht daraus einen Handgriff: Zielmaß eintragen, Ausschnitt
wählen, PDF drucken. Millimetergenau, mit Schnittmarken, mehrere Bilder auf einem Blatt.
</p>
<div class="held-marken" aria-hidden="true">
<svg viewBox="0 0 300 120" class="held-svg">
<rect x="8" y="8" width="284" height="104" fill="#FBFBF7" stroke="#D7D6CC" />
<g stroke="#1B3BE0" stroke-width="1">
<path d="M26 22h-10M22 26v-10" /><path d="M120 22h10M124 26v-10" />
<path d="M26 98h-10M22 94v10" /><path d="M120 98h10M124 94v10" />
<path d="M156 22h-10M152 26v-10" /><path d="M282 22h-8M278 26v-10" />
<path d="M156 98h-10M152 94v10" /><path d="M282 98h-8M278 94v10" />
</g>
<rect x="26" y="22" width="94" height="76" fill="#EAEAE3" stroke="#D7D6CC" stroke-width="0.5" />
<rect x="156" y="22" width="120" height="76" fill="#EAEAE3" stroke="#D7D6CC" stroke-width="0.5" />
<text x="30" y="93" font-family="monospace" font-size="7" fill="#6A685D">35 × 45 mm</text>
<text x="160" y="93" font-family="monospace" font-size="7" fill="#6A685D">13 × 18 cm</text>
</svg>
</div>
</header>
<section class="zahlen" aria-label="Kennzahlen">
{daten.map((d) => (
<div class="zahl">
<b>{d.zahl}</b>
<span class="mono-label">{d.label}</span>
<p>{d.fein}</p>
</div>
))}
</section>
<section class="problem">
<h2>Das Problem</h2>
<p>
Der übliche Weg führt über ein Layoutprogramm oder über den Druckertreiber, und beide meinen
es zu gut: „an Seitengröße anpassen“, „einpassen“, „randlos skalieren“. Am Ende liegt ein
Ausdruck vor, der fast stimmt. Für ein Passbild ist „fast“ zu wenig, und für ein
4 × 3-Bildchen ein ganzes DIN-A4-Blatt zu verbrauchen, nur weil es sonst nicht in
100 % Größe auf die Seite passt, ist schlicht Verschwendung.
</p>
<p>
Klarbild Drucken rechnet stattdessen in Millimetern, packt so viele Bilder auf einen Bogen,
wie hineingehen, und legt Schnittmarken dorthin, wo geschnitten wird. Was aus dem Drucker
kommt, hat das Maß, das du eingetragen hast.
</p>
</section>
<section class="muster">
<h2>Ein Bogen, gerechnet</h2>
<p class="muster-lead">
Ein 13 × 18, ein 9 × 13 und sechs Passbilder — auf einem einzigen DIN-A4-Bogen,
alle in 100 % Größe, mit Eckmarken zum Schneiden. Diese Zeichnung ist kein Entwurf:
Sie stammt aus demselben Packer, der auch das PDF erzeugt.
</p>
<div class="muster-halter">
<svg class="muster-svg" viewBox={`-6 -6 ${musterBogen.wMm + 12} ${musterBogen.hMm + 12}`} role="img"
aria-label={`DIN-A4-Bogen mit ${musterAnzahl} Bildern in drei Formaten`}>
<rect x="0" y="0" width={musterBogen.wMm} height={musterBogen.hMm} fill="#FBFBF7" stroke="#D7D6CC" stroke-width="0.4" />
{musterSeite?.placements.map((pl) => (
<g>
<rect x={pl.x} y={pl.y} width={pl.w} height={pl.h}
fill={musterFarbe[pl.specId] || 'oklch(45% 0.23 267 / 0.14)'}
stroke="oklch(45% 0.23 267 / 0.45)" stroke-width="0.3" />
<text x={pl.x + pl.w / 2} y={pl.y + pl.h / 2 + 1.6} text-anchor="middle"
font-family="monospace" font-size="4" fill="#16150F" opacity="0.62">
{kurzMass(pl.w, pl.h)}
</text>
</g>
))}
{musterMarken.map((l) => (
<line x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2} stroke="#1B3BE0" stroke-width="0.25" />
))}
</svg>
<ul class="muster-liste">
<li><i style="background: oklch(45% 0.23 267 / 0.09)"></i> 1 × 13 × 18 cm</li>
<li><i style="background: oklch(45% 0.23 267 / 0.20)"></i> 1 × 9 × 13 cm</li>
<li><i style="background: oklch(45% 0.23 267 / 0.34)"></i> 6 × 35 × 45 mm</li>
<li class="muster-summe">{musterAnzahl} Teile auf {musterPlan.pages.length} Bogen · 5 mm Rand · 8 mm Abstand</li>
</ul>
</div>
</section>
<section class="koennen">
<h2>Was es kann</h2>
<div class="karten">
{koennen.map((k) => (
<article class="kk">
<p class="mono-label">{k.kicker}</p>
<h3>{k.titel}</h3>
<p class="kk-text">{k.text}</p>
<p class="kk-beleg">{k.beleg}</p>
</article>
))}
</div>
</section>
<section class="ablauf">
<h2>In vier Schritten</h2>
<ol class="schritte">
{schritte.map((s) => (
<li><span class="mono-label">{s.n}</span><b>{s.t}</b><p>{s.d}</p></li>
))}
</ol>
</section>
<section class="technik">
<h2>Unter der Haube</h2>
<dl>
<div><dt>Einheit</dt><dd>Millimeter. Pixel entstehen erst beim Rendern (<code>px = mm / 25,4 × dpi</code>), PDF-Punkte über <code>mm / 25,4 × 72</code>.</dd></div>
<div><dt>Rechenkern</dt><dd>Abhängigkeitsfreies TypeScript. Browser-Vorschau und Server-PDF benutzen buchstäblich denselben Code — was du siehst, wird gedruckt.</dd></div>
<div><dt>Packen</dt><dd>Exaktes Raster bei gleichen Maßen, MaxRects (Best Short Side Fit) bei gemischten. Bewertet nach wenigsten Bogen, vollster erster Seite, wenigsten Drehungen.</dd></div>
<div><dt>Bild</dt><dd>sharp in einer einzigen Kette: EXIF-Drehung, Ausschnitt, Skalierung, Rand. Der Zuschnitt wird relativ (0…1) gespeichert und ist damit auflösungsunabhängig.</dd></div>
<div><dt>PDF</dt><dd>pdf-lib. Seitengröße exakt das Bogenmaß, Bilder 1 : 1 platziert, Schnittmarken als Vektorlinien, Klebefalze gestrichelt.</dd></div>
<div><dt>Nachweis</dt><dd>Automatische Tests für die Geometrie plus Rasterprobe der fertigen PDFs bei 254 dpi (= 10 px/mm) mit Kantenmessung.</dd></div>
</dl>
</section>
<section class="offen">
<h2>Selbst betreiben</h2>
<p>
Klarbild läuft auf einem eigenen Server: Astro mit Node, PostgreSQL, eine Warteschlange im
selben Prozess, Dateien lokal oder auf S3. Das Druckmodul kommt ohne jeden externen Dienst
aus — kein Modell, kein Schlüssel, keine Bilder, die das Haus verlassen. Wer nur drucken
will, braucht die KI-Teile gar nicht erst zu konfigurieren.
</p>
<p class="fein">
Diese Seite ist eine Vorstellung, keine Bedienoberfläche — hier lässt sich nichts hochladen
und nichts erzeugen.
</p>
</section>
<footer class="fuss">
<span class="regmark"></span>
<span>Klarbild · Druckmodul</span>
</footer>
</main>
<style>
.vs { max-width: 980px; margin: 0 auto; padding: 0 20px 80px; }
.vs h2 { font-size: 1.5rem; letter-spacing: -0.01em; margin: 0 0 14px; }
.vs section { margin: 64px 0; }
.held { padding: 72px 0 8px; }
.held h1 { font-size: clamp(2.1rem, 6vw, 3.4rem); line-height: 1.08; letter-spacing: -0.03em; margin: 14px 0 20px; }
.ohne { color: var(--accent); }
.lead { font-size: clamp(1rem, 2.2vw, 1.15rem); color: var(--ink-soft); max-width: 62ch; margin: 0; line-height: 1.65; }
.held-marken { margin-top: 34px; }
.held-svg { width: 100%; max-width: 420px; height: auto; display: block; }
.zahlen { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px;
background: var(--line); border: 1px solid var(--line); }
.zahl { background: var(--card); padding: 20px 18px; }
.zahl b { display: block; font-size: 2rem; letter-spacing: -0.03em; line-height: 1.1; }
.zahl .mono-label { display: block; margin: 6px 0 8px; }
.zahl p { margin: 0; font-size: 0.82rem; color: var(--ink-soft); line-height: 1.5; }
.problem p { max-width: 68ch; color: var(--ink-soft); margin: 0 0 14px; }
.muster-lead { max-width: 62ch; color: var(--ink-soft); margin: 0 0 24px; }
.muster-halter { display: grid; grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 32px; align-items: start; }
.muster-svg { width: 100%; height: auto; display: block; }
.muster-liste { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px;
font-size: 0.9rem; color: var(--ink-soft); }
.muster-liste li { display: flex; align-items: center; gap: 10px; }
.muster-liste i { width: 22px; height: 22px; border: 1px solid oklch(45% 0.23 267 / 0.45); flex: none; }
.muster-summe { font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.08em;
color: var(--soft); padding-top: 12px; border-top: 1px solid var(--line); }
.muster-summe::before { content: none; }
.karten { display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); gap: 16px; }
.kk { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; }
.kk h3 { font-size: 1.12rem; margin: 8px 0 10px; letter-spacing: -0.01em; }
.kk-text { margin: 0; font-size: 0.92rem; color: var(--ink-soft); line-height: 1.62; }
.kk-beleg { margin: 14px 0 0; padding-top: 12px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 0.72rem; color: var(--accent); line-height: 1.5; }
.schritte { list-style: none; padding: 0; margin: 0; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
.schritte li { border-top: 2px solid var(--ink); padding-top: 12px; }
.schritte b { display: block; margin: 8px 0 6px; }
.schritte p { margin: 0; font-size: 0.88rem; color: var(--ink-soft); line-height: 1.6; }
.technik dl { margin: 0; display: grid; gap: 1px; background: var(--line); border: 1px solid var(--line); }
.technik dl > div { background: var(--card); padding: 14px 18px; display: grid;
grid-template-columns: 130px minmax(0, 1fr); gap: 16px; align-items: baseline; }
.technik dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--soft); }
.technik dd { margin: 0; font-size: 0.9rem; color: var(--ink-soft); line-height: 1.6; }
.technik code { font-family: var(--font-mono); font-size: 0.82em; background: var(--paper);
border: 1px solid var(--line); border-radius: 3px; padding: 1px 5px; }
.offen p { max-width: 68ch; color: var(--ink-soft); margin: 0 0 14px; }
.offen .fein { font-size: 0.85rem; color: var(--soft); }
.fuss { display: flex; align-items: center; gap: 10px; padding-top: 28px;
border-top: 1px solid var(--line); font-family: var(--font-mono); font-size: 11px;
letter-spacing: 0.14em; text-transform: uppercase; color: var(--soft); }
@media (max-width: 860px) { .zahlen { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) {
.vs { padding: 0 16px 64px; }
.held { padding: 40px 0 4px; }
.vs section { margin: 44px 0; }
.technik dl > div { grid-template-columns: 1fr; gap: 6px; }
.muster-halter { grid-template-columns: 1fr; gap: 20px; }
}
</style>
</Base>
+38
View File
@@ -90,5 +90,43 @@ async function maybeComplete(jobId: string): Promise<void> {
await notifyJobDone(Number(jt.telegram_chat_id), jobId);
} catch (e) { console.error('[worker] Telegram-Notify:', e); }
}
// Hinweis per E-Mail — nur, wenn der Auftraggeber das ausdrücklich
// eingeschaltet hat. Fehlschläge bleiben im Log; ein Auftrag gilt nicht
// deshalb als gescheitert, weil eine Benachrichtigung nicht rausging.
try { await mailJobDone(jobId, finalStatus, row.done, row.failed); }
catch (e) { console.error('[worker] Mail-Notify:', e); }
}
}
/** Kurze Rückmeldung an den Auftraggeber, wenn er sie bestellt hat. */
async function mailJobDone(jobId: string, status: string, fertig: number, fehler: number): Promise<void> {
const u = await one<{ email: string | null; display_name: string | null; username: string; private: boolean }>(
`SELECT u.email, u.display_name, u.username, j.private
FROM jobs j JOIN users u ON u.id = j.created_by
WHERE j.id = $1 AND u.notify_jobs = true AND u.email IS NOT NULL`, [jobId]);
// Private Sitzungen hinterlassen bewusst keine Spur — auch keine Mail.
if (!u?.email || u.private) return;
const { trySendMail, mailLayout, mailReady } = await import('./lib/mail');
if (!(await mailReady())) return;
const basis = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
const url = `${basis}/warteschlange`;
const name = u.display_name || u.username;
const gut = status !== 'failed';
const titel = gut ? 'Deine Bilder sind fertig' : 'Ein Auftrag ist fehlgeschlagen';
const kern = gut
? `${fertig} ${fertig === 1 ? 'Bild ist' : 'Bilder sind'} fertig${fehler ? `, ${fehler} nicht` : ''}.`
: `Es hat leider nichts geklappt (${fehler} ${fehler === 1 ? 'Position' : 'Positionen'}). In der Warteschlange steht der Grund.`;
await trySendMail({
to: u.email,
subject: `Klarbild — ${titel.toLowerCase()}`,
text: `Hallo ${name},\n\n${kern}\n\n${url}\n\n`
+ `Diese Hinweise kannst du in Klarbild unter „Mein Konto" wieder abschalten.`,
html: mailLayout(titel, [
`Hallo ${name},`, kern,
`Diese Hinweise kannst du in Klarbild unter „Mein Konto" jederzeit wieder abschalten.`,
], { text: 'In Klarbild ansehen', url }),
});
}
+106
View File
@@ -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');
});
+66
View File
@@ -0,0 +1,66 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { istMail, mailLayout, esc } from '../src/lib/mail.ts';
test('Gültige Adressen werden erkannt', () => {
for (const gut of ['mail@tillheidrich.de', 'a.b+c@sub.beispiel.co.uk', 'x@y.de', 'Oma_1949@web.de']) {
assert.ok(istMail(gut), `fälschlich abgelehnt: ${gut}`);
}
});
test('Ungültige Adressen werden abgelehnt — auch die mit angehängtem Zeilenumbruch', () => {
const schlecht = [
'', ' ', 'keine-adresse', 'a@b', '@b.de', 'a@.de', 'a b@c.de', 'a@b .de',
'a@b.de\nBcc: opfer@example.com', // Kopfzeilen-Einschleusung
'a@b.de\r\nSubject: x',
'a@b.de\n', // JS-„$" passt auch vor dem Zeilenende
'a'.repeat(250) + '@b.de', // zu lang
];
for (const s of schlecht) assert.ok(!istMail(s), `durchgerutscht: ${JSON.stringify(s)}`);
});
test('esc entschärft alles, was HTML zerlegen könnte', () => {
assert.equal(esc('<b>x</b>'), '&lt;b&gt;x&lt;/b&gt;');
assert.equal(esc('a & b'), 'a &amp; b');
assert.equal(esc('sagte "hallo"'), 'sagte &quot;hallo&quot;');
// Reihenfolge: & zuerst, sonst würde &lt; zu &amp;lt;
assert.equal(esc('<&>'), '&lt;&amp;&gt;');
});
test('Die Überschrift der Mail wird immer maskiert', () => {
const html = mailLayout('</h1><script>alert(1)</script>', ['Text']);
assert.ok(!html.includes('<script>'), 'Skript-Tag im HTML gelandet');
assert.match(html, /&lt;script&gt;/);
});
test('Die Adresse des Knopfes wird maskiert', () => {
const html = mailLayout('Titel', ['Text'], { text: 'Ansehen', url: 'https://x.de/"><script>alert(1)</script>' });
assert.ok(!html.includes('<script>'), 'Skript-Tag über die Adresse eingeschleust');
});
test('Absätze dürfen bewusst HTML enthalten — deshalb müssen Aufrufer maskieren', () => {
// Das ist Absicht (für <b>), und genau deshalb ist es die Pflicht der
// Aufrufer, Nutzereingaben vorher durch esc() zu schicken. Der Fehler, den
// das Review gefunden hat, war eine Stelle, die das vergessen hatte.
const html = mailLayout('Titel', ['Ein <b>fettes</b> Wort']);
assert.match(html, /<b>fettes<\/b>/);
// Und mit esc() ist es entschärft:
assert.ok(!mailLayout('T', [esc('<b>x</b>')]).includes('<b>x</b>'));
});
test('Die Mail lädt nichts von fremden Servern nach', () => {
const html = mailLayout('Titel', ['Text'], { text: 'Los', url: 'https://klarbild.example/s/abc' });
// Keine Bilder, keine Schriften, keine Stylesheets von außen — sonst wäre es
// ein Zählpixel und würde in vielen Postfächern ohnehin blockiert.
assert.ok(!/<img/i.test(html), 'Bild im Mail-HTML');
assert.ok(!/<link/i.test(html), 'externe Ressource im Mail-HTML');
assert.ok(!/@import|url\(/i.test(html), 'CSS lädt etwas nach');
// Genau ein Ziel: der übergebene Link.
const ziele = [...html.matchAll(/href="([^"]*)"/g)].map((m) => m[1]);
assert.deepEqual([...new Set(ziele)], ['https://klarbild.example/s/abc']);
});
test('Ohne Knopf entsteht auch kein Link', () => {
const html = mailLayout('Titel', ['Nur Text']);
assert.ok(!html.includes('href='), 'unerwarteter Link');
});
+156
View File
@@ -0,0 +1,156 @@
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'), '');
});
+189 -3
View File
@@ -1,7 +1,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { layout, capacity, cutMarks, effectiveGap, recommendedGap, dpiCheck, type SheetSpec } from '../src/lib/printlayout';
import { parseSizeMm, mmToPx, mmToPt, paperById, photoById, labelMm } from '../src/lib/paper';
import { layout, capacity, cutMarks, effectiveGap, recommendedGap, dpiCheck, type SheetSpec } from '../src/lib/printlayout.ts';
import { parseSizeMm, mmToPx, mmToPt, paperById, photoById, labelMm } from '../src/lib/paper.ts';
import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset } from '../src/lib/printoversize.ts';
const A4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 12, bleedMm: 0, center: true };
@@ -11,7 +12,10 @@ test('parseSizeMm versteht Tills Schreibweisen', () => {
assert.deepEqual(parseSizeMm('35x45mm'), { w: 35, h: 45 });
assert.deepEqual(parseSizeMm('5'), { w: 50, h: 50 });
assert.deepEqual(parseSizeMm('4,5x6'), { w: 45, h: 60 });
assert.deepEqual(parseSizeMm('4:3 / 15'), { w: 112.5, h: 150 });
// a:b ist Breite:Höhe — „4:3" ist quer, „3:4" hochkant.
assert.deepEqual(parseSizeMm('4:3 / 15'), { w: 150, h: 112.5 });
assert.deepEqual(parseSizeMm('3:4 / 15'), { w: 112.5, h: 150 });
assert.deepEqual(parseSizeMm('16:9/30'), { w: 300, h: 168.75 });
assert.equal(parseSizeMm('quatsch'), null);
assert.equal(parseSizeMm('9999x1'), null);
});
@@ -131,4 +135,186 @@ test('Formattabellen sind konsistent', () => {
assert.equal(photoById('S12x15')!.h, 150);
assert.equal(labelMm(35, 45), '35 × 45 mm');
assert.equal(labelMm(120, 150), '12 × 15 cm');
// Nachkommastellen dürfen nicht wegfallen — das Label steht in Fußzeile und Dateiname.
assert.equal(labelMm(112.5, 150), '11,25 × 15 cm');
assert.equal(labelMm(105, 148), '10,5 × 14,8 cm');
});
test('Die gewählte Ausrichtung bleibt, wenn sie nicht mehr Bogen kostet', () => {
// Ein einzelnes Passbild darf nicht quer gelegt werden, nur weil hochkant
// mehr auf den Bogen passen würden.
const res = layout([{ id: 'p', wMm: 35, hMm: 45, count: 1, allowRotate: true }], A4);
assert.equal(res.pages[0].placements[0].rotated, false);
assert.equal(res.pages[0].placements[0].w, 35);
// 25 × 15 cm auf A4 hoch: quer ist es zu breit (250 > 200 mm Nutzbreite),
// gedreht passt es — dann muss gedreht werden.
const gross = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: true }], A4);
assert.equal(gross.pages.length, 1);
assert.equal(gross.pages[0].placements[0].rotated, true);
assert.equal(gross.pages[0].placements[0].w, 150);
// Ohne Dreherlaubnis bleibt es liegen und wird als „passt nicht" gemeldet.
const stur = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: false }], A4);
assert.equal(stur.pages.length, 0);
assert.equal(stur.unplaced.length, 1);
});
/* --- Regressionen aus dem Code-Review vom 18.08.2026 ------------------- */
test('Ohne Angabe ist Drehen erlaubt (allowRotate undefined)', () => {
// Vorher landete das Bild in „unplaced", weil !!undefined === false war.
const res = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1 }], A4);
assert.equal(res.unplaced.length, 0);
assert.equal(res.pages[0].placements[0].rotated, true);
});
test('Auch bei vielen Formaten bekommt jedes eine passende Ausrichtung', () => {
// Notbremse „tooBig" (>5 Formate) darf kein Format still fallen lassen.
const specs = Array.from({ length: 5 }, (_, i) => ({ id: `k${i}`, wMm: 20, hMm: 30, count: 1, allowRotate: true }));
specs.push({ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: true });
const res = layout(specs, { ...A4, gapMm: 4 });
const placed = res.pages.flatMap((p) => p.placements).map((p) => p.specId);
assert.ok(placed.includes('g'), 'das breite Bild muss gedreht platziert werden');
assert.equal(res.unplaced.length, 0);
});
test('unplaced nennt das Bild, das wirklich fehlt', () => {
const specs = [
{ id: 'klein', wMm: 20, hMm: 30, count: 2, allowRotate: false },
{ id: 'riesig', wMm: 250, hMm: 250, count: 3, allowRotate: false },
];
const res = layout(specs, A4);
assert.equal(res.unplaced.length, 1);
assert.equal(res.unplaced[0].specId, 'riesig');
assert.equal(res.unplaced[0].count, 3);
});
test('Eckmarken bleiben auch mit Beschnittzugabe außerhalb des gedruckten Bereichs', () => {
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 10, gapMm: 20, bleedMm: 5, center: true };
const res = layout([{ id: 'a', wMm: 60, hMm: 80, count: 2, allowRotate: false }], sheet);
const pg = res.pages[0];
// Wunschversatz 3 mm ist zu klein für 5 mm Beschnitt — muss angehoben werden.
const lines = cutMarks(pg, sheet, { mode: 'corner', lengthMm: 4, offsetMm: 3, bleedMm: 5 });
assert.ok(lines.length > 0);
for (const l of lines) for (const p of pg.placements) {
const inBleed = (x: number, y: number) =>
x > p.x - 5 + 1e-6 && x < p.x + p.w + 5 - 1e-6 && y > p.y - 5 + 1e-6 && y < p.y + p.h + 5 - 1e-6;
assert.ok(!inBleed(l.x1, l.y1) && !inBleed(l.x2, l.y2), 'Marke liegt im gedruckten Beschnitt');
}
});
test('capacity deckelt nicht mehr bei 200', () => {
const a2: SheetSpec = { wMm: 420, hMm: 594, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
const n = capacity({ id: 'x', wMm: 20, hMm: 30, allowRotate: false }, a2);
assert.equal(n, 20 * 19);
});
test('Entartete Mengen bringen den Packer nicht zum Stehen', () => {
const t0 = process.hrtime.bigint();
const specs = Array.from({ length: 40 }, (_, i) => ({ id: `s${i}`, wMm: 5 + (i % 5), hMm: 6 + (i % 4), count: 12, allowRotate: true }));
const res = layout(specs, { wMm: 2000, hMm: 2000, marginMm: 0, gapMm: 0, bleedMm: 0, center: true });
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
assert.ok(res.pages.length >= 1);
assert.ok(ms < 4000, `Packen dauerte ${Math.round(ms)} ms — zu lange für den Serverprozess`);
});
/* --- Übergroße Bilder (20 × 30 auf A4 & Posterdruck) ------------------- */
test('20 × 30 cm passt nicht auf A4 — aber nur um 3 mm', () => {
const randlos: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
assert.equal(fitsOnSheet(200, 300, randlos, false), false);
const info = overflowPlacement('a', 1, 200, 300, randlos, false);
assert.equal(info.rotated, false);
assert.equal(info.lostLeft, 0);
assert.equal(info.lostRight, 0);
assert.equal(info.lostTop, 1.5); // 300 − 297 = 3 mm, mittig verteilt
assert.equal(info.lostBottom, 1.5);
assert.equal(info.lostMm, 3);
// mittig: x = (210 − 200) / 2
assert.equal(info.placement.x, 5);
assert.equal(info.placement.y, -1.5);
});
test('Überstehen dreht, wenn dabei weniger verloren geht', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
// 297 × 200 quer: ungedreht ragt es 87 mm über die Breite, gedreht passt es fast.
const info = overflowPlacement('a', 1, 297, 200, a4, true);
assert.equal(info.rotated, true);
assert.equal(info.lostMm, 0);
});
test('Posterdruck: 30 × 40 cm auf A4 ergibt 2 × 2 Blätter mit Klebefalz', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
const tp = tilePlan(300, 400, a4, 10);
assert.equal(tp.cols, 2);
assert.equal(tp.rows, 2);
assert.equal(tp.tiles.length, 4);
assert.equal(tp.overlapMm, 10);
// Erste Kachel füllt die Nutzfläche, letzte trägt den Rest.
assert.equal(tp.tiles[0].wMm, 200);
assert.equal(tp.tiles[0].hMm, 287);
assert.ok(tp.tiles[0].glueRight && tp.tiles[0].glueBottom);
assert.equal(tp.tiles[3].glueRight, false);
assert.equal(tp.tiles[3].glueBottom, false);
// Die Kacheln decken das ganze Bild ab (rechter/unterer Rand erreicht).
const maxX = Math.max(...tp.tiles.map((t) => t.xMm + t.wMm));
const maxY = Math.max(...tp.tiles.map((t) => t.yMm + t.hMm));
assert.ok(maxX >= 300 - 1e-6, `rechts fehlt etwas: ${maxX}`);
assert.ok(maxY >= 400 - 1e-6, `unten fehlt etwas: ${maxY}`);
});
test('Kacheln überlappen sich wirklich um den Klebefalz', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
const tp = tilePlan(300, 400, a4, 10);
const links = tp.tiles.find((t) => t.col === 0 && t.row === 0)!;
const rechts = tp.tiles.find((t) => t.col === 1 && t.row === 0)!;
const ueberlappung = (links.xMm + links.wMm) - rechts.xMm;
assert.equal(Math.round(ueberlappung * 10) / 10, 10);
});
test('Kachel-Ausschnitt bleibt im Zuschnitt des Nutzers', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
const tp = tilePlan(300, 400, a4, 10);
const crop = { x: 0.1, y: 0.2, w: 0.6, h: 0.5 };
for (const t of tp.tiles) {
const c = tileCrop(crop, t, 300, 400);
assert.ok(c.x >= crop.x - 1e-9 && c.x + c.w <= crop.x + crop.w + 1e-9, 'waagerecht ausgebrochen');
assert.ok(c.y >= crop.y - 1e-9 && c.y + c.h <= crop.y + crop.h + 1e-9, 'senkrecht ausgebrochen');
}
// Die erste Kachel beginnt exakt am Zuschnitt-Anfang.
const erste = tileCrop(crop, tp.tiles[0], 300, 400);
assert.equal(Math.round(erste.x * 1e6) / 1e6, crop.x);
assert.equal(Math.round(erste.y * 1e6) / 1e6, crop.y);
});
test('Ein Bild, das passt, gilt nicht als übergroß', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
assert.equal(fitsOnSheet(130, 180, a4, false), true);
assert.equal(fitsOnSheet(250, 150, a4, true), true, 'gedreht passt es');
assert.equal(fitsOnSheet(250, 150, a4, false), false, 'ungedreht nicht');
});
test('Posterdruck lässt Platz für die Blattnummer, ohne unten herauszuragen', () => {
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, bleedMm: 0 };
// 5 mm Rand reicht nicht für die 8-pt-Zeile — die Kachel rückt 1 mm nach unten.
assert.equal(noteInset(sheet), 1);
const ts = tileSheet(sheet);
const tp = tilePlan(300, 400, ts, 10);
const oben = (sheet.marginMm || 0) + noteInset(sheet);
for (const t of tp.tiles) {
assert.ok(oben + t.hMm <= sheet.hMm + 1e-6,
`Kachel ${t.row}/${t.col} ragt unten heraus: ${oben + t.hMm} > ${sheet.hMm}`);
assert.ok((sheet.marginMm || 0) + t.wMm <= sheet.wMm + 1e-6);
}
// Die Kacheln decken das Endmaß lückenlos ab.
const rechts = Math.max(...tp.tiles.map((t) => t.xMm + t.wMm));
const unten = Math.max(...tp.tiles.map((t) => t.yMm + t.hMm));
assert.ok(rechts >= 300 - 1e-6 && unten >= 400 - 1e-6, `Abdeckung ${rechts} × ${unten}`);
});
test('Großzügiger Rand verschiebt die Kachel nicht', () => {
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 10, bleedMm: 0 };
assert.equal(noteInset(sheet), 0);
assert.deepEqual(tileSheet(sheet), sheet);
});
+103
View File
@@ -0,0 +1,103 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildSheetFilename, buildSingleFilename, paperToken, nameAusKopf, KLARBILD_NAME } from '../src/lib/naming.ts';
/**
* Namen für Druckdateien. Der Grund für diese Tests ist derselbe wie im August
* bei den Bibliotheksbildern: Zwei Bogen desselben Tages hießen gleich, und wer
* beide in denselben Ordner legte, verlor den ersten.
*/
const AM = new Date('2026-09-01T12:32:07Z'); // 14:32:07 Berliner Zeit
test('Ein Bogen bekommt Zeitstempel, Inhalt, Papier und Kürzel', () => {
const n = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: '4eca6ec4-…', at: AM });
assert.equal(n, '2026-09-01_143207_druck-9-teile_a4_4eca.pdf');
});
test('Zwei Bogen derselben Sekunde können sich nicht überschreiben', () => {
const a = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: 'aaaa1111', at: AM });
const b = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: 'bbbb2222', at: AM });
assert.notEqual(a, b, 'gleiche Sekunde, gleiche Einstellung — trotzdem verschieden');
});
test('Einer und mehrere — Einzahl, Mehrzahl, Bogenzahl', () => {
const eins = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 1, pages: 1, runId: 'ab12', at: AM });
assert.ok(eins.includes('druck-1-teil_'), eins);
const viele = buildSheetFilename({ wMm: 329, hMm: 483, paperId: 'A3PLUS', pieces: 24, pages: 3, runId: 'ffff', at: AM });
assert.equal(viele, '2026-09-01_143207_druck-24-teile-3-bogen_a3plus_ffff.pdf');
});
test('Eigener Name hat Vorrang und wird entschärft', () => {
const n = buildSheetFilename({ name: 'Kita Müller / Satz 2', wMm: 210, hMm: 297, paperId: 'A4', pieces: 4, pages: 1, runId: 'cccc', at: AM });
assert.equal(n, '2026-09-01_143207_kita-mueller-satz-2_a4_cccc.pdf');
});
test('Unbekanntes Papier wird zum Maß', () => {
assert.equal(paperToken(null, 100, 150), '100x150');
assert.equal(paperToken('', 210, 297), '210x297');
assert.equal(paperToken('A4', 210, 297), 'a4');
// Ein sehr langer Bezeichner würde den Namen aufblähen — dann lieber das Maß.
assert.equal(paperToken('ein-ellenlanger-bezeichner', 210, 297), '210x297');
});
test('Einzelbilder erben das Motiv der Quelle', () => {
const n = buildSingleFilename({ sourceName: '2026-08-31_221842_kombiniert_30x40_25b3.png', wMm: 130, hMm: 180, ext: 'jpg', id: '25b34b55-e816', at: AM });
assert.equal(n, '2026-09-01_143207_kombiniert_130x180_25b3.jpg');
});
test('Ohne brauchbares Motiv steht „klarbild" da, nicht nichts', () => {
assert.equal(buildSingleFilename({ sourceName: null, wMm: 35, hMm: 45, ext: 'jpg', id: 'aaaa', at: AM }),
'2026-09-01_143207_klarbild_35x45_aaaa.jpg');
assert.equal(buildSingleFilename({ sourceName: 'IMG_1234.HEIC', wMm: 35, hMm: 45, ext: 'jpg', id: 'bbbb', at: AM }),
'2026-09-01_143207_img-1234_35x45_bbbb.jpg');
});
test('Krumme Maße verlieren den Punkt — sonst sähe er wie eine Endung aus', () => {
const n = buildSingleFilename({ sourceName: 'x.png', wMm: 150, hMm: 112.5, ext: 'png', id: 'dddd', at: AM });
assert.equal(n, '2026-09-01_143207_x_150x112-5_dddd.png');
assert.equal(n.split('.').length, 2, 'genau eine Endung');
});
test('Druckdateien folgen demselben Muster wie Bibliotheksergebnisse', () => {
for (const n of [
buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: '4eca', at: AM }),
buildSingleFilename({ sourceName: 'lea.png', wMm: 130, hMm: 180, ext: 'jpg', id: '25b3', at: AM }),
]) {
assert.ok(KLARBILD_NAME.test(n.replace(/\.[^.]+$/, '')), `passt nicht ins Schema: ${n}`);
}
});
test('Namen taugen für jedes Dateisystem', () => {
for (const name of ['Grüße/Köln', 'a\\b:c*d?e"f<g>h|i', '../../..', 'x'.repeat(200)]) {
const n = buildSheetFilename({ name, wMm: 210, hMm: 297, paperId: 'A4', pieces: 2, pages: 1, runId: 'eeee', at: AM });
assert.ok(/^[a-z0-9_.-]+$/.test(n), `unerwartetes Zeichen: ${n}`);
assert.ok(n.endsWith('.pdf'));
}
});
test('Der Name kommt vom Server, nicht aus dem Browser', () => {
assert.equal(nameAusKopf('attachment; filename="2026-09-01_143207_druck-9-teile_a4_4eca.pdf"', 'x.pdf'),
'2026-09-01_143207_druck-9-teile_a4_4eca.pdf');
assert.equal(nameAusKopf("attachment; filename*=UTF-8''gr%C3%BC%C3%9Fe.pdf", 'x.pdf'), 'grüße.pdf');
assert.equal(nameAusKopf(null, 'x.pdf'), 'x.pdf');
assert.equal(nameAusKopf('attachment', 'x.pdf'), 'x.pdf');
});
test('Ein Name aus dem Kopf kann keinen Pfad einschleusen', () => {
for (const boese of ['attachment; filename="../../etc/passwd"', 'attachment; filename="a/b.pdf"',
'attachment; filename="a\\\\b.pdf"', 'attachment; filename=".hidden"']) {
const n = nameAusKopf(boese, 'x.pdf');
assert.ok(!n.includes('/') && !n.includes('\\') && !n.startsWith('.'), `Pfad überlebt: ${n}`);
}
});
test('ZIPs aus der Bibliothek heißen nicht alle gleich', async () => {
const { buildAuswahlZipName } = await import('../src/lib/naming.ts');
const a = buildAuswahlZipName(12, AM);
assert.match(a, /^2026-09-01_143207_auswahl-12-bilder_[0-9a-f]{4}\.zip$/);
assert.equal(buildAuswahlZipName(1, AM).includes('auswahl-1-bild_'), true);
// Zwei Auswahlen derselben Sekunde: das Kürzel unterscheidet sie.
const paare = new Set(Array.from({ length: 50 }, () => buildAuswahlZipName(12, AM)));
assert.ok(paare.size > 40, `zu viele gleiche Namen: ${paare.size}/50`);
});
+140
View File
@@ -0,0 +1,140 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import sharp from 'sharp';
import { renderCell, coverCrop, containCrop } from '../src/lib/printrender.ts';
import { mmToPx } from '../src/lib/paper.ts';
/** Quergrafik 3:2 mit klar erkennbaren Rändern. */
async function testImage(w = 1200, h = 800): Promise<Buffer> {
return sharp({ create: { width: w, height: h, channels: 3, background: { r: 30, g: 90, b: 220 } } })
.png().toBuffer();
}
test('Zuschneiden liefert exakt das Zielmaß — auch mit Beschnittzugabe', async () => {
const img = await testImage();
for (const bleed of [0, 3]) {
const out = await renderCell(img, null, 35, 45, 300, { ext: 'png', fit: 'cover', bleedMm: bleed });
assert.equal(out.width, mmToPx(35 + 2 * bleed, 300));
assert.equal(out.height, mmToPx(45 + 2 * bleed, 300));
}
});
test('Einpassen liefert exakt das Zielmaß und legt die Randfarbe an', async () => {
const img = await testImage();
for (const bleed of [0, 3]) {
const out = await renderCell(img, null, 35, 45, 300, { ext: 'png', fit: 'contain', bleedMm: bleed, background: '#000000' });
assert.equal(out.width, mmToPx(35 + 2 * bleed, 300), 'Breite');
assert.equal(out.height, mmToPx(45 + 2 * bleed, 300), 'Höhe');
const { data, info } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
const at = (x: number, y: number) => {
const i = (y * info.width + x) * info.channels;
return [data[i], data[i + 1], data[i + 2]];
};
const oben = at(Math.round(info.width / 2), 3);
const mitte = at(Math.round(info.width / 2), Math.round(info.height / 2));
assert.deepEqual(oben, [0, 0, 0], 'oben muss Randfarbe sein');
assert.ok(mitte[2] > 150, 'in der Mitte muss das Bild stehen');
}
});
test('Zuschneiden füllt das Format vollständig aus (kein Rand)', async () => {
const img = await testImage();
const out = await renderCell(img, null, 35, 45, 300, { ext: 'png', fit: 'cover', background: '#000000' });
const { data, info } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
const at = (x: number, y: number) => {
const i = (y * info.width + x) * info.channels;
return [data[i], data[i + 1], data[i + 2]];
};
for (const [x, y] of [[3, 3], [info.width - 4, 3], [3, info.height - 4], [info.width - 4, info.height - 4]])
assert.ok(at(x, y)[2] > 150, 'auch die Ecken tragen Bild');
});
test('Querformat-Ziel aus Hochformat-Quelle bleibt maßhaltig', async () => {
const img = await testImage(800, 1200);
const out = await renderCell(img, null, 150, 100, 300, { ext: 'jpg', fit: 'contain', background: '#ffffff' });
assert.equal(out.width, mmToPx(150, 300));
assert.equal(out.height, mmToPx(100, 300));
});
test('Gedrehte Fassung tauscht Breite und Höhe', async () => {
const img = await testImage();
const out = await renderCell(img, null, 90, 130, 300, { ext: 'png', rotate: true });
assert.equal(out.width, mmToPx(130, 300));
assert.equal(out.height, mmToPx(90, 300));
});
test('cover- und contain-Ausschnitt rechnen gegensätzlich', () => {
const a = 35 / 45; // Hochformat-Ziel
const cov = coverCrop(1200, 800, a); // Querbild
const con = containCrop(1200, 800, a);
assert.ok(cov.w < 1 && cov.h === 1, 'cover schneidet seitlich ab');
assert.ok(con.h > 1 && con.w === 1, 'contain ragt oben/unten hinaus');
assert.ok(con.y < 0, 'der Überstand liegt außerhalb des Bildes');
});
test('Ein Ausschnitt wird exakt übernommen (kein stilles Nachzentrieren)', async () => {
// Linke obere Ecke rot einfärben, dann genau diese Ecke zuschneiden.
const base = await sharp({ create: { width: 1000, height: 1000, channels: 3, background: { r: 0, g: 0, b: 255 } } })
.composite([{ input: await sharp({ create: { width: 200, height: 200, channels: 3, background: { r: 255, g: 0, b: 0 } } }).png().toBuffer(), left: 0, top: 0 }])
.png().toBuffer();
const out = await renderCell(base, { x: 0, y: 0, w: 0.2, h: 0.2 }, 50, 50, 300, { ext: 'png' });
const { data } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
assert.ok(data[0] > 200 && data[2] < 60, 'der gewählte Ausschnitt muss rot sein');
});
/* --- Regressionen aus dem Code-Review vom 18.08.2026 ------------------- */
test('EXIF-Drehung wird angewendet — Handyfotos kommen nicht quer heraus', async () => {
const quer = await sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 0, g: 0, b: 255 } } })
.composite([{ input: await sharp({ create: { width: 80, height: 60, channels: 3, background: { r: 255, g: 0, b: 0 } } }).png().toBuffer(), left: 0, top: 0 }])
.jpeg().toBuffer();
const mitExif = await sharp(quer).withMetadata({ orientation: 6 }).jpeg().toBuffer();
const auto = await sharp(await sharp(mitExif).rotate().toBuffer()).metadata();
const out = await renderCell(mitExif, null, 90, 130, 300, { ext: 'png' });
// renderCell muss mit dem Raster rechnen, das der Browser zeigt.
assert.deepEqual(out.srcPx, [auto.width, auto.height]);
});
test('Extreme Ausschnitte sprengen den Speicher nicht und liefern das Zielmaß', async () => {
const panorama = await sharp({ create: { width: 6000, height: 1500, channels: 3, background: { r: 20, g: 80, b: 200 } } }).jpeg().toBuffer();
for (const [w, h] of [[105, 148], [5, 400]] as [number, number][]) {
const out = await renderCell(panorama, null, w, h, 300, { ext: 'jpg', fit: 'contain', background: '#000000' });
assert.equal(out.width, mmToPx(w, 300));
assert.equal(out.height, mmToPx(h, 300));
}
// Ausschnitt weit außerhalb des Bildes: nur Randfarbe drumherum, kein Riesenpuffer.
const weit = await renderCell(panorama, { x: -3.5, y: -3.5, w: 8, h: 8 }, 35, 45, 300, { ext: 'png', fit: 'contain', background: '#000000' });
assert.equal(weit.width, mmToPx(35, 300));
assert.equal(weit.height, mmToPx(45, 300));
});
test('Gedrehte Fassung mit Beschnittzugabe bleibt maßhaltig', async () => {
const img = await testImage();
const out = await renderCell(img, null, 90, 130, 300, { ext: 'png', rotate: true, bleedMm: 3 });
assert.equal(out.width, mmToPx(130 + 6, 300));
assert.equal(out.height, mmToPx(90 + 6, 300));
});
test('Beschnittzugabe vergrößert das Feld, ohne den Ausschnitt zu verschieben', async () => {
// Farbverlauf, damit jede Position eine eigene Farbe hat.
const w = 1000, h = 1000;
const px = Buffer.alloc(w * h * 3);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const i = (y * w + x) * 3;
px[i] = Math.round((x / w) * 255); px[i + 1] = Math.round((y / h) * 255); px[i + 2] = 128;
}
const img = await sharp(px, { raw: { width: w, height: h, channels: 3 } }).png().toBuffer();
const crop = { x: 0.2, y: 0.3, w: 0.5, h: 0.4 };
const mitte = async (bleed: number) => {
const out = await renderCell(img, crop, 50, 40, 300, { ext: 'png', bleedMm: bleed });
const { data, info } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
// Mitte der Trimmbox = Mitte des Bildes (der Beschnitt liegt symmetrisch außen).
const i = (Math.round(info.height / 2) * info.width + Math.round(info.width / 2)) * info.channels;
return [data[i], data[i + 1]];
};
const a = await mitte(0), b = await mitte(5);
assert.ok(Math.abs(a[0] - b[0]) <= 2 && Math.abs(a[1] - b[1]) <= 2,
`Trimmbox-Mitte wandert mit Beschnitt: ${a} vs ${b}`);
});
+43
View File
@@ -0,0 +1,43 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { kurzfassung } from '../src/lib/printruns.ts';
/**
* Die Kurzfassung steht in der Verlaufsliste. Sie kommt zellenweise herein —
* zwei Zellen mit demselben Maß dürfen dort nicht zweimal auftauchen.
*/
test('Einzelnes Maß bleibt, wie es ist', () => {
assert.equal(kurzfassung(['13 × 18 cm']), '13 × 18 cm');
});
test('Gleiche Maße werden zusammengezogen', () => {
assert.equal(kurzfassung(['10 × 15 cm', '10 × 15 cm']), '10 × 15 cm ×2');
});
test('Vorhandene Stückzahlen werden addiert, nicht angehängt', () => {
assert.equal(kurzfassung(['35 × 45 mm ×8', '35 × 45 mm ×4']), '35 × 45 mm ×12');
assert.equal(kurzfassung(['10 × 15 cm ×3', '10 × 15 cm']), '10 × 15 cm ×4');
});
test('Verschiedene Maße bleiben nebeneinander, in ihrer Reihenfolge', () => {
assert.equal(kurzfassung(['13 × 18 cm', '35 × 45 mm ×8']), '13 × 18 cm, 35 × 45 mm ×8');
});
test('Zusätze wie (Poster) trennen die Gruppen', () => {
assert.equal(kurzfassung(['30 × 40 cm ×2 (Poster)', '30 × 40 cm ×1 (Poster)']),
'30 × 40 cm ×3 (Poster)');
assert.equal(kurzfassung(['30 × 40 cm (Poster)', '30 × 40 cm (überstehend)']),
'30 × 40 cm (Poster), 30 × 40 cm (überstehend)');
});
test('Leere Liste ergibt etwas Lesbares', () => {
assert.equal(kurzfassung([]), 'Druckbogen');
});
test('Sehr lange Aufzählungen werden gekürzt', () => {
const viele = Array.from({ length: 40 }, (_, i) => `${i + 10} × ${i + 15} cm`);
const s = kurzfassung(viele);
assert.ok(s.length <= 118, `zu lang: ${s.length}`);
assert.ok(s.endsWith('…'));
});
+118
View File
@@ -0,0 +1,118 @@
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,
stripXmp,
} 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 &amp; 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);
});
test('Die APP1-Kennung endet auf ein echtes Nullbyte (sonst erkennt es kein Werkzeug)', async () => {
const jpg = embedProvenance(await bild().jpeg().toBuffer(), { sourceType: TRAINED_ALGORITHMIC_MEDIA });
const i = jpg.indexOf('http://ns.adobe.com/xap/1.0/');
assert.ok(i > 0, 'Kennung nicht gefunden');
assert.equal(jpg[i + 28], 0x00, 'Kennung endet nicht auf 0x00');
});
test('Zweimal kennzeichnen ersetzt die alte Angabe, statt eine zweite anzuhängen', async () => {
for (const [name, machen] of [
['PNG', async () => bild().png().toBuffer()],
['JPEG', async () => bild().jpeg().toBuffer()],
] as const) {
const roh = await machen();
const alt = embedProvenance(roh, { sourceType: TRAINED_ALGORITHMIC_MEDIA, model: 'altes/modell' });
const neu = embedProvenance(alt, { sourceType: TRAINED_ALGORITHMIC_MEDIA, model: 'neues/modell' });
// Genau **ein** Paket, und zwar das neue.
const pakete = neu.toString('latin1').split('<x:xmpmeta').length - 1;
assert.equal(pakete, 1, `${name}: ${pakete} XMP-Pakete statt einem`);
assert.match(readXmp(neu)!, /neues\/modell/, `${name}: falsches Modell`);
assert.ok(!readXmp(neu)!.includes('altes/modell'), `${name}: altes Modell noch da`);
// Und das Bild ist weiterhin heil.
const m = await sharp(neu).metadata();
assert.equal(m.width, 240, `${name}: Maße verändert`);
assert.ok((await sharp(roh).raw().toBuffer()).equals(await sharp(neu).raw().toBuffer()),
`${name}: Bildinhalt verändert`);
}
});
test('stripXmp lässt Dateien ohne Kennzeichnung unangetastet', async () => {
const png = await bild().png().toBuffer();
assert.ok(stripXmp(png).equals(png));
const jpg = await bild().jpeg().toBuffer();
assert.ok(stripXmp(jpg).equals(jpg));
});
+102
View File
@@ -0,0 +1,102 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { neuerSlug, tageLesen, geraet, gleich, nachweisBauen, nachweisGilt, nachweisAusCookie, nachweisName,
MAX_TITEL, MAX_NOTIZ } from '../src/lib/shares.ts';
process.env.SESSION_SECRET ||= 'test-geheimnis-fuer-die-tests';
test('Slugs sind lang genug, gut vorlesbar und verwechslungsfrei', () => {
const gesehen = new Set<string>();
for (let i = 0; i < 5000; i++) {
const s = neuerSlug();
assert.equal(s.length, 12, `falsche Länge: ${s}`);
// Kein i, l, 0, O, 1 — sonst verhört man sich am Telefon.
assert.match(s, /^[abcdefghjkmnpqrstuvwxyz23456789]+$/, `verbotenes Zeichen: ${s}`);
assert.ok(!gesehen.has(s), `Slug doppelt nach ${i} Zügen: ${s}`);
gesehen.add(s);
}
});
test('Die Zeichen sind gleichmäßig verteilt — sonst schrumpft der Suchraum', () => {
const zaehler = new Map<string, number>();
for (let i = 0; i < 4000; i++) for (const c of neuerSlug()) zaehler.set(c, (zaehler.get(c) || 0) + 1);
const werte = [...zaehler.values()];
const schnitt = werte.reduce((a, b) => a + b, 0) / werte.length;
for (const [c, n] of zaehler) {
assert.ok(Math.abs(n - schnitt) / schnitt < 0.25, `${c} kommt ${n}× statt ~${Math.round(schnitt)}× vor`);
}
});
test('Unsinnige Gültigkeiten werden abgelehnt, nicht als „unbegrenzt" gedeutet', () => {
// Genau der Fehler, den das Review gefunden hat: Number('abc') ist NaN,
// NaN > 0 ist false — und der Link galt dann für immer.
for (const schlecht of ['abc', -5, 1e9, Infinity, NaN, {}, [], 'morgen']) {
assert.throws(() => tageLesen(schlecht, 30), /Gültigkeit/, `durchgerutscht: ${JSON.stringify(schlecht)}`);
}
});
test('Sinnvolle Gültigkeiten kommen durch, Voreinstellung greift bei Weglassen', () => {
assert.equal(tageLesen(7, 30), 7);
assert.equal(tageLesen('30', 30), 30);
assert.equal(tageLesen(0, 30), 0); // 0 = bewusst unbegrenzt
assert.equal(tageLesen(3650, 30), 3650);
assert.equal(tageLesen(undefined, 30), 30);
assert.equal(tageLesen(null, 30), 30);
assert.equal(tageLesen('', 30), 30);
assert.equal(tageLesen(7.9, 30), 7); // abgeschnitten, nicht gerundet
});
test('Aus dem User-Agent bleibt nur Browser und System übrig', () => {
const f = [
['Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1', 'Safari · iPhone'],
['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36', 'Chrome · Windows'],
['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/121.0', 'Firefox · Mac'],
['Mozilla/5.0 (Linux; Android 14) Chrome/120.0 Mobile Safari/537.36', 'Chrome · Android'],
] as const;
for (const [ua, soll] of f) assert.equal(geraet(ua), soll, ua.slice(0, 40));
assert.equal(geraet(''), null);
assert.equal(geraet(null), null);
// Der Fingerabdruck darf nicht mehr hergeben als Browser und System.
for (const [ua] of f) assert.ok((geraet(ua) || '').length < 25, 'zu viel Detail behalten');
});
test('Der Nachweis fürs Zugangswort gilt nur für seinen Link', () => {
const a = 'aaaaaaaa-0000-0000-0000-000000000001';
const b = 'bbbbbbbb-0000-0000-0000-000000000002';
const n = nachweisBauen(a);
assert.ok(nachweisGilt(a, n), 'eigener Link abgelehnt');
assert.ok(!nachweisGilt(b, n), 'Nachweis gilt für einen fremden Link');
assert.ok(!nachweisGilt(a, 'quatsch'), 'Unsinn akzeptiert');
assert.ok(!nachweisGilt(a, ''), 'Leerwert akzeptiert');
assert.ok(!nachweisGilt(a, null), 'null akzeptiert');
});
test('Ein abgelaufener oder gefälschter Nachweis wird abgelehnt', () => {
const id = 'aaaaaaaa-0000-0000-0000-000000000001';
const [, sig] = nachweisBauen(id).split('.');
const vergangen = Math.floor(Date.now() / 1000) - 10;
assert.ok(!nachweisGilt(id, `${vergangen}.${sig}`), 'abgelaufener Nachweis kam durch');
// Ablaufzeit hochsetzen, ohne die Signatur zu kennen → muss scheitern.
const zukunft = Math.floor(Date.now() / 1000) + 999999;
assert.ok(!nachweisGilt(id, `${zukunft}.${sig}`), 'manipulierte Laufzeit kam durch');
});
test('Der Nachweis wird aus dem richtigen Cookie gelesen', () => {
const kopf = `andere=x; ${nachweisName('abc123def456')}=wert.sig; noch=y`;
assert.equal(nachweisAusCookie(kopf, 'abc123def456'), 'wert.sig');
assert.equal(nachweisAusCookie(kopf, 'einanderer12'), null);
assert.equal(nachweisAusCookie(null, 'abc123def456'), null);
});
test('gleich() vergleicht zeitkonstant und stolpert nicht über Längen', () => {
assert.ok(gleich('abc', 'abc'));
assert.ok(!gleich('abc', 'abd'));
assert.ok(!gleich('abc', 'abcd'));
assert.ok(!gleich('', 'x'));
assert.ok(gleich('', ''));
});
test('Die Längengrenzen sind gesetzt', () => {
assert.ok(MAX_TITEL > 0 && MAX_TITEL <= 500);
assert.ok(MAX_NOTIZ > MAX_TITEL);
});
Binary file not shown.