Commit Graph

24 Commits

Author SHA1 Message Date
Paul Nothaft afaa00f428 chore: remove issue screenshots from the source tree (#1259)
Two PR screenshots landed at the repo root in #1241 and have been shipping as
part of the source tree since. Nothing references them.

Screenshots belong on a `screenshots/*` branch — that is what those branches
are for, and how every other UI change here has attached its evidence. Added
ignore rules so the next one cannot follow the same path, anchored with a
leading slash so docs/ keeps its own five images and test-assets/ keeps the
fixtures the e2e specs load.

Verified no other tracked file matches the new patterns.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-01 09:34:40 +02:00
Paul Nothaft b69dd134d0 feat(faces): People in this gallery — face recognition via an optional ML sidecar (#1074) (#1075)
* feat(ml): optional face-detection sidecar, opt-in and inert by default (#1074)

First of four PRs for "People in this gallery". This one ships only the
sidecar, its wiring and its CI — no schema, no backend code, no UI. Nothing
in PicPeak calls it yet.

picpeak-ml is a single FastAPI + onnxruntime container: three endpoints
(/health, /info, /faces), no database, no volumes, no egress, no model
download at runtime. Clustering, person identity and every privacy decision
stay in the backend where the data already lives.

Models are YuNet (detection) + FaceNet-512 (embedding), both MIT, both
pinned by URL and SHA-256 and verified at build time. The licence analysis
is in ml/LICENSES.md: the more accurate InsightFace weights are
non-commercial-only and PicPeak's users are working photographers, so they
are never baked into an image we publish.

Two things worth review attention:

- Alignment uses a least-squares similarity transform (Umeyama), NOT
  cv2.estimateAffinePartial2D. RANSAC and LMEDS exist to reject outliers
  among many correspondences; given five landmarks and no outliers they fit
  a three-point subset exactly and let the rest drift. Measured on a real
  off-frontal portrait: eyes and nose pinned to 0.11px, mouth corners
  11.8px out on a 160px crop. Umeyama distributes it (max 6.5px, rms 5.1 vs
  7.4). The failure mode is silent — a bad warp still yields 512 confident
  floats — so tests/test_pipeline.py pins it numerically.

- FACENET_ONNX_URL has no default and the build fails loudly without it.
  deepface distributes FaceNet-512 as Keras .h5 only, so the ONNX is
  produced once by tools/convert_facenet.py and published as a release
  asset. Converting inside the build would drag TensorFlow through both
  architecture legs of every build to produce a byte-identical file. The CI
  jobs are gated on the FACENET_ONNX_URL repository variable and skip
  cleanly until it is set.

Off by default, twice over: the sidecar is behind the `faces` compose
profile, and the backend will gate on a `faces` feature flag that defaults
to false. FACE_ML_URL defaults to http://picpeak-ml:8000 so the standard
deployment needs no configuration — nothing dials that host while the flag
is off, which is why a non-resolving default is harmless.

Verified: 27 pytest tests green; YuNet loads and detects against a real
portrait with its landmark order matching the alignment template
index-for-index; both compose files validate and the faces profile is
correctly excluded from a default `up`; workflow YAML parses and the job
graph resolves.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(ml): pin the converter toolchain, verify parity, drop a false reproducibility claim (#1074)

Ran the FaceNet-512 conversion for real and corrected what the previous
commit assumed about it.

The conversion works: 23,497,424 parameters, 89.6 MB ONNX, and the converted
graph matches the Keras original to 2.086e-06 absolute / cosine
1.0000000000. That check is now part of the script rather than something I
did once by hand — a subtly wrong graph still returns 512 plausible floats,
so it refuses to leave the file on disk if parity fails.

Also ran the full pipeline against both real models end to end. The
embedding is L2-normalized to 1.000000, and the same face survives being
re-rendered: half scale 0.973, double scale 0.984, JPEG q40 0.987, rotated
8 degrees 0.984, brightness +40 0.988. Scale invariance in particular is
evidence the alignment warp is doing its job.

Corrected claim: the conversion is NOT byte-reproducible. Two runs with the
same pinned versions on the same machine gave different SHA-256s. The graphs
are functionally identical — same 336 nodes, same 271 initializers, every
weight matching to 0.000e+00 — but a few initializer names differ because
tf2onnx's traced-op naming is not deterministic (Keras layer naming is
deterministic; I checked). The previous commit message and README both
claimed byte-identical output. They were wrong, and it matters: anyone
re-running the conversion gets a different hash, and without this note that
reads like tampering. The build-time SHA-256 pins one published artifact so
its URL cannot start serving different bytes; validating a fresh conversion
is the parity check's job.

requirements-convert.txt now pins the exact set that produced the artifact,
including transitive keras/protobuf/numpy, and documents that the converter
needs Python 3.11 while the image runs 3.12.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): schema, queue, clustering and API for People in this gallery (#1074)

Backend half of the feature. Migration 177, a face-detection queue, the
clustering engine, the gallery and admin APIs, and the privacy wiring. No UI
yet; nothing is reachable until the `faces` feature flag is on, which
defaults to false.

The flag is the gate, not FACE_ML_URL. That variable now has a working
default (the compose service name), so its presence proves nothing about
intent — if it were the gate, every install would poll a hostname that does
not resolve. faceQueue re-checks the flag every tick, so turning it off stops
the workers without a restart.

Visibility scoping is the part worth reviewing closely. Face rows have no
concept of photo visibility, but guests are restricted to
photos.visibility='visible'. A raw count leaks how many hidden photos someone
appears in, and an unscoped cover face renders a crop of a photo the guest
may not open — with the best-scoring face being the likeliest pick, so it
would happen often rather than rarely. facePeopleService recomputes both per
request against the caller's own scope, and event_people.face_count_total is
named to be conspicuous in a guest path. Six tests cover it, including the
case where a person's photos are ALL hidden and they must vanish entirely.

Face data is excluded from backups and .picpeak exports, per the decision in
the thread: it is derived, so a restore re-scans rather than carrying
biometrics between operators. Three separate mechanisms, because the engines
cannot be filtered alike — EXCLUDED_TABLES for export, --exclude-table-data
(not --exclude-table; the CREATE TABLE must survive or restore breaks on the
first query) for Postgres, and DELETE + VACUUM on the temp copy for SQLite,
which has no way to exclude a table from a whole-file .backup. The VACUUM is
not cosmetic: without it the pages stay in the file and the claim is false on
disk.

Archiving now purges face data explicitly. photo_faces cascades off photos,
but archive deletes neither the photo rows nor the event, so without this an
archived gallery kept its biometrics indefinitely.

Other decisions: clustering keeps names across a re-cluster by majority
inheritance (without it, one button click silently discards every name the
photographer typed); consolidation refuses to merge two people who were named
differently; assignment never compares across model_version, since embeddings
from two pipelines are not comparable; low-quality faces are stored but left
unassigned so they show in "this photo contains" without spawning junk people.

Migration is 177, not 174 — 174/175/176 landed on main while this branch was
open.

29 tests green: 7 migration (idempotency, down(), cascade, and that
installing it enqueues NOTHING), 11 clustering, 11 privacy/visibility. Lint
clean; the pre-existing error counts in databaseBackup.js and server.js are
unchanged.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): People strip, face filter and admin controls (#1074)

Frontend half. Renders nothing anywhere unless the `faces` feature flag is
on AND the photographer enabled detection for the gallery — the whole guest
surface hangs off one boolean, event.people_enabled, which the server
computes from the flag, the per-event toggle and the show-to-guests toggle
together.

Guest side: a People strip between the filter bar and the grid, circular
crops from each person's cover face, an active-filter chip row, and a "Show
all" bottom sheet. The face filter composes with category, search, media
type and the liked/saved/rated filters in the same useMemo rather than
replacing them, so "photos of Anna that I liked" works. Two people selected
means AND by default — that is what picking a second face almost always
asks for — with a toggle to OR that appears only once a second person is
picked.

Unnamed people show a photo count and never "Person 7". A number is honest
about what the system knows; an invented name is not. There is a test
asserting we don't do it.

The strip renders nothing below two people, collapses to one line when
dismissed (persisted per slug, so dismissing one gallery says nothing about
the next), and appears mid-backfill with a progress line rather than
blocking the gallery behind a spinner. Avatar crops are computed in ratios
of the source dimensions so they survive whatever rendition the browser
gets; without width/height they fall back to an uncropped thumbnail, since
a wrongly-offset crop is worse than no crop.

No new download endpoint: "download these N" rides the existing photoIds
path, which already enforces access level and per-category permissions
server-side. Adding a person_id selector would have been a second thing to
authorize for no gain.

Guest-facing copy never says "biometric" or "recognition" — those words
describe our implementation, not the guest's experience. The sheet's
footnote answers the first question every guest has (where does this go?)
inline. The admin card, by contrast, is explicit: it states the controller
obligation next to the toggle, and warns that scanning materializes the
preview tier on galleries that never generated one, which is real CPU and
disk an admin should know about before a 2,000-photo backfill.

EN + DE translations. 140 frontend tests green (8 new), tsc and eslint clean.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): measured match threshold, working build defaults, 89MB smaller image (#1074)

Ran the Phase 0 spike that had been outstanding, published the model, and
fixed what both turned up.

THRESHOLD IS NOW MEASURED, NOT GUESSED. LFW's standard 1000-pair protocol
run through this exact pipeline (YuNet -> Umeyama alignment -> FaceNet-512
ONNX), 100% detection on 2000 images:

  same person  cosine 0.6958 +/- 0.1415
  diff person  cosine 0.0849 +/- 0.1674   separation 0.6109
  peak accuracy 96.60% @ 0.405

So the pipeline separates people well — the thing I could not previously
claim, since every earlier number was the same face re-rendered.

Default moves 0.62 -> 0.50. The old value was a placeholder and a bad one:
it gave 0% false merges but 22.4% false splits, i.e. roughly one in four
same-person pairs failing to join, which fragments a gallery badly. 0.50
gives 1.0% false merge / 8.2% false split. Peak accuracy (0.405) is
deliberately NOT chosen: for clustering the two errors do not cost the same.
A false split is a duplicate row the photographer can merge away; a false
merge puts a stranger into someone's "download my photos" — and until the
Phase 2 merge/split UI ships, there is no way to undo one. So this sits on
the conservative side of the optimum.

The spike is committed as ml/tools/benchmark_threshold.py rather than
thrown away, so "why 0.50?" has an answer in six months and a re-tune is one
command.

BUILD DEFAULTS. FACENET_ONNX_URL/_SHA256 now default to the published
ml-models-v1 release asset, so `docker build ml/` and
`docker compose --profile faces up` work with no arguments. Blanking either
still fails loudly — a URL without a checksum is never acceptable, since the
checksum is what makes the URL safe to trust. Found by running compose for
real: it failed exactly as designed, which was correct behaviour and a bad
out-of-box experience now that a canonical artifact exists.

IMAGE SIZE. 389MB -> 300MB single-arch. `chown -R` after COPY rewrote every
copied file into a fresh layer, duplicating the 90MB model for nothing; the
user is now created before the copies and ownership set via COPY --chown.
Also drops pip/setuptools from the runtime image. Measured RSS is 186MiB
idle, and the container answers /faces end-to-end in well under the
80-150ms/photo the issue budgeted.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): threshold 0.50 -> 0.60 from real clustering, theme-aware People strip (#1074)

Both fixes come from running the feature on an actual gallery — 61 photos,
5 real identities — rather than reasoning about it.

THRESHOLD. The LFW pairwise sweep in the previous commit said 0.50, and it
was wrong. On a real gallery at 0.50, three of six visible clusters were
contaminated: two different people merged into one strip entry, which is the
exact failure that puts a stranger into someone's "download my photos".

Pairwise error rates do not predict cluster purity. Greedy assignment
compounds — one wrong face drags the centroid toward the midpoint between two
identities, making the next wrong face likelier. A 1% pairwise false-merge
rate is not a 1% chance of a clean gallery, and no amount of staring at an
ROC curve would have shown that.

Sweep against ground truth (5 identities):

    0.50 -> 6 clusters, 3 contaminated
    0.56 -> 6 clusters, 0 contaminated
    0.60 -> 5 clusters, 0 contaminated   <- exactly right
    0.64 -> 5 clusters, 0 contaminated, fewer faces assigned

0.60 recovers the right number of people with no contamination; higher only
loses coverage. Migration 177 carries the full reasoning so the next person
to touch this knows why the obvious pairwise answer is the wrong one.

THEME. The People strip hardcoded `text-neutral-800` for named people. On a
dark gallery — which the screenshot immediately showed — that renders a
named person's label almost invisibly, while UNNAMED people stayed legible.
Exactly backwards. Labels, headings, the collapsed summary, the scan line
and the filter chip row now read the gallery's own theme tokens
(--color-text / --color-muted-text / --color-accent / --color-surface-border)
like the rest of the gallery surface.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): keep the mobile filter row inside the viewport (#1074)

At 390px the photo count and Clear link were pushed against the right edge
by ml-auto and clipped. Only apply it from the sm breakpoint up, where
there is room; below that they flow after the chips.

Found by screenshotting the real thing on an iPhone-sized viewport.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): complete Phase 1, add People management and auto-categories (#1074)

Closes the two Phase 1 gaps, then builds Phase 2 and Phase 3.

PHASE 1 GAPS. "Download these N" was specified, described as done in an
earlier summary, and never actually built — I had verified the backend needed
no new endpoint and let that stand as if the button existed. It now hands the
filtered photo ids to the same path as a manual selection, so the server
re-applies access level and per-category permissions on the way through.
Photos in a downloads-disabled category are excluded client-side too, so the
number on the button is the number the guest receives. Hidden entirely when
downloads are off for the gallery.

Lightbox person chips ("In this photo: Anna") are the second way into the
face filter — a guest looking at a photo of themselves can act on it without
scrolling back to the strip. Tapping one closes the lightbox and filters the
grid behind it.

PHASE 2. A People management modal over the endpoints that already existed
and were already tested: rename inline, merge (multi-select, first pick is
the target so the name a photographer typed survives), split via a face
picker, hide, ignore. This matters more than it sounds — clustering
deliberately errs toward splitting because a wrong merge puts a stranger into
someone's download, and that trade only works if merging is easy.

PHASE 3. Rule engine over face_count plus face-area ratio: 0 -> Details,
1 large -> Portraits, 2-5 -> Small groups, >5 -> Groups. The area ratio is
what separates "a portrait of someone" from "someone is in this landscape".
Three guarantees, all tested: it only ever fills an EMPTY category (enforced
in the query AND re-checked in the UPDATE, so a photographer setting one
mid-run still wins), everything it touches is marked auto_categorized so undo
is exact, and it is a no-op unless separately enabled. Migration 178 adds the
column — separate from 177, which has already run wherever this branch is
deployed.

Verified on the real gallery: 61 photos -> 48 portraits + 13 small groups,
undo cleared exactly 61 and left the manual ones alone. Merge moved faces and
removed the source. Both confirmed against the database, not just the UI.

TWO BUGS THE BROWSER CAUGHT, both invisible to tsc:

- The lightbox destructure never landed — my patch targeted a line that has a
  default value, matched nothing, and failed silently. `people` resolved to
  something else entirely and the chips would never have rendered. eslint's
  "outer scope value" warning is what surfaced it.

- Admin face thumbnails 403'd because <AuthenticatedImage> attaches whatever
  gallery token is in session storage; an admin who has also opened one of
  their own galleries sends a type:"gallery" bearer to an admin route. Admin
  routes authenticate from the httpOnly cookie, which a plain same-origin
  <img> sends by itself. Worth noting AdminPhotoGrid has the same latent
  shape; not touched here.

Also: the admin card now reports "N people (M shown to guests)" when those
differ, so the settings page and the gallery stop disagreeing without
explanation.

45 backend tests (8 new) and 140 frontend tests green; tsc and eslint clean.
EN + DE for every new string.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* perf(faces): batch migration DDL and drop the face stack from server.js import (#1074)

CI's backend job timed out at 10 minutes on the first run of this branch.
Nothing failed — 132 of 182 suites passed and the wall clock ran out. Main
does the same 182 in 124s, and where main has 12 suites slow enough for jest
to print a duration, this branch had 77.

Two changes, both worth making regardless of how much of the gap they close:

- Migration 177 added its columns one ALTER TABLE at a time (four on photos,
  three on events, plus a separate index statement) and seeded settings with
  a SELECT and an INSERT per key. It now uses one alterTable per table and
  one SELECT plus one bulk INSERT. 178 folds its index into the same
  statement as its column. That chain replays in ~90 suites, so statement
  count there is multiplied by 90.

- server.js required faceQueue at module scope, which pulls in axios and —
  through imageProcessor — sharp. Every supertest suite that imports
  server.js was paying for a module graph it never uses. Now required inside
  the startup block, next to the call that needs it.

Honest about the evidence: locally the migration delta measures at zero
(1.15s vs 1.13s for the same suite, three runs each), so batching alone does
not explain an eight-minute regression. A fast local disk and many cores mask
per-statement and per-import costs that a two-core runner with a shared disk
does not. These are the two real costs this branch added to a path that runs
in almost every suite; whether they are sufficient is a question for CI, not
for another round of local speculation.

37 face tests still green after the change.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* i18n(faces): complete EN and DE coverage for the face feature (#1074)

The admin card and the Features toggle were rendering entirely from inline
English `defaultValue` fallbacks — 22 keys existed in no locale file at all,
so a German admin saw an English consent notice, English toggles and English
buttons. The gallery side was already translated; the admin side was not,
and nothing in the toolchain flags this because a `defaultValue` always
renders something.

Adds the missing `admin.faces.*` (19), `settings.features.faces.*` (2) and
shared `common.clear/saved/saveFailed` in both languages. Existing keys are
left alone (setdefault, not overwrite), so the shared `common` strings other
features rely on are untouched.

Committed the audit as frontend/scripts/i18n-faces-audit.py rather than
throwing it away: it extracts every t() key the face components actually use
and diffs it against each locale, and it also reports German values that are
byte-identical to English, which is the usual shape of an untranslated
copy-paste. Currently: 69 keys in use, EN complete, DE complete, no
identical pairs.

Verified in the browser, not just in the JSON — the German card reads
"61 / 61 Fotos durchsucht · 16 Personen (5 für Gäste sichtbar)" end to end.
Also checked the components for hardcoded user-facing text (JSX nodes,
title/aria-label/placeholder attributes) outside t(); there is none.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): 13 defects from external review — coordinates, counts, erasure, races (#1074)

Codex reviewed the branch against main. Thirteen findings, nine P1. I checked
every one against the code and could not dismiss a single one as a false
positive, so all thirteen are fixed here.

THE WORST ONE: bounding boxes were stored in the wrong coordinate system.
The sidecar reports coordinates in the space of the image it was HANDED —
which is the ≤1920px preview, not the original — while every consumer
compares them against photos.width/height, the original dimensions. A 6000px
photo therefore produced boxes ~3x too small and areas ~9x too small: avatar
crops landed in the wrong place and the Portraits rule could never fire. It
is invisible on any photo already under 1920px, which is exactly why the
demo gallery and every screenshot looked correct. Now scaled once in
faceProcessor so everything downstream can assume original-image coordinates.

ERASURE. The FK cascade on photo_faces is decorative on SQLite: PicPeak never
enables `PRAGMA foreign_keys`, so deleting a photo left its embeddings
behind. I first enabled the pragma globally and reverted it — six unrelated
suites immediately failed on pre-existing dangling references, and switching
it on would start rejecting inserts on every existing install. That is a real
change worth making, but it is its own PR, not a rider on this one. Instead
deletion purges explicitly: purgePhotoFaces in the photo paths (single, bulk,
service) and photo_faces/event_people in deleteEventCascade. Tests assert
this with the pragma explicitly OFF, so they can only pass if the code does
the work.

COUNTS. A re-scan deleted the old face rows without undoing their
contribution to event_people, so counts inflated on every re-scan and ghost
people survived. Now the affected people are recomputed before the
replacements are assigned. My own "must not double its faces" test only
checked photo_faces rows, which is why it passed throughout.

RACES. A worker that finished after an admin purged the event committed its
rows anyway — erasure reported success and the data reappeared. The commit is
now conditional on the row still being 'processing'. And assignFaces is
read-modify-write over an event's people, so two workers lost each other's
updates; it is now serialised per event with an in-process mutex plus a
Postgres advisory lock for the multi-pod case the queue advertises.

METADATA LOSS. Merging discarded the source's name and suppression flags, so
a merge could erase a typed name or un-hide someone. Reclustering remembered
only people with a label, so an unnamed-but-hidden bystander came back
guest-visible after one "Re-group people" — and suppression now propagates to
every descendant cluster, not just the majority one.

Also: export reset face_status so a restored gallery re-scans instead of
claiming to be scanned forever; manual category edits clear auto_categorized
so "undo automatic" cannot delete a photographer's own choice; external
photos are skipped rather than failed (resolvePhotoStorageKey returns null
for them by design); the gallery refetches photo memberships as a scan
progresses so filtering is not stale; a failed VACUUM now fails the backup
rather than publishing one that may retain biometric pages; and the ML
Dockerfile's `|| true` is scoped to the uninstall — as written it was
`(install && uninstall) || true`, so a failed dependency install produced a
green layer and an image with no onnxruntime.

Four new regression tests. Full backend suite failure set verified identical
to origin/main; frontend 140 green; tsc and eslint clean.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): 12 more defects from review round 2 — cross-event purge, leaks, lifecycle (#1074)

Second Codex round on the same diff, now including round 1's fixes. Twelve
findings, seven P1. Again none were false positives.

SECURITY, AND MINE FROM ROUND 1: the bulk-delete face purge iterated the raw
`photoIds` from the request instead of the event-scoped `photos` rows the
handler had already validated. purgePhotoFaces has no event scope of its own,
so an editor could pass another gallery's photo id and delete its face data —
even though the photo deletion right below it was correctly scoped. Fixing
one thing and introducing another is exactly why the second round was worth
running.

ANOTHER VISIBILITY LEAK, same class as the one round 1 fixed: /people returns
scan progress, and getScanStatus counted every photo with a face_status —
including hidden ones. Guests could read the hidden-photo count off the
progress bar while the people list and covers beside it were properly scoped.
Now scoped by the same predicate, with the caller passing its audience.

RECLUSTER, ROUND 1'S FIX WAS INCOMPLETE. I made suppression follow every
descendant but still copied the flags from the majority ANCESTOR. When
reclustering merges a visible named person with a hidden one, the majority
ancestor is often the visible one — republishing the hidden person's photos.
Suppression is now OR-ed across every ancestor contributing faces. The name
also now goes to the genuine largest descendant; the previous code took
whichever cluster came first in map order, which the comment already claimed
it did not.

LIFECYCLE. Face data is excluded from backups and exports, but photos.
face_status came across intact, so a restored install claimed every photo was
scanned while holding no faces — and the worker only claims 'pending', so it
stayed that way forever. Now: the SQLite backup requeues in the dump, restore
requeues after the pool reinit (the Postgres path cannot rewrite rows inside
pg_dump), the portable importer purges LOCAL face tables (they were excluded
from the replace list, so another instance's embeddings survived an import
with FK checks suspended) and requeues, and archiving disables detection so a
restored archive is honestly off rather than enabled-and-empty.

WRITE PATHS. Only processPhoto enqueued. The synchronous upload path
(chunked-upload completion, watch-folder) left photos unscanned, and
replacePhoto kept the OLD image's faces on a row now pointing at a different
picture — stale identities shown on the new photo.

FRONTEND. PeopleSheet and the admin manager rendered centred thumbnails and
ignored the bbox, so on group photos the avatar showed whoever stood in the
middle and two people from one photo were indistinguishable — in the manager
whose entire job is telling faces apart. The crop maths is now one shared
helper (faceCrop.ts) so the three surfaces cannot drift again. Full-page
layouts (gallery-premium, gallery-story) render their own lightbox and never
received the people props.

Backend failure set verified identical to origin/main; frontend 140 green;
tsc and eslint clean.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): round 3 — five of round 2's fixes were wrong or no-ops (#1074)

Third and final Codex round. Eight findings, four P1 — and the important part
is that FIVE of them are defects in round 2's fixes, not in the original code.

- The sync-upload enqueue I added was a silent no-op. It queried through
  `trx` after the transaction had already been committed, which throws
  "Transaction query already complete" straight into the catch I had wrapped
  it in. Chunked uploads and watch-folder imports were still never scanned,
  and the code read as though they were. Uses `db` now.

- The post-restore requeue ran BEFORE the files were restored, in both the
  portable importer and the native restore. The face worker is live during a
  restore, so it could claim those rows and scan the previous instance's
  files, or fail them for originals not yet on disk — with nothing to requeue
  them afterwards. Both now run after file restoration; the native one is
  extracted into requeueFaceScans() and called from the full and
  database-only paths.

- The admin face crop mixed coordinate spaces: an original-pixel bbox scaled
  against the THUMBNAIL's natural size. The API now returns the source
  dimensions alongside the box, so there is one space to reason about.

- Forwarding people props through layoutProps did not make them work — the
  full-page layouts never destructured them. GalleryStoryLayout now threads
  them to its own lightbox.

Genuinely new findings, all in the same class as ones already fixed:

- releaseToPending updated unconditionally, so a photo purged while its
  sidecar request was in flight came back as 'pending' and was rescanned —
  biometric rows reappearing after the purge reported success. Round 2 fixed
  exactly this on the COMMIT path and I did not carry it to the retry path.
  Now guarded on 'processing'.

- purgePhotoFaces left face_status alone, so a worker mid-scan still
  satisfied its commit guard and could write fresh faces into a photo being
  deleted — orphans, since the FK cascade is inert on SQLite. It now clears
  the claim as part of the purge.

- Phase 3 was unreachable: the migration seeds face_auto_categorize_enabled
  false and nothing could ever write it, so the rule engine and its undo
  endpoint returned "disabled" in every real flow. Added GET/PUT and a toggle
  on the admin card, EN + DE.

NOT fixed, deliberately: GalleryPremiumLayout uses yet-another-react-lightbox
rather than the shared PhotoLightbox, so person chips there are a real port
rather than a prop forward. Recorded as open rather than bodged.

Backend failure set identical to origin/main; 41 face tests and 140 frontend
tests green; i18n audit reports EN and DE complete at 71 keys.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): block face recognition on the all-in-one image (#1074, #1042)

The single-container image cannot run this feature, so it is refused there
rather than left to degrade.

WHY, since the reason is not obvious from the code: the AIO image runs the
backend, the frontend, SQLite and every background worker inside one
container aimed at "one photographer plus guests browsing". It has no Redis,
SQLite gives it a single writer, and it contains no ML sidecar to talk to.
Face detection would add a second image-processing pipeline competing with
Sharp for the same CPU and memory. That failure is not loud — the install
just becomes slow and looks broken, which is the worst possible shape for a
deployment whose whole promise is one container and no decisions.

Gated on an explicit PICPEAK_SINGLE_CONTAINER marker, NOT inferred from
SERVE_FRONTEND or a SQLite path: plenty of legitimate multi-container setups
serve the frontend from the backend or run SQLite, and none of them should
lose the feature by accident.

Three layers, because the first is the only one that enforces:

- faceSettings.isFeatureEnabled() returns false before consulting the flag,
  so a database restored from a full deployment with `faces` enabled still
  cannot switch it on here.
- The feature-flag API forces `faces: false` in both directions, so the admin
  UI reflects reality instead of offering a switch that refuses to stay on.
- The Features tab renders the card disabled with a plain-language reason,
  read from a new `single_container` field on /admin/system/version (an
  endpoint the admin UI already calls).

Documented in ml/README.md and .env.example. Three tests pin the behaviour,
including that the marker only accepts explicit truthy values.

NOTE FOR PR #1068: this expects `Dockerfile.aio` to set
`ENV PICPEAK_SINGLE_CONTAINER=true`. That one line lives on that branch and
is not in this commit — until it lands, an AIO build would still offer the
feature. Worth adding alongside the `Limits` section of docs/single-container.md.

44 face tests green; EN + DE complete at 72 keys.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* test(faces): pin the bbox coordinate space with a real scale factor (#1074)

The coordinate-space bug — boxes stored in preview space while every consumer
reads them as original-image pixels — had no test, and could not have been
caught by the ones that existed: every photo in the demo gallery is 750px, so
the scale factor was always exactly 1.0 and the correction never executed.

Verified by hand first, on a real 4000x3000 upload with the face placed
off-centre so a wrong crop would be unmistakable. Before the fix the stored
box was 1493,204 (preview space, face actually at x≈2850-3618); after, 3110,426
— a factor of 2.083, exactly 4000/1920, landing inside the face. The admin
crop then resolved to left=-395px/top=-46px on a 64px window, which is the
face centred.

That verification is now a test rather than a memory. Three cases: a 4000px
photo must scale by 4000/1920, a 1920px photo must NOT change (the case that
hid the bug), and a row with no width must fall back to unscaled rather than
storing NaN.

Note for anyone extending these: jest hoists mock factories above the file,
so anything they close over has to be `mock`-prefixed. Getting that wrong
fails at transform time with a message that does not name the variable.

47 face tests green.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-18 22:37:28 +02:00
Paul Nothaft 2f4b8a64c0 chore(backend): ignore runtime storage in git/docker, remove dead getSafeFilename
Follow-ups from the codex review of #834:

- .gitignore: backend/storage/ is runtime-generated (media, previews,
  thumbnails, business docs) and was only partially ignored — E2E runs
  left it dangling as untracked, which is how ~12 MB of artifacts nearly
  landed in a commit. Ignore the whole directory (nothing under it is
  tracked); replaces the narrower business-docs rule.
- backend/.dockerignore: the granular storage/* rules missed
  storage/previews, so locally generated previews were copied into
  production images. Exclude storage entirely — the Dockerfile creates
  the needed directories itself (RUN mkdir -p, Dockerfile:96).
- fileSecurityUtils.js: remove getSafeFilename — zero callers across the
  repo, and its private extension whitelist silently drifted from the
  real validation paths (see #834), which is exactly the trap dead
  security code sets.
2026-07-19 00:40:53 +02:00
Luca d6da89f48a chore(accounting): PR #622 nits — stray artifact, dedupe requireFlag, IMAP poll backoff
1. Remove the committed test artifact backend/storage/business-docs/quote/2026/
   Q-2026-0001.pdf and gitignore backend/storage/business-docs/ so generated CRM
   docs can't be committed again.
2. adminLedger + adminExpenses dropped their local requireFlag copies and now
   import the shared (now cached) requireFeatureFlag middleware.
4. roundTripTest polls IMAP with ×1.5 backoff (cap 8s) instead of a flat 3s, so a
   30s test takes ~5 SELECT/SEARCH locks not ~10 (some servers throttle).

Nit 3 (dashboard + events pages still on the gallery-theme vars, not dark-mode-
swapped) is left as a documented follow-up per the review.
2026-06-16 18:36:35 +02:00
Paul Nothaft 0faf9b3281 docs: move documentation to docs.picpeak.app, drop in-repo copies
The full documentation now lives at https://docs.picpeak.app — built
from the picpeak-docs Nextra repo. The README, SIMPLE_SETUP, and the
v1 OpenAPI generation flow all point there now.

Removed (now living at docs.picpeak.app):
- DEPLOYMENT_GUIDE.md (root-level — content covered by docs.picpeak.app/deployment)
- docs/ADMIN_SETUP_GUIDE.md
- docs/JWT_SECRET_MIGRATION.md
- docs/SECURITY_BEST_PRACTICES.md
- docs/admin-api-quickstart.md → docs.picpeak.app/api
- docs/nginx-fix.md → docs.picpeak.app/deployment/reverse-proxy
- docs/openapi.json, docs/openapi.yaml → still generated locally as a
  build artifact (now gitignored), synced into picpeak-docs by
  scripts/sync-api-docs.sh
- docs/picpeak-admin-api.openapi.yaml → ditto

Kept:
- docs/*.png (logo + screenshots — README still img-tags these)

Updated:
- README.md — replaced six in-repo doc links with docs.picpeak.app
  pointers, restructured the Documentation section as a curated link
  list to the new site
- SIMPLE_SETUP.md — single deployment-guide link redirected
- .gitignore — docs/openapi.{json,yaml} are now build artifacts, not
  tracked
- backend/src/routes/v1/events.js — comment clarifies the OpenAPI flow
2026-04-29 22:24:15 +02:00
Paul Nothaft 1b717ce5ed feat: native S3 storage backend (#328) + presigned download follow-up
Lets PicPeak write photos, thumbnails, hero images, watermarks, and
archive zips to any S3-compatible bucket (AWS S3, MinIO, Cloudflare R2,
Backblaze B2, Wasabi, DigitalOcean Spaces) instead of the local
filesystem. Selected via STORAGE_BACKEND=local|s3.

Architecture
- backend/src/services/storage/StorageBackend.js — abstract interface
  (put/get/exists/stat/delete/list/copy/rename/signedUrl/putFromFile/
  getToFile) — typedef-only, documents the contract.
- LocalFsStorage.js — wraps fs with atomic-write-via-tmp-rename, path
  traversal protection, list-as-walker.
- S3StorageBackend.js — thin wrapper around the existing
  S3StorageAdapter (used by backupService) mapping it onto the canonical
  interface; supports optional STORAGE_S3_PREFIX namespace.
- index.js — factory selected by STORAGE_BACKEND with startup ping
  (HEADs sentinel key on S3, fs.stat on local) so misconfig fails fast
  before the first request.

Consumer refactors (~12 services + routes), each parametrized over the
abstraction:
- imageProcessor / videoProcessor — pipe Sharp/ffmpeg output through
  storage.put; expose withLocalCopy() helper for S3-mode regeneration
  paths that need a local file for sharp/ffmpeg.
- archiveService / downloadZipService — finalize zip in tmp dir, then
  storage.putFromFile. Atomic-rename pattern preserved on local; S3
  emulates via copy + delete (worker prunes orphaned .tmp.* on startup).
- photoProcessor / photoReplacementService / adminPhotos upload+delete /
  routes/v1/events.js POST /events/:id/photos / routes/events.js — every
  upload path now goes storage.putFromFile(temp) → unlink temp.
- gallery.js bulk-download (cached + on-the-fly + selected) — managed
  photos via storage.get, external-mode unchanged.
- protectedImages / secureImages / photoResolver — read via
  storage.get; resolvePhotoStorageKey returns the canonical key.
- watermarkService / watermarkGeneratorService — persistent watermarks
  via storage.put.
- fileWatcher — bails out with a clear log warning when STORAGE_BACKEND=s3
  (chokidar can't watch S3); auto-import lands via the S3 prefix walker
  introduced in the follow-up commit.
- expirationChecker — small touch (event.expired webhook fire from #327
  shipping in the next commit).

Migration tooling
- backend/scripts/migrate-storage.js — one-shot --dry-run capable script
  that walks photos.path, thumbnail_path, hero_path, watermark_path and
  events.archive_path/download_zip_path; streams local → S3; sha256
  size-match skip for idempotent re-run; failures CSV.

Presigned-URL "Download All" (#328 follow-up shipped in this commit)
- routes/gallery.js — when STORAGE_BACKEND=s3 + event.allow_presigned_download
  + downloads enabled + watermark NOT enabled, /download-all returns a
  302 redirect to a 5-minute presigned S3 URL. Per-event opt-in surface
  ships in the next commit's UI.

Tests
- backend/__tests__/integration/storageBackend.test.js — parametrized
  contract suite running against BOTH LocalFs AND MinIO (18 tests, both
  backends — 36 cases total).
- backend/__tests__/integration/imageProcessor.storage.test.js — same
  parametrized pattern for the image processor (10 tests × 2 backends).
- backend/__tests__/integration/backup-s3.test.js — bootstrap fix:
  drop the redundant initDb() (001_init handles it) and remove
  schema-drift in configureS3Backup (app_settings has no created_at
  anymore and the unique constraint is on setting_key alone, not
  composite). 0/12 → 7/12 (5 remaining are unrelated assertion drift).
- backend/src/services/photoResolver.js — mixed-source events (reference
  mode with managed-uploaded photos) now fall back to managed when
  external_relpath is missing instead of throwing.
- tests/e2e/s3-storage-roundtrip.spec.ts — Playwright spec that
  auto-skips against local backend; full upload → serve → delete
  round-trip when run against an S3-mode backend.

Server wiring (server.js)
- initStorage() called after database init, before rate limiters.
- This commit's diff also includes the webhook delivery worker startup
  and the S3 auto-importer startup. Those features ship in the next two
  commits — co-located here for one bisectable diff per file.

Docs + ops
- README §"Storage Backends" — capability matrix, switching playbook,
  IAM policy snippet, MinIO/R2/B2 examples.
- README §"Webhooks" — also added here (full diff bundled).
- .env.example — STORAGE_BACKEND + STORAGE_S3_* + STORAGE_AUTO_IMPORT
  documented; WEBHOOK_* added in the same diff.
- .gitignore — re-anchor the existing `storage/` rule to `/storage/`
  so backend/src/services/storage/ (the new abstraction code) is
  trackable. The runtime ./storage/ data dir stays ignored.

Out of scope for v1 (per the issue): presigned URLs for individual
photo display (always streamed for protection middleware), CDN
integration, hybrid hot/cold tiers, S3 → local migration, multi-bucket
per-event.
2026-04-28 10:06:36 +02:00
Paul Nothaft 8d0fb8e157 chore: expose pid + uptime on /health for crash-detection monitors
Adds `pid` and `uptime` fields to the /health response so external monitors
(and the local E2E watchdog) can detect a silent process restart between
two checks — e.g. an unhandled rejection that crashes Node and Docker
quietly relaunches the container.

Also adds .gitignore patterns for a local-only E2E suite that lives in
tests/e2e/local/ on individual machines and is never pushed.
2026-04-27 22:38:00 +02:00
Paul Nothaft 50c09904a9 feat: add update instructions dialog, email notifications, and capture date sorting
- Add Update Instructions Dialog with environment-specific commands (Docker/Git/Standalone)
- Add email notification settings for new version alerts
- Add "Sort by Capture Date" option using EXIF metadata extraction
- Fix E2E tests by loading environment variables via dotenv
- Add test-images/ and backend/*.db to .gitignore

Closes #181
2026-02-16 16:23:57 +01:00
Paul Nothaft 9c2a0d272a feat: add admin dark mode and SEO/robots.txt settings
Admin Dark Mode:
- Add AdminDarkModeContext with light/dark/system preference
- Update all admin components with Tailwind dark: classes
- Add dark mode toggle in admin header
- Persist preference in localStorage

SEO Settings:
- Add robots.txt configuration in Settings > SEO tab
- Block AI crawlers (GPTBot, ChatGPT-User, etc.) with toggle
- Custom robots.txt rules management
- Add RobotsMetaTags component for gallery pages
- Backend service for dynamic robots.txt generation
- Database migration for SEO settings storage

UI/UX Improvements:
- Consistent dark mode styling across all admin pages
- Update gallery components with themed CSS classes
- Fix input, card, and button styling for dark mode
2026-02-06 23:26:01 +01:00
Paul Nothaft a19e7c40a2 fix: sync header_style DB column with theme editor selections (#158)
The frontend never sent header_style/hero_divider_style as separate
fields when creating or updating events, so the database columns always
kept their default value of 'standard' — making the hero header
impossible to enable through the admin UI.

- Extract headerStyle/heroDividerStyle from theme config and include in
  create and update payloads (CreateEventPage, EventDetailsPage)
- Add backend fallback to extract values from color_theme JSON when not
  explicitly provided, ensuring older clients stay in sync
2026-02-03 17:12:56 +01:00
Paul Nothaft 0790a1ddad feat: add per-event hero logo customization options
Add configurable hero logo settings for individual events:
- Logo visibility toggle (show/hide in hero section)
- Logo size options (small, medium, large, xlarge)
- Logo position options (top, center, bottom)

Changes include:
- Database migration for hero_logo_visible, hero_logo_size, hero_logo_position fields
- Backend routes updated to handle new settings
- Frontend admin page with logo customization controls
- HeroGalleryLayout component with dynamic logo rendering
- i18n translations for EN and DE

Also updates .gitignore to exclude test files and artifacts.
2026-01-22 13:54:23 +01:00
Paul Nothaft 892e47d017 feat: add multi-administrator support with RBAC and fix backup/restore for S3
## Multi-Administrator System
- Add role-based access control (RBAC) with predefined roles (Super Admin, Admin, Editor, Viewer)
- Add granular permissions system for all admin operations
- Add admin user management page with invite functionality
- Add email invitation system for new administrators
- Add permission middleware protecting all admin routes
- Add PermissionGate component for frontend permission checks
- Track event creator (created_by) for audit purposes

## Backup & Restore Fixes
- Fix S3 backup: endpoint URL handling, manifest loading, field name compatibility
- Fix S3 restore: add list-backups endpoint, transform S3 config from frontend format
- Fix PostgreSQL compatibility: add .returning('id') for insert operations
- Fix disk space check: use df command, handle unknown space gracefully
- Fix dry-run validation to not block on warnings
- Fix req.user → req.admin in restore routes

## Database Migrations
- 054: Add roles table with predefined roles
- 055: Add permissions table
- 056: Add role_permissions junction table
- 057: Add role_id to admin_users
- 058: Add admin_invitations table
- 059: Add admin email templates
- 060: Add created_by to events table

## Other Improvements
- Update .gitignore to exclude planning docs and local backup directory
- Remove SQLite database file from tracking
- Add i18n translations for user management (EN/DE)
2026-01-07 17:10:46 +01:00
Paul Nothaft 6033461be1 feat: add Apple Liquid Glass templates, image security settings, and automated releases
## New Features
- Apple Liquid Glass CSS template with iOS 26-inspired design
- Liquid Glass Dark theme with neon accents
- Image Security settings tab with per-event protection levels
- Release Please automation for versioning and changelog

## Improvements
- Update CSS template migration with final working templates
- Add search placeholder visibility fix for glass themes
- Update README roadmap (Download Protection, Gallery Templates, Filtering & Export now implemented)

## Infrastructure
- Add release-please.yml workflow for automated releases
- Add release-please-config.json and manifest
- Update docker-build.yml with Release Please integration comments
- Add comprehensive CHANGELOG.md

## Cleanup
- Add working/planning docs to .gitignore (CLAUDE.md, test-*.md, feature-*.md, etc.)
- Remove internal planning documents from git tracking (kept locally)

## Files Added
- .github/workflows/release-please.yml
- .release-please-manifest.json
- release-please-config.json
- CHANGELOG.md
- frontend/src/features/settings/tabs/ImageSecurityTab.tsx
2026-01-03 23:35:23 +01:00
paul bd3aa6206b Add CLAUDE.md guidance and ignore locally
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2025-11-25 22:18:54 +02:00
paul 32355fabad Revert "Ignore local Playwright tests directories"
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m9s
This reverts commit c127fd829d.
2025-09-22 21:31:28 +02:00
paul c127fd829d Ignore local Playwright tests directories
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m8s
2025-09-22 21:30:41 +02:00
paul ceefe4f5a7 chore: normalize .gitignore after cleanup
Mirror to GitHub / mirror (push) Successful in 49s
Test and Lint / backend-test (push) Successful in 1m52s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m4s
Version and Release / trigger-drone (push) Successful in 4s
2025-09-05 15:01:16 +02:00
paul dad1787aad docs: fix deployment/admin routing and CORS guidance; add AGENTS.md; ignore AGENTS.md (refs #18) 2025-09-05 15:01:16 +02:00
paul 8a0a4436b0 Fix migration require paths after reorganization
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m41s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m21s
Version and Release / version-bump (push) Successful in 48s
Version and Release / trigger-drone (push) Has been skipped
- Updated all core migrations to use ../../src/ instead of ../src/
- Updated legacy migrations with the same path fix
- This fixes MODULE_NOT_FOUND errors during deployment

The error occurred because migrations were moved one level deeper
into core/ and legacy/ subdirectories without updating the relative
paths to the source files.
2025-07-25 11:09:58 +02:00
paul 9854ca2f59 Reorganize migrations for new vs existing deployments
Mirror to GitHub / mirror (push) Successful in 27s
Test and Lint / backend-test (push) Successful in 1m21s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Failing after 31s
Version and Release / trigger-drone (push) Has been skipped
- Created core/ directory for essential migrations that always run
- Created legacy/ directory for migrations only needed when upgrading
- New deployments will only run core migrations for a clean database
- Existing deployments will run all migrations in proper sequence
- Fixed duplicate migration numbers (014 and 027)
- Updated migration runners to handle new directory structure
- Added README explaining the migration organization

This change optimizes deployment for new users who will get a clean
schema without running unnecessary upgrade migrations.
2025-07-24 22:40:25 +02:00
paul 7c79052681 refactor: consolidate deployment documentation and cleanup repository
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Has been skipped
- Merge all deployment docs into single comprehensive DEPLOYMENT_GUIDE.md
- Add instructions for non-nginx deployment options
- Reference utility scripts in deployment guide
- Remove orphaned migrations folder at root level
- Remove redundant deployment documentation files
- Keep all utility scripts in scripts/ folder
- Update CLAUDE.md to reference new deployment guide

This provides a single source of truth for all deployment scenarios.
2025-07-24 20:54:50 +02:00
paul 9dc3777985 CRITICAL FIX: prevent gallery pages redirecting to admin login
Users were being redirected from gallery pages to admin login due to
useLocalizedDate hook trying to fetch admin settings. Fixed by:

1. Added general_date_format to public settings endpoint
2. Created publicSettingsService for unauthenticated access
3. Updated useLocalizedDate to use public settings instead of admin
4. Fixed API interceptor to not redirect on public endpoint 401s
5. Added backups/ and test-archiver/ to .gitignore

This restores gallery access for all users.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul b3f240b2a5 chore: add production todo list and update CI/CD configs
- Added PRODUCTION_TODO_LIST.md with 9 completed production fixes
- Updated .gitea/workflows/mirror-to-github.yml
- Updated .gitignore

This commit includes all the production fixes implemented:
1. Password complexity settings
2. Gallery login security improvements
3. Analytics configuration fixes
4. Translation additions
5. UI/UX improvements
6. Date format consistency
7. Chrome compatibility fixes

All tasks have been completed and tested for production deployment.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
paul 1773ed5f95 Initial commit - Project start (July 17, 2025)
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Has been skipped
Original: feat: enhance security logging and ensure rate limit blocks are properly tracked

- Add comprehensive logging for rate limit blocks with full request details
  - IP address (with proper proxy detection), user agent, headers, timestamps
  - Rate limit info (current count, limit, remaining, reset time)
  - Separate tracking for auth vs general endpoints

- Enhance authentication failure logging
  - JWT validation failures with detailed error info
  - Admin auth attempts without token
  - Failed token validation with user context
  - All events include IP, path, method, user agent

- Improve Winston logger configuration for production
  - Add automatic log rotation (10MB errors, 50MB combined)
  - Create separate security.log for auth/rate limit events
  - Ensure logs directory exists automatically
  - Add structured JSON format for log aggregation
  - Support container logging with LOG_TO_CONSOLE env var

- Create comprehensive documentation
  - Security logging guide with examples
  - Monitoring recommendations
  - Configuration reference

- Add test script to verify logging functionality

All rate limit settings remain configurable via admin panel:
- Window duration, max requests, auth limits
- Skip authenticated requests option
- Public endpoints only option

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00