Commit Graph

704 Commits

Author SHA1 Message Date
Paul Nothaft 1f3f7e9c02 fix(gallery): make the returning-guest recovery findable (#1210) (#1217)
* fix(gallery): make the returning-guest recovery findable (#1210)

A guest who fills the registration form in again becomes a second
gallery_guests row, and their earlier likes and favourites stop counting as
theirs. Recovery has always existed to prevent exactly that — as a small link
under the submit button, which people reasonably read as fine print and
skipped, so duplicates kept accumulating even for guests who had given an
email the first time and were eligible for it.

Given its own block below a divider, and worded around what the guest loses by
missing it: 'Been here before? Your earlier picks are still saved.' rather than
'I've been here before', which reads as a greeting rather than a reason to
stop. The affordance itself becomes 'Get them back'.

Still a choice the guest makes, not a check the server runs. Looking up whether
the typed address is already registered would answer 'is this person in this
gallery' to anyone who asked — which is why /guest/recover always returns 200
and cannot be used that way.

The alreadyHere key is retired rather than reworded: a key by that name holding
'Get them back' would mislead the next translator. Both new strings are in all
seven locales that carried the old one.

Three tests: the hint is present, the affordance routes into recovery rather
than registering, and an ordinary first-time registration is unchanged.

* fix(i18n): match the German formality in the returning-guest hint (#1210)

The dialog addresses the guest as Sie throughout — "Willkommen — wie heißen
Sie?", "Ihre Auswahl wird unter diesem Namen gespeichert" — and the new line
came out in du. Mixing the two in one modal reads as sloppy to a German
speaker.

Caught by looking at the rendered dialog rather than the string, which is the
argument for screenshotting a copy change at all.

* fix(gallery): theme tokens for the recovery block, formal register in nl (#1210)

External review of #1217.

**The dark variant never fires in a gallery.** A dark gallery preset is
delivered through CSS variables; ThemeProvider does not add Tailwind's .dark
class. So `text-neutral-600 dark:text-neutral-400` on a dark surface stayed
dark grey on dark, and the divider stayed light. My block was the only place in
this modal using neutral-* classes at all — the rest already uses text-theme
and text-muted-theme for exactly this reason. The divider now takes
--color-surface-border, which is the token index.css actually defines.

**Dutch had the same mixed register German did.** The dialog says uw/u
throughout — 'wat is uw naam?', 'Uw selecties worden opgeslagen' — and the new
hint came out with 'Je'. Same slip, same fix, found the same way.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-28 08:33:12 +02:00
Paul Nothaft f18bc568c8 feat(admin): shift-click range selection in the photo grid (#1212) (#1213)
* feat(admin): shift-click range selection in the photo grid (#1212)

Selecting photos was one tile at a time. Select All is all-or-one, so
're-assign these two hundred' meant two hundred clicks — which is how #1209
ran into it, re-categorising a large imported set.

Shift-click now selects the span from the last plain click to the tile under
the cursor, the way a file manager does.

It extends the selection rather than replacing it: the grid already lets you
accumulate tiles one at a time, so a range is another addition to that set. And
it only ever adds — deselecting by dragging a range back over itself is a
different gesture, and guessing at it would let a mis-aimed shift-click destroy
a selection instead of growing it. The anchor stays put across repeated
shift-clicks, so the second one re-aims the same span from the original point
instead of walking along behind the cursor.

The anchor carries the id of the tile it was set on, not just the index. An
index means a different photo after a filter or a re-sort, and a range measured
from a stale anchor would select the wrong span with nothing to show for it;
the write checks the anchor still points where it was set and falls back to a
plain toggle when it does not. Validating at use rather than clearing on every
list change means a background refetch, which hands back an equal list, leaves
the anchor usable.

Seven tests, four of which fail without the change; the other three pin the
plain-click and no-anchor behaviour that must not move.

* fix(admin): clear the range anchor whenever the selection is cleared (#1212)

External review. Cancel Selection, Deselect All and a successful bulk move or
delete all emptied selectedPhotos and left the anchor behind. The anchor is
invisible, and the list is usually unchanged, so it stayed valid — the next
shift-click reached back into a selection session the user had already ended
and selected a range they never started.

Cleared at all four reset points now. Test fails against the un-fixed code.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-28 08:27:46 +02:00
Paul Nothaft 5c85e0c0e4 fix(guests): surface duplicate guest registrations, and stop making so many (#1210) (#1216)
* fix(guests): surface duplicate guest registrations, and stop making so many (#1210)

Guest registration always inserts. A client whose token expired — or who opens
the gallery on a second device — becomes a new gallery_guests row, and their
likes and favourites split across the copies. The photographer's 'final
selection' is then only trustworthy if somebody notices two Tinas with half the
picks each.

Two halves, neither of which touches the registration path.

**Say which rows are the same person.** Merging already worked, endpoint and UI
both; nothing said WHICH rows to merge. The guests list now marks each row with
the others sharing its email and returns a count for the banner, and the admin
list offers the group straight to the merge mode that already exists.
Case-folded and trimmed, because the same person types Tina@ one day and tina@
the next and both read as distinct rows. Email only — two guests called Anna
are not evidence of anything, and rows without an email are not grouped at all
since require_name_email is off by default and a shared link produces plenty of
them.

It preselects rather than merges: which row survives decides the name and
verification state the merged guest keeps, and that is the admin's call.

**Create fewer of them.** The guest token was 24h and every call site took that
default, so even the same browser lost its identity after a day of inactivity.
Now 30 days, GUEST_TOKEN_TTL to override. A guest token is scoped to one event,
carries no admin capability, and the gallery is already behind whatever
protects it — 30 days is the shape of a real proofing cycle.

Deliberately NOT done: reusing a guest row when a typed email matches, which
the report suggests first. It would let anyone who knows an address inherit
that person's identity and selections, and answering differently for a known
email would leak which addresses are in the gallery — the thing
/guest/recover already goes out of its way to avoid. Prevention at the entry
path needs the verification round-trip, which is a separate decision about
friction.

13 tests; 8 of the 9 backend ones fail without the change. The frontend ones
caught a real bug while being written — the new useMemo sat after the loading
early-return, so the hook count changed between renders.

* fix(guests): merge must not strand a pending invite (#1210)

Three findings from external review of #1216.

**A merge could kill an emailed invite link.** Creating an invite inserts a real
gallery_guests row, so an admin who pre-mints one and then sees the guest
self-register has two rows sharing an email — which this feature now points out
and offers to merge. Redemption resolves guest_invites.guest_id with
is_deleted: false, so merging soft-deleted the row the link pointed at: the
client got 404 guest_missing while the invite dialog still showed the invite as
Pending. Nothing anywhere said the link was dead. Unredeemed, unrevoked invites
now move to the survivor first. Spent ones stay put — a redeemed invite records
who redeemed what, and retargeting it would rewrite that.

**The preselection silently chose the survivor.** performMerge keeps
mergeSelection[0], and the group was handed over in API order, which is
newest-first — so Review then Merge discarded an older, email-verified row
holding most of the picks in favour of a fresh re-registration. The proposal is
now ordered deliberately: verified first, then whoever holds the most feedback,
then the oldest. Still only a proposal, and the confirmation now names the
survivor by email as well as name, because duplicates share a name and 'Merge 2
guests into Tina?' said nothing.

**duplicate_of was quadratic.** Every row carried the other n-1 ids, so a group
of n serialised n² of them — and nothing consumed the list: the UI asked only
whether a row was in a group, then regrouped by email itself. Replaced with
duplicate_group, the normalised email, which keeps the payload linear and the
case/whitespace folding in one place instead of reimplemented on the client.

Two new backend tests for the invite paths, one frontend test asserting the
merge call keeps the verified row. The invite test fails against the un-fixed
code.

* fix(guests): keep guest-controlled input out of who survives a merge (#1210)

Round 2 of external review on #1216.

**The survivor ranking used an attacker-controlled signal.** Preferring
whoever holds the most feedback looked like the obvious tiebreak and is exactly
the wrong one: registration does not verify the address, so anyone who knows a
guest's email can register with it, mark enough photos to out-rank the real
person, and be preselected as the survivor. An admin accepting a confirmation
between two rows with the same name and email would then move the victim's
picks onto an identity whose token the visitor still holds. distinct_photos is
guest-controlled and has no business deciding this. The ranking is now
email_verified_at then created_at — both server-set.

**A merge could make the survivor unrecoverable.** Rows are grouped with case
and whitespace folded out, so a merge can be proposed between tina@example.com
and Tina@Example.com. /guest/recover lowercases what the guest types and then
matches on equality, so a survivor left holding the raw value can never be
recovered by email again. The kept row's address is now canonicalised during
the merge. Both write paths normalise today, so this covers rows that predate
that — which are exactly the rows case-folded grouping surfaces.

Two more backend tests. The residual, stated plainly: an admin can still merge
two unverified rows in either order. What is gone is the tool ranking them by
something a visitor controls.

* fix(compose): pass GUEST_TOKEN_TTL through to the backend (#1210)

The override was documented in .env.example and could never take effect: the
backend service takes an explicit environment list, so a variable not named
there never reaches the container. An operator following the documentation
would have shortened the guest session and seen nothing change.

docker-compose.production.yml uses env_file: .env and already passed it
through; docker-compose.dev.yml is gitignored, so only this file needs it.

* fix(guests): the admin picks the merge survivor, the tool does not (#1210)

Fourth review round on the same point, and the right conclusion is that there
is no correct automatic answer.

Every rule tried was wrong somewhere. Most-feedback is guest-controlled — the
address is never verified at registration, so anyone who knows it can register
and mark photos until they out-rank the real person. Oldest-first, the
replacement, is worse for the ordinary case: when a token expires the OLD row
is the dead identity and the new one is the visitor's live session, so keeping
the oldest deletes the identity they are actually using, and the frontend holds
that deleted guest in sessionStorage without clearing it on a 401. Registration
timing is visitor-controlled too.

The data does not say which row is really the person. So the UI asks: merge
mode gains a Keep column, the button stays disabled until a row is nominated,
and only rows included in the merge can be nominated. The group is still
preselected — finding the duplicates was always the point — but nothing about
who survives is decided by sort order any more.

This also makes the claim in the PR description true. It said the admin decides
which row survives; until now the preselection quietly decided it for them.

Two rewritten frontend tests: the merge is blocked until a survivor is chosen
and then keeps exactly that row, and a row outside the group cannot be
nominated. The test i18n mock now interpolates, so aria-labels are queryable by
their rendered text.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-28 08:27:15 +02:00
Paul Nothaft 22e00f80b6 feat(feedback): a third identity mode with one shared colour tag per photo (#1197) (#1208)
* feat(feedback): a third identity mode with one shared colour tag per photo (#1197)

Split out of #1178, where @boergu asked for a colour tag with no identity
dimension at all: not everyone sharing a device's state, but everyone — on any
device — sharing the PHOTO's state. Guest A marks it green, guest B later marks
it orange, and the tag simply becomes orange. One collaboratively-agreed verdict
per photo instead of per-person tallies.

identity_mode gains 'shared'. The mode is scoped to the colour tag: likes,
ratings, comments, favourites and reactions stay per-visitor exactly as in
'simple', because that is what was asked for and widening it would change what
every other control means.

Stored as an ordinary photo_feedback row under a reserved identifier rather
than as a column on photos. That is what keeps the rest of the system working
untouched — the per-colour tally simply has exactly one entry, so
dominant_color_label, color_label_count, the admin colour filter and the
XMP/CSV export that #745 reads all keep their existing shapes, and no consumer
has to learn a second one. The identifier cannot be claimed: real ones are
sha256 hashes or server-minted UUIDs, and the per-guest write path rejects it
outright.

Last write wins, inside a transaction that locks the photo row. Without the
lock two guests tapping different colours in the same instant both read 'no
tag', both insert, and the photo ends up carrying two shared tags — the
per-guest tally this mode exists to remove. Re-sending the colour already on a
photo clears it, from any guest: the same toggle every other colour path uses,
and the only way to remove a tag without inventing a second control.

Switching modes is non-destructive. Existing per-guest labels are left alone
and simply not read while shared is on; the shared tag starts empty rather than
collapsing marks nobody agreed on, and switching back restores every original
exactly. An event can hold both sets, only one of which is live.

The tag stays visible with show_feedback_to_guests off — it arrives through the
per-viewer channel, being the photo's own state rather than someone else's
opinion — while the per-colour tallies stay hidden. The colour filters answer
from it for the same reason, so a gallery with sharing off cannot show colours
on tiles that no filter can find.

Attribution is gone by design, and the settings panel says so before an
operator picks the mode.

Decisions (1), (4) and (5) from the issue were settled up front, as it asked.
Decision (3) turned out not to need anything: guest colour filters already read
my_color_label, and the admin's my_color_labels filters photo_admin_marks
(#1183), not guest identity — so nothing collapses on either side.

* fix(feedback): shared mode saves on Postgres, and dormant labels stay dormant (#1197)

Three findings from external review, all confirmed against source before fixing.

**The mode could not be saved on Postgres at all.** Migration 078 created
identity_mode with a CHECK constraint pinned to ('simple','guest'), guarded on
`client === 'pg'` — so SQLite never has it and no SQLite test can see it, while
the database every default production install runs rejects the new value
outright. Migration 192 drops and re-adds the constraint with 'shared' included;
its down() resets any event using the mode to 'simple' first, or the narrower
constraint could not be restored. Verified against a real Postgres on a scratch
database: the insert fails before, succeeds after, up() is re-runnable, and
down() puts the old constraint back.

**Dormant labels were still being read.** Switching modes is deliberately
non-destructive, which leaves both sets of colour labels in the table with only
one live — and every read that did not say which set it meant kept counting the
other. The per-colour tallies, color_label_count, the admin grid badge, the
XMP/CSV export, both admin colour filters and the guest colour filter all saw
labels the mode does not show; switching back exposed the shared row as an
anonymous other guest's dot. The settings panel promises these are 'kept but not
shown', and that has to mean every surface, not just the badge. Scoped at the
source — the two count helpers resolve the mode themselves — so the admin grid
and the export are fixed without touching either.

**The create form's identity mode was dropped.** CreateEventPage has always
rendered the chooser and the create route never read it, so a gallery created as
'guest' came out 'simple' and had to be set again on the event afterwards. A
pre-existing bug that adding a third option made worse; threaded through now,
which fixes it for all three modes.

Six regression tests, each verified to fail against the un-fixed code.

* fix(feedback): keep every colour surface consistent across a mode change (#1197)

Second review round, four findings, all confirmed in source first.

**Stored counters went stale on a mode switch.** photos.color_label_count is
denormalized and recomputed on feedback writes, so changing identity_mode —
which changes nothing about the rows, only which of them are live — left the
old mode's totals on the tiles, the admin grid and the filter summary until
each photo happened to be touched again. On a finished gallery that is never.
Recounted for the event when the mode actually changes, as two statements
rather than a per-photo recompute: four of the five counters cannot have moved.

**Duplicating an event dropped the mode**, the same shape as the create-form
bug from the last round — a gallery cloned to reuse its proofing setup came
back in 'simple'.

**The event feedback summary counted dormant labels**, inflating total_feedback
in the admin analytics and the guest /feedback-summary while every other
surface hid them.

**The swatch trusted its optimistic guess over the server.** In shared mode the
tag belongs to the photo, so another guest can move it between this viewer's
last read and their click: a viewer still showing green clicks green, the
server sets green because the tag had become red meanwhile, and the optimistic
'same colour, so clear' blanked the swatch against a server that holds one. The
response already says which happened, so it is used. The per-guest modes are
unaffected — only the guest can move their own label, so guess and answer
always agreed there.

Three regression tests, each verified to fail against the un-fixed code.

* fix(feedback): shared tag is not a participant, and the keyboard path reconciles too (#1197)

Third review round, two findings.

**feedback_count counted the shared tag as a guest.** It is COUNT(DISTINCT
guest identity) across all feedback types, and the reserved identifier looked
like a person: a photo with one rating and a shared tag reported two. The
column is exported as rating_count (photoExportService), so merely tagging a
photo inflated its rating count in the CSV and JSON exports.

**The lightbox keyboard path still trusted its own guess.** The reconciliation
from the last round covered clicks through PhotoColorLabels, but the proofing
shortcuts call PhotoLightbox.submitColorLabel directly and set local state from
a locally computed toggle. That is the path a proofing client actually uses, so
it had the divergence the previous fix was for: another guest moves the tag,
this viewer presses the key, the server sets a colour and the swatch blanks.
Both branches now read the outcome off the response.

One regression test, verified to fail against the un-fixed code.

* fix(feedback): identity-mode lookup must survive a migration-time caller (#1197)

updatePhotoFeedbackStats is called from migrations as well as from the request
path — migration 186's duplicate-photo dedupe (#1162) recomputes the survivor's
totals — and a migration runs against a half-built schema where
event_feedback_settings need not exist yet. The new inner join threw there,
which took the whole stats update down with it, so the reparented rows were
never counted and eight assertions in the 186 suite failed.

Falls back to 'simple', which is the right answer rather than merely a safe
one: an install with no feedback settings table has no event in shared mode, so
the non-shared scope is exactly correct.

Caught by CI, not by me — I had been running affected suites rather than the
full one after each review round.

* fix(feedback): atomic shared-tag write, scoped feedback list, safe PG fallback (#1197)

Round 4 of external review, and one of the three is about the fix I made for
the CI failure two rounds ago.

**The identity-mode fallback could poison a Postgres transaction.** The join
was wrapped in try/catch so a migration-time caller with a half-built schema
would fall back to 'simple'. On Postgres a failed statement aborts the entire
transaction, so catching it and carrying on left the caller's trx poisoned and
the aggregate that follows failed with 'current transaction is aborted' —
defeating the very compatibility the fallback was added for. It now asks
whether the table exists before issuing the join, which is safe to ask and
aborts nothing. Memoised once true, since a table does not un-create itself and
this sits on the feedback write path.

**The shared-tag stats were recomputed after the commit.** A failure there
returned 500 for a tag that had already been written, so the client reverted
its swatch and the next tap on the same colour toggled the committed tag off
instead of setting it. Two concurrent writers could also race their aggregate
updates. Recomputed inside the transaction now, while the photo row is still
locked.

**The raw feedback list still carried both label sets.** Only the tallies and
my_feedback had been scoped, so a dormant per-guest label was still visible to
anyone reading the list — and with sharing off it came back flagged is_mine.
getPhotoFeedback now filters colour labels to the active set.

One test for the list; the migration suite that caught the original CI
regression still passes.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-28 08:19:41 +02:00
Paul Nothaft a490b64954 fix(admin): the "Uncategorized" photo filter returns every photo (#1211) (#1214)
The dropdown offered the filter and it never worked. It rendered as
`value="0"`, and adminPhotos.js skips '0' outright:

    if (category_id !== undefined && category_id !== '' && category_id !== '0') {

so no category condition was applied and the whole event came back. Four lines
below that guard sits the branch that does the work, keyed on the literal
'uncategorized' — which nothing was sending. The two ends have never agreed on
the wire value, and neither is wrong on its own.

It fails silently, which is why it went unnoticed: a full list reads as 'the
filter found nothing to narrow' rather than 'the filter did not run'.

Send what the backend already understands rather than teaching it a second
spelling. The onChange passes non-numeric values through unchanged, so the
string arrives intact.

Reported in #1209 by someone re-categorising a few thousand photos imported
without a category — the filter is the first step of filter, Select All, bulk
assign, so its failure takes the whole path with it.

Tests both ends of the contract, since the bug was the pairing rather than
either half: the frontend emits 'uncategorized', and the endpoint answers it
with only the null-category rows. The backend test also pins that 0 means no
filter, so a future change there has to be a decision rather than an accident.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-28 08:05:32 +02:00
Paul Nothaft 0a36ca6056 feat(gallery): folders that contain photos instead of filtering them (#1160) (#1161)
* feat(gallery): folders that contain photos instead of filtering them (#1160)

A category has always been a filter: its photos stay in the root grid and
picking the category narrows that grid. D#1086 asked for the opposite — put
the selects in a bucket and get them OUT of the main grid, so the client sees
the 40 finals and clicks through for the other 200.

`photo_categories.is_folder` makes that a per-category choice. One column is
enough because the neighbouring features already built the substrate:
hero_photo_id (#163) is the folder cover, allow_downloads (#640) is per-folder
download rules, display_order + event_category_order (#782) is folder ordering,
and photos.category_id being single-valued is already folder semantics.

Deliberately no parent_id. "Root -> Selects folder" is depth one, i.e. plain
containment; folders-inside-folders waits until someone asks.

Containment lands in the one useMemo where the category filter was already
applied, and the tiles render above the grid rather than inside a layout, so
all eight gallery layouts inherit folders without eight implementations.
Scope drives the counts too, so root reports 40 photos and not 240.

`?folder=<slug>` carries the open folder, preserving token and admin_preview,
so a folder is linkable and Back walks out of it instead of leaving the gallery.

Defaults to false, so every existing gallery keeps filtering exactly as before.

Folders are organisational, not access control: a foldered photo is served by
the same per-photo auth as any other. A test pins that, so nobody later mistakes
containment for a security boundary.

* feat(gallery): download a folder on its own, and label folders when moving photos (#1160)

Downloads now cover both halves of the requirement:

  - the gallery-wide "download all" keeps zipping every photo including the
    foldered ones (verified: 62 files), so a folder never quietly removes
    photos from the client's one-click download;
  - inside a folder there is a "Download folder (n)" button that zips only
    that folder, once (verified: 20 files). It reuses /download-selected, so
    there is no new endpoint and no second zip-building path.

The button honours the per-category opt-out (#640) both ways: a folder with
allow_downloads = false renders no button, and individual photos that opted
out are excluded from the id list rather than silently 403-ing mid-zip.

Moving photos into a folder already worked — a folder IS a category, so the
existing bulk "move to category" flow does it. What was missing is that a
folder and a filter category looked identical in that dropdown while having
very different consequences, so folder options now read
"<name> (folder — hidden from the main grid)". Threading is_folder through to
the dialog needed the admin category prop types widened; the data was already
on the wire.

* fix(gallery): folders were unreachable in the full-bleed layouts (#1160)

Containment comes from `filteredPhotos`, which BOTH layout branches use, but the
tiles were only rendered in one. On a Premium or Story gallery the foldered
photos therefore disappeared from the grid with no tile to click — moving 200
selects into a folder effectively deleted them from the client's view. The
folder nav is now built once and rendered by both branches, so a branch can't
hide photos without also offering the way in.

Those two layouts are edge-to-edge by design, and a block of cover cards above
the hero wrecks the opening they exist for, so they get a compact chip row
(`Folders  [icon Selects 20]`) instead. It only renders when the gallery
actually has folders, leaving every existing full-bleed gallery byte-identical.

Also scopes the people strip to the photos on screen. `face_count` comes from
/people and spans the whole event, which contradicted the grid in two ways:
inside a folder a face read "12 photos" but filtered down to the handful in that
folder, and at root a person whose photos ALL lived in a folder showed up and
filtered to nothing — a dead chip. Recounted from `photo.person_ids`, which is
already what the filter itself uses, and zero-count people are dropped. No
backend change; the ids were on the wire already.

Verified in the running app: Premium renders the chip and navigates; the
lightbox counter inside a folder reads "1 / 20", not 1 / 62; the people strip
inside the folder drops from 12/11/4 to the one face actually present.

* fix(gallery): folder edge cases found in external review (#1160)

Seven issues, all verified against the code before fixing.

Unreachable folders (the serious one). `adminCategories` derives a slug with
`[^\w\s-]` stripping and `\w` is ASCII-only, so a valid name in a non-Latin
script slugs to the empty string — `Избранное` and `日本語` both do. Keying the
URL on the slug meant such a folder wrote no param and resolved to nothing: its
photos left the root grid with no way back to them. This repo ships ru and sl
locales, so that is a reachable state, not a hypothetical. Folders are now keyed
by `folderKey()` — slug when there is one, id otherwise.

Stale selection across a scope change. The grid clears its selection when
`categoryId` changes, which is already null at root, so a selection made outside
a folder survived into it and the toolbar would offer to download (or a client
to hide) photos no longer on screen. Cleared on both explicit navigation and
popstate.

Dead category chips inside a folder. The filter branch ignores
`selectedCategoryId` while a folder is open, so the chips did nothing when
clicked. They are no longer offered there.

"No photos found" beside folder tiles. A gallery whose photos all live in
folders rendered the tiles and then the grid's empty state directly under them,
claiming the gallery was empty while pointing at its contents.

Counts that contradicted the grid. The filter bar and both people surfaces were
still counting over every event photo, so a chip could advertise a total the
scoped grid would never produce. All now count over `scopedPhotos`.

`!!` on a validated boolean. express-validator's isBoolean() accepts the STRINGS
"false" and "0", and `!!'false'` is true — a form-encoded caller asking for a
filter would have silently got a folder. Uses the existing parseBooleanInput.

Duplicate-event dropped folder-ness. The category clone selected only name, slug
and is_global, so every folder in a duplicated gallery came back as a filter.

* fix(gallery): folder scoping gaps from external review round 2 (#1160)

Cache mutation, introduced by this branch. `photosInScope` returned the caller's
own array on the no-folders fast path, and `filteredPhotos` sorts in place — so
every gallery WITHOUT folders was reordering the React Query cache for every
other consumer of `data.photos`. The pre-branch code cloned; now it always does.

Colliding folder keys. UNIQUE is (slug, event_id), so a global folder and an
event folder can share a slug, and the gallery merges both scopes. Keying on the
slug alone meant the second folder resolved to the first and its photos could
not be opened. The id is now always part of the key.

"Download folder" downloaded a subset. Search, feedback, media and people
filters stay active when entering a folder, and the ids came from
`filteredPhotos` — so the button promised the folder and delivered whatever the
filter had left, or vanished when it matched nothing. Built from `scopedPhotos`.

Folder-only root misdetected. `rootIsFoldersOnly` tested `filteredPhotos`, so a
search matching none of the loose root photos looked folder-only and swallowed
the no-results message. Tests the unfiltered scope instead.

Empty state in the full-bleed layouts. The Premium/Story branch was missing the
folder-only guard the standard branch got, so a folder-only gallery printed
"no photos found" under its own folder chips.

Filter metadata still event-wide. `availableMediaTypes` and `colorLabelCounts`
counted over every photo, so the sidebar could offer a Video or colour chip for
something that only exists in another scope — always filtering to nothing. Both
derive from `scopedPhotos`, which moved above them for that reason.

* fix(gallery): honest folder downloads and scoped totals (#1160)

Silent truncation. /download-selected slices the id list to 500 server-side
(gallery.js:1776), so a folder larger than that delivered a truncated archive
under a button promising the whole thing. The limit is now mirrored client-side:
the request carries only what the server will honour and the label says
"Download first 500 of 620" instead of claiming the folder.

Gallery shell was being unmounted. Suppressing the folder-only empty state by
skipping PhotoGridWithLayouts took the hero, event title, logout and download
controls with it in the full-bleed layouts, since those render from inside that
component — a folder-only Premium gallery collapsed to a bare chip row. Replaced
with a suppressEmptyState prop so only the message goes.

Two more counts that could contradict the grid: the sidebar's total and the
people match-count denominator ("42 of 62" at a root that holds 42). Both scoped.
The client-access visible/total stat is deliberately left event-wide — that one
is a photographer-facing statistic about the gallery, not a filter affordance.

Stale admin cache. EventDetailsPage caches the same category rows under
'admin-event-categories' and hands them to the Photos tab's move dialog, so
toggling a folder left that dialog labelling it a plain category until remount.
Both keys are invalidated now.

Not changed, after challenging the review: select-all in the full-bleed layouts
stays scoped to the displayed photos. Wiring it to the full event would select
photos that are not on screen, contradicting containment and reviving the stale
selection bug. The reviewer withdrew the finding on that basis. The residual UX
gap — no one-click "everything" in Premium/Story once folders exist — is real
and noted on the PR.

* feat(gallery): one-click download-everything in the full-bleed layouts (#1160)

Premium and Story have no header download button — their only gallery-wide
download is select-all followed by download-selected, and select-all is
correctly scoped to what is on screen. Once folders exist that left no single
way to get the whole gallery. The folder strip now carries an event-wide
"Download all photos" that hits /download-all (which has always included
foldered photos), shown at the root only, since inside a folder the breadcrumb
already offers that folder's download.

Also lands the capped folder label that was written but never actually applied
in the previous commit — the edit silently didn't match, so a 510-photo folder
still advertised "Download folder (510)" while the request was capped to 500.
Caught by building a real 510-photo folder rather than trusting the reasoning:
it now reads "Download first 500 of 510". A unit test pins the client constant
to the backend's cap so the two can't drift apart unnoticed.

* fix(gallery): remount layouts on folder change, and stop scoped counts leaking into event-wide controls (#1160)

Carousel crash. Layout state is only meaningful for the photo set it was built
against, but the layout instance was reused across a folder change. In carousel
mode an index valid at root (31 of 42) indexes past the end of a smaller folder,
and CarouselGalleryLayout does `photos[currentIndex]` unguarded. The grid is now
keyed by the open folder, so a scope change remounts: verified live, 31/42 at
root becomes 1/20 on entering the folder instead of dereferencing undefined.
The key also avoids driving one instance between the empty and non-empty render
paths, which matters because that component's `photos.length === 0` early return
sits ABOVE four useState calls — a pre-existing conditional-hook hazard this
feature would otherwise have made reachable.

Nested empty state. suppressEmptyState only silenced PhotoGridWithLayouts' own
early return; the Premium and Story layouts have their own noPhotosFound return,
so a folder-only root still printed "no photos" under the tiles proving
otherwise. The flag is forwarded to them.

Download All was labelled from the wrong number. The sidebar's total is now the
folder scope (correct for the category list), but the same value labelled and
disabled Download All — which fetches the event-wide archive. On a folder-only
root that showed 0 and refused a valid download. Split into a separate
downloadAllTotal.

Feedback chip counts. likeCount, favoriteCount and ratedCount still counted over
every event photo while clicking them filters the scope, so a chip could promise
matches from another folder and deliver none.

* fix(gallery): premium crash, story Download All, and empty-mount hazard (#1160)

ReferenceError blanking the Premium gallery — my own bug from the previous
commit. The suppressEmptyState prop landed on the nested PhotoCard instead of
GalleryPremiumLayout (both destructure `allowDownloads = true`, and the patch hit
the first one), so the layout's guard referenced an identifier that was not in
its scope. A folder-only Premium root threw instead of rendering. Now declared
and destructured on the layout, and exercised: 62 photos all foldered renders
the tile, the hero and the download button with no message and no throw.

Story's footer "Download All Photos" built its id list from the `photos` prop,
which is now the folder scope — so it silently omitted every foldered photo
while still calling itself Download All. Layouts now receive an event-wide
downloadAllIds and prefer it. Premium's equivalent control is a select-all, not
a download, and stays scoped by the same reasoning as before.

Empty-array mounts. Suppressing the empty state meant the layout got mounted
with photos=[], and CarouselGalleryLayout returns before four of its useState
calls — driving one instance between empty and non-empty changes its hook count
and React throws. Only the full-bleed layouts, which own the hero and logout
chrome, are now mounted empty; every other layout renders nothing instead.

* fix(gallery): keep the shell and drop dead controls on folder-only roots (#1160)

Skipping the empty layout took the hero and welcome message with it. The early
return sat above both, so a gallery whose photos all live in folders lost its
configured hero and welcome copy at the root and only regained them after
opening a folder. Only the layout child is skipped now; the surrounding shell
renders as it always did.

The filter bar was gated on the event-wide photo count, so a folder-only root
still rendered search, sort and the feedback chips with nothing in scope for
them to act on — the same empty filter row discussion #317 asked us to remove.
Gated on the current scope.

Story's download toast counted `photos` while the request now carries the
event-wide id list, so it could announce "Downloading 0 photos" and then fetch
the whole gallery. Counts the ids it actually sends.

* fix(gallery): clear the person filter on scope change, and fix two folder-only shell details (#1160)

A person selected in one scope can have no photos in the next. peopleInScope
drops them from the strip, so the filter stayed active with nothing left to
clear it — and the full-bleed layouts have no people UI at all, leaving a guest
staring at an empty grid with a reload as the only way out. Cleared on both
folder navigation and popstate, alongside the category selection and the photo
selection already reset there.

Story's hero announced "0 Photos" on a folder-only root, since it derives that
stat from the scope it renders and the scope is empty by definition there.
Falls back to the event-wide count.

Premium's integrated Download All is a select-all over the current scope, so on
a folder-only root it was a visible control that did nothing when clicked. It is
hidden while the scope is empty rather than left dead.

* fix(gallery): uncapped Story download, protected folder covers, scoped people order (#1160)

The event-wide id list I added for Story's "Download All Photos" made it worse,
not better: /download-selected caps at 500 ids server-side, so a gallery larger
than that silently shipped a partial archive under a button promising all of it.
Replaced with an onDownloadEverything callback that runs the whole-gallery
/download-all path, which has no cap. eventPhotoCount now carries the number
Story needs for its hero stat, so no id list crosses the boundary at all.

Folder covers bypassed image protection. A cover is a real gallery photo, but it
was rendered through AuthenticatedImage's defaults while every photo tile passes
the gallery's protection settings — so on a gallery configured for canvas
rendering or maximum protection, each cover was an ordinary blob-backed <img>.
The tiles now receive and apply the same props as the grid.

People kept /people's event-wide ordering after their counts were rescoped, so a
folder's most-photographed person could sort behind someone with a single match
— and PeopleStrip only shows the first twelve inline. Sorted by the recomputed
count, with a test.

* fix(gallery): folder covers honour maximum protection (#1160)

Maximum protection implies canvas rendering even when the independent
use_canvas_rendering toggle is off, which is its default — every other gallery
image path spells that out as `useCanvasRendering || protectionLevel ===
'maximum'` (PhotoGrid, PhotoLightbox, HeroHeader, JustifiedGalleryLayout). The
folder cover forwarded the raw toggle, so on a maximum-protection gallery with
the toggle untouched the cover fell back to a blob-backed <img>. Matches the
convention now.

* fix(gallery): don't let download-everything bypass a category opt-out, and keep folder links alive across renames (#1160)

The whole-gallery route serves a prebuilt zip containing EVERY event photo with
no per-category filter — gallery.js says so itself, next to
bumpEventDownloadCounts, as a known pre-existing gap. Wiring Story's footer to
that route therefore converted a path that DID enforce the #640 opt-out into one
that doesn't, and because the callback was supplied unconditionally it affected
Story galleries with no folders at all.

The same reasoning applies to the download-everything button this branch added
to the full-bleed folder strip: it routes there too, so on a gallery with a
restricted category it would have handed over exactly the photos the opt-out
withholds. Both are now withheld whenever any category opts out; those galleries
keep the per-folder download, which enforces it. Verified both ways — the
control disappears with a restricted category present and returns once the
restriction is lifted.

Folder links also survived a rename badly: the key embeds the slug for
readability, and renaming a category rewrites that slug, so a URL already sent
to a client stopped matching and silently opened the gallery root. Resolution
now keys on the trailing category id, which does not move.

* fix(gallery): make folder navigation clickable in the Story layout (#1160)

Story renders `.story-nav` as `position: fixed` across the top of the viewport
at z-index 50, and the folder strip sits in exactly that band — so the nav
swallowed every click on the chips and the breadcrumb. A Story gallery whose
photos all live in folders had no way to reach them at all. Confirmed with
elementFromPoint at the chip's centre returning NAV.story-nav; the strip now
carries its own stacking context above it and the same probe returns the chip.

Story's footer download could also be offered with nothing to send: on a
folder-only root of a gallery that has a category download opt-out, the parent
deliberately withholds the whole-gallery callback and the scope is empty, so the
button would have posted an empty id list and taken a 400. It is only rendered
when one of the two actually exists.

* fix(gallery): stop the Story folder strip from blocking the layout's own nav (#1160)

The previous commit raised the whole folder strip above `.story-nav` so the
chips could be clicked, and thereby traded the bug for its mirror image: the
strip is mostly empty space, so as a solid z-60 container it swallowed the
clicks for Story's own search, favourites and logout sitting underneath.

The container no longer takes hits at all; only the chips, breadcrumb and
download button opt back in. The download button also loses its ml-auto, since
being pushed to the right put it physically on top of the nav's controls rather
than merely above them in stacking order.

Verified by hit-testing all three at once — folder chip, download button, and
Story's nav control each resolve to themselves under elementFromPoint, so none
is covering another.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-28 08:03:49 +02:00
Paul Nothaft cec8eff70c fix(images): fence the capture-date backfill on the file it read (#1201) (#1204)
The capture-date backfill committed its result keyed on the row id alone. It
snapshots every candidate up front, then walks them one at a time reading
originals off S3 or a NAS mount — a pass that can run for many minutes.

replacePhoto, reachable from the replace_by_name upload path, swaps a NEW file
under an existing row and rewrites path/filename. A replacement landing inside
that window carries no date of its own, so captured_at was still NULL, the
whereNull guard passed, and the previous file's EXIF date was written onto the
new photo. Silent: nothing errored, the run reported it as a success, and the
gallery just sorted that photo to the wrong place.

Fenced on path and filename as well as the id — the same fence #1199 put on the
orientation backfill for the same reason — so a replaced row matches zero rows
and is skipped. The candidate query already selects both columns, so no query
change. Knex renders a null value in the object form as `is null` on both the pg
and sqlite3 clients, so a row with a NULL path still matches itself.

Those skipped candidates are now counted rather than dropped. replacePhoto is
not the only writer of path/filename — eventRenameService rewrites both on an
event rename, which is not a content change — and another writer filling
captured_at first lands in the same place. Without a counter they fell out of
the run's arithmetic entirely: success + noExif + failed no longer added up to
the count the operator was shown when they started the job, on the card as well
as in the log.

The card shows the count only when it is non-zero, the same shape the
orientation job uses for staleTiers. The wording states what is known — changed
by something else, not updated — rather than promising a retry: for the
already-dated case there is nothing to retry, and the Missing Capture Date
figure above is what says whether work is left. Locale coverage matches the
staleTiers key (en, de, fr, sl), with the defaultValue carrying the rest.

Regression test: a replacement landing mid-run leaves captured_at NULL and is
not counted as updated. Verified to fail against the unfenced code.
2026-08-27 08:44:19 +02:00
Paul Nothaft da802169a8 fix(gallery): show colour labels in the Carousel layout (#1189) (#1196)
Carousel paints its own markup instead of going through PhotoCard, so it
inherited none of the colour-label treatment — not other viewers' marks from
#1178 and not the viewer's own from #1044. A photo the client flagged green
looked identical to one nobody had touched, in a layout a photographer can
select like any other. It has been missing since the feature landed.

Rendered in both places the carousel paints a photo. The thumbnail strip is
the one that matters: it is the only place the layout shows more than one photo
at a time, so it is the only place a label can actually be scanned.

Two small additions to ColorLabelBadge, both defaulting to today's behaviour so
every existing layout renders byte-identically:

- `size="sm"` shrinks the dots for the strip's 80px tiles, where the grid-sized
  20px dot plus three 10px ones covers most of the image.
- `position` is overridable because this layout has different corners free. Its
  top-left carries the counter and category chips and its top-right the
  play/fullscreen buttons, so the badge goes bottom-left on the main frame —
  the only corner left — and top-left in the strip, where nothing competes.

This is the per-layout position override #1178's review said would start to pay
for itself the first time a layout genuinely needed a different corner.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 21:13:52 +02:00
Paul Nothaft d3e9a7cf0d feat(auth): make the admin "Remember me" checkbox actually do something (#1186) (#1195)
The checkbox had no `checked`, no `onChange`, and no place in the login
request; `rememberMe` existed only as an i18n label. On the backend
establishAdminSession hardcoded `expiresIn: '24h'` and the cookie always got
DEFAULT_MAX_AGE_MS, so there was nothing to receive it anyway.

Wired end to end: state on the page, `remember_me` in the login body, and a
30-day JWT plus a matching 30-day cookie when it is set.

Opt-in on purpose. An absent or malformed value means "no", so a client that
never sends it keeps exactly the 24h session it always had, and a stolen cookie
is still worth a day by default.

The JWT and the cookie take their lifetime from the same flag. If they can
disagree the session either dies early (long cookie, short token) or outlives
what the user consented to, so the tests assert them against each other.

Review found the feature was non-functional as written, which is the important
part: sessionTimeoutMiddleware and isSessionExpired enforce
security_session_timeout_minutes — 60 minutes by default — against a session's
idle time regardless of how long its token lives, so a remembered admin was
logged out within the hour with a 30-day token sitting unused. rememberMe now
travels in the JWT payload and both checks exempt a remembered session from the
IDLE timeout. Not from expiry: the token still dies on its own 30-day exp, and
revocation, deactivation and password-change invalidation are untouched.

Also: /api/admin/auth/change-password reissued a hardcoded 24h token without
the flag, so a remembered admin dropped back to 24h the moment they changed
their password — which is mandatory for new and reset accounts. It now inherits
the choice from the session it replaces, carried on req.admin.rememberMe.

Through MFA the choice rides inside the signed mfa_pending token rather than
being resent, so the second leg cannot ask for longer than the first agreed to.

The tests drive POST /api/auth/admin/login and read the real Set-Cookie and
token rather than minting a local clone of the ternary they are meant to be
checking, boot one database per file before anything reads it, and generate
their credential per run so no literal that looks like a password lands in the
repository.

No visual change — the checkbox was uncontrolled, so it already toggled on
click; it just did nothing.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 21:13:23 +02:00
Paul Nothaft edef4d7365 fix(images): backfill orientation for libraries that predate the fix (#1199)
* fix(images): backfill orientation for libraries that predate the fix (#1198)

#1194 corrected the generators and every ingest path, but did nothing for
photos already in the database. Those rows end up worse than untouched ones:
before the fix a rotated photo was CONSISTENTLY wrong — a sideways image in a
tile shaped to match — and afterwards the regenerated thumbnail is correct
while photos.width/height still describe the raw sensor order, so masonry and
justified size a portrait photo with a landscape ratio. The dimension repair
cannot reach them: it only selects rows with a NULL dimension, and an affected
row has both, just transposed.

Its own job rather than a mode of that one. They look alike but are not the
same operation: the repair FILLS missing values and touches nothing else,
while this RECOMPUTES and invalidates the derived data generated against the
old orientation. Sharing a lease would also mean one blocks the other.

A first attempt at this was reverted from #1194 after review found five
problems. All five are addressed here:

- Originals are read through resolvePhotoStorageKey + withLocalCopy +
  withProcessableImage, so the job works on S3 installs and on RAW/DNG. The
  dimension repair's direct fs read does neither, which stops being an edge
  case in a job that walks the whole library.
- The canonical preview is cleared BEFORE faces are requeued.
  ensurePreviewImage returns a cached preview whenever it is still a valid
  image, and a pre-fix unrotated one is perfectly valid — so requeueing alone
  made the rescan read unrotated pixels and scale those boxes by the corrected
  dimensions, which is worse than leaving the data alone.
- Invalidation keys off the EXIF transform, not a dimension delta. Orientations
  2, 3 and 4 move every pixel while leaving width and height unchanged, as does
  5-8 on a square image; a delta check skips exactly those rows.
- Archived events are excluded — archiving deletes the originals and keeps the
  rows, so every one of them would fail its read.
- The dimension write and the invalidation share a transaction. Split, a
  failure between them leaves stale face data that no retry can fix, because
  the retry computes "already correct".

Tier deletion stays outside the transaction on purpose: it touches storage, and
a failed object delete must not roll back a correct database write. A leftover
tier regenerates on next read; a rolled-back write is silent corruption.

* fix(images): invalidate every stale rendition, fence the writes, and give the job a button (#1198)

Three things from review, one of which mattered a lot.

The invalidation was too narrow. Clearing only preview_path fixed the face
data and left the gallery worse off: ensureThumbnail and ensureHeroImage
return their cached file whenever it is merely VALID, and a pre-fix sideways
thumbnail is perfectly valid — so a corrected row rendered the old sideways
image inside a newly-corrected portrait tile. All three canonical renditions
are cleared now, their stored objects deleted, and both responsive tier sets
with them.

The responsive tiers also needed handling rather than a hopeful catch. Their
helpers swallow delete errors, and ensurePreviewImageAtWidth treats
storage.stat(key) as a cache hit — so a tier that survived deletion keeps
serving unrotated forever and never regenerates. The keys are re-checked after
deletion and survivors are counted into the result, so a run that could not
clear them does not report itself as clean.

Writes are fenced on the identity that was measured, not just the id.
replacePhoto swaps a new file under an existing row and rewrites
path/filename, and it IS reachable — from the replace_by_name upload path in
adminPhotos.js. A replacement landing while this job read the old original
would otherwise have had the previous file's dimensions written over it and
its fresh renditions cleared.

And the job had no way to start it: the endpoint existed with no caller, so an
upgrade would have left every affected library untouched unless an operator
found the API themselves. It gets a Status card like its two neighbours, with
strings in en/de/fr/sl. No backlog counter, because unlike the other two it
cannot know how many rows need it without doing the work.

* fix(images): make the backfill idempotent, and stop it lying about what it did (#1198)

Six things from review round 2.

The job was not idempotent, and the way it failed was expensive. Its trigger is
the EXIF tag on the ORIGINAL, which correcting a photo never changes — so every
re-run threw away the renditions it had just regenerated and requeued every
completed face scan. On a face-enabled install, running it twice meant
re-detecting the whole library for nothing. Migration 191 adds
photos.orientation_checked_at, written in the same transaction as the work it
records, with `force` as the escape hatch for an interrupted run.

The candidate query selected preview_path but not thumbnail_path or hero_path,
which the deletion loop reads — so those two pointers were cleared in the
database while the objects stayed in storage, still reachable through
previously issued URLs.

watermark_path was missed entirely. gallery.js serves it ahead of the original
when branding watermarking is on, which makes it the most visible rendition of
the lot. (Its generator needed rotating too — that went into #1185, where the
other three live.)

storage.stat() RESOLVES with null for a missing key rather than rejecting, so
counting "the promise settled" marked every deleted — and every never-created —
tier as a survivor. A perfectly clean run told the operator to re-run. Now a
null means gone, and a rejection counts as stuck, since a storage error is not
proof the object went away.

Face data is invalidated whenever the stored dimensions change, not only when
the change came from rotation: boxes are scaled by photo.width at read time, so
any dimension change strands them.

And `corrected` now comes from the affected-row count. If the fence rejected the
write because the file was replaced mid-run, the photo was not corrected and
the run must not claim it was.

* fix(images): stop the backfill doing unnecessary work, and make its retry advice true (#1198)

Round 3, four points, all narrower than the last two rounds.

It re-processed photos that were already correct. A 5-8 rotation changes the
dimensions, so a tagged photo whose stored dimensions are ALREADY oriented must
have been ingested after #1185 — its renditions are fine and clearing them
deletes valid files and rescans a completed face detection for nothing. Those
are now skipped and simply marked. Orientations 2, 3 and 4 (and 5-8 on a square
image) leave the dimensions identical either way, so they carry no such
evidence and are still done once.

The retry advice was impossible to follow. When a responsive tier could not be
deleted the row was still marked, so the ordinary re-run the UI recommends
found nothing and the stale tier kept serving unrotated forever. The marker is
withheld when a tier survives, which is what makes that message honest.

Storage cleanup now only runs when a fenced write actually landed. If the file
was replaced mid-run every update matched zero rows, but the deletion went
ahead anyway and could destroy renditions belonging to the REPLACEMENT —
watermarks especially, which are keyed by photo id and alias straight onto the
new file.

And the full-photo ETag includes the backfill's timestamp. It was built from
the ORIGINAL's mtime plus the watermark settings hash, neither of which this
job touches — so a guest holding a pre-fix ETag would go on getting 304 and
their cached sideways image no matter how many times the backfill succeeded.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 21:01:51 +02:00
Paul Nothaft 3991dc3ccb fix(admin): gate the dimension repair as system maintenance (#1182)
* fix(admin): gate the dimension repair as system maintenance (#1181)

The endpoint's candidate query is unscoped, so it walks every event in the
install, reads every original off S3 or the NAS mount, and rewrites their
metadata. It required only photos.edit, which the built-in team_photographer
preset holds (175_granular_permissions_and_presets.js:106) — a role that
exists for a contributing second shooter, not for someone who should be able
to start a whole-library scan or touch another owner's events.

Now system.manage, whose own description is "run system maintenance actions",
with the status endpoint on system.view to match. Nobody who should have it
loses it: super_admin is granted every permission, solo_photographer is
'ALL', and migration 175 already projects every settings.edit holder forward
onto system.manage on upgrade.

The capture-date sweep next to it was gated this way in #1179; this brings its
older twin in line.

* fix(admin): gate the dimension status card on the permission the button needs (#1181)

Same mismatch as the capture-date card: system.view and system.manage are
independent grants and StatusTab renders its card and enabled button purely on
a successful status payload (StatusTab.tsx:558), so a system.view-only role got
a live Repair button whose every click 403s.

* fix(admin): stop the dimension status card polling a 403 (#1181)

With the endpoint correctly requiring system.manage, anyone who can open the
Status tab but lacks it would have had a 403 and a logged denial every ten
seconds for a panel they were never shown. The query is now gated on the same
permission the endpoint requires, so it never starts.

* fix(admin): gate the dimension card's render on the permission too (#1181)

TanStack keeps the cached status after `enabled` flips false, so checking only
the payload would still show the card — and an enabled Repair button whose POST
403s — to a lower-privileged admin logging in behind a system.manage user
inside the cache lifetime.

* fix(admin): name the dimension-card permission flag for the card it gates (#1181)

#1179 adds a second system.manage-gated card to this same component with the
same flag name. Two identical declarations merge WITHOUT a conflict and then
fail to compile — TS2451, cannot redeclare block-scoped variable — and since
each PR is green on its own, nothing catches it until main's build breaks.

Verified by trial-merging both into main: no conflict, two declarations, tsc
fails on both lines. Naming this one for the card it gates removes the trap;
once both have landed the two flags can collapse into one.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:54:04 +02:00
Paul Nothaft 51d20c5920 fix(gallery): show other guests' colour labels in the grid (#1178) (#1180)
* fix(gallery): show other guests' colour labels in the grid (#1178)

A colour set by one guest was visible to others in the lightbox and invisible
on the tile. The lightbox reads /photos/:id/feedback, which returns per-colour
tallies across everyone; the grid reads /photos, whose payload carried only
`my_color_label` — so PhotoCard could render nothing else. The feature simply
was not extended to the grid.

/photos now also returns `other_color_labels`: the DISTINCT colours other
viewers put on each photo, gated on show_feedback_to_guests like every other
aggregate. `my_color_label` stays ungated, because a viewer's own selection is
not shared data — that distinction is unchanged.

Distinct colours rather than counts, and capped at three dots: a tile has room
for a couple of marks, and "who marked this, and how many" is a question the
lightbox already answers properly. The viewer's own colour is excluded from
the dots so the badge and the dots never say the same thing twice, and they
sit in opposite corners so they do not read as one group. The inset ring stays
the viewer's own signal, which is what the badge was built for.

Not addressed: the same issue asks for an identity-less shared colour tag —
one tag per photo that any guest can overwrite. Neither existing identity mode
does that (`simple` scopes by device fingerprint, `guest` by guest_id), so it
is a third model touching the feedback schema, the per-guest caps, moderation
and the admin aggregates. That is a feature with its own design, not part of
this fix.

* fix(gallery): carry other guests' labels into the premium and story grids too (#1178)

PhotoCard was not the only place the badge renders. GalleryPremiumLayout and
StoryPhotoCard have their own copies, and both still passed only
my_color_label — so the fix would have covered the default grid and left the
two full-bleed layouts showing nothing, which is the same shape of gap the
original bug had.

Found by driving a real gallery rather than reading the diff: the masonry grid
rendered the dots correctly, and a grep for the remaining call sites turned up
these two.

* fix(gallery): keep the other-viewers colour dots out of the contested corner (#1178)

The dots were placed bottom-left, which is the busiest corner in every
layout: Timeline paints a timestamp chip there on every tile, and Grid,
Mosaic and Masonry a media-type badge. All of them render after the badge,
so the dots sat underneath them.

Moved into a single row in the corner the colour-label dot already owns,
next to the viewer's own mark. Nothing new is contested, and the grouping
reads better anyway — your mark and everyone else's are the same kind of
information.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:53:52 +02:00
Paul Nothaft 849a5807b7 fix(admin): make "Storage used" report storage used (#1164) (#1170)
* fix(admin): make "Storage used" report storage used (#1164)

The tile summed photos.size_bytes — the catalogued size of the ORIGINALS,
which has no relationship to the disk PicPeak runs on. In reference mode those
files are never copied and sit on the NAS; duplicate rows counted the same
file twice (#1162); and it ignored everything PicPeak genuinely does write
locally: thumbnails, previews, hero renditions, watermarks and the per-event
download cache. The reporter's tile read ~80 GB against 21 GB of real usage.

Worse than the label: the same number drove the storage soft-limit warning bar
and, via /storage/info, the recommended soft limit — so a reference-mode
install got a disk-capacity recommendation computed from bytes that are not on
the disk.

- new localStorageUsage service walks the storage root and reports the total
  plus a breakdown. Walking rather than summing DB columns is the point:
  thumbnail/preview/hero rows record a key and never a byte count, and orphans
  from a deleted event or an interrupted import are real bytes. Symlinks are
  not followed, so a link into the media mount cannot put the NAS back in the
  total. Cached for 5 minutes, since the dashboard polls.
- the dashboard tile and /storage/info now report that, with the catalogued
  figure kept and labelled as such next to it. A failed measurement reads as
  "unavailable" rather than substituting a number that means something else.

On the local rig: 64.37 MB used against 15.75 MB catalogued, of which 27.9 MB
is watermarks and 6.8 MB is download cache — none of which the old figure
could see.

Not addressed here: `.download-cache/all.zip` still has no TTL or size cap. It
is now at least visible in the breakdown, which is what makes the case for
capping it.

* fix(admin): exclude the media share from local storage usage (#1164)

External review found the walk could reintroduce the exact over-count it
replaces.

EXTERNAL_MEDIA_ROOT's compose default is `<storage>/external-media`, where the
NAS is bind-mounted. That is a plain directory, not a symlink, so the symlink
guard did not cover it and the walk descended into the share — putting every
referenced original back into a figure whose whole purpose is to leave them
out, and comparing NAS bytes against statfs() of the local disk. On the
reference-mode installs this issue is about, that is the failure mode
reappearing inside its own fix.

The configured root is now skipped when it lies inside the storage root, and
the result reports which path was excluded. A directory that merely shares the
name is still counted, because those really are local bytes.

Also from the review:

- concurrent cold-cache callers now share one walk. /dashboard/stats,
  /storage/info and the sidebar are routinely requested together, and each was
  starting its own stat-per-file traversal of the whole library.
- storage_partial is surfaced in the StorageInfo type and the sidebar tile, not
  just the dashboard and analytics cards. An unreadable subtree makes the total
  a floor, and a floor silently compared against a soft limit reads as "safely
  under".

* fix(admin): do not report a disk walk on an S3 backend (#1164)

Second review round.

S3 installs were regressed. With STORAGE_BACKEND=s3 the originals, renditions,
archives and download caches are objects in the bucket and STORAGE_PATH holds
only incidental local files — so the walk reported near-zero and the soft-limit
recommendation was derived from it. Those installs now keep the catalogued
figure, which is the approximation they had before this PR, and the response
says which measurement it is (`storage_measurement: 'disk' | 'catalog'`) so the
UI labels it instead of implying a disk measurement that never happened.

The Settings → Status storage card ignored storage_partial, formatting a lower
bound as exact and deriving the limit percentage from it — so an unreadable
subtree could read as safely under the limit. It now carries the same `+`
marker as the sidebar and dashboard.

* fix(admin): stop rendering an absent measurement as zero usage (#1164)

Third review round, two findings.

The analytics storage bar coerced a null measurement to 0, drawing an empty
bar labelled "0% of limit" and suppressing the over-limit state — reading as
plenty of room at exactly the moment nothing is known. It now shows the
catalogued figure on S3, where that IS the available answer, and says "no
measurement available" rather than inventing a percentage when there is none.

/storage/info walked the filesystem before checking the backend and then threw
the result away on S3. The sidebar polls that endpoint, so a migrated install
still holding a large local tree paid a full stat-per-file traversal on every
cold cache for nothing. Gated before the walk, as the dashboard route already
was.

* fix(admin): tell "no disk to measure" apart from "the measurement failed" (#1164)

External review of the stable twin.

Both were reported as `storage_measurement: 'catalog'`, so a failed local walk
made the dashboard claim the objects live in S3. They are different things —
one is a fact about the install, the other is a fault — and there is now an
`unavailable` state for the second.

The analytics percentage could reach the billions. `safeSoftLimit` fell back to
`storageUsed || 1`, and on S3 that is null → 1, while the figure beside it came
from `catalogedBytes`. An editor or viewer holds `analytics.view` but not
`settings.view`, so `/storage/info` 403s for them and `storageInfo` is
undefined — which is exactly when that fallback fires. It now falls back to the
measured figure, and suppresses the percentage entirely when there is no real
limit rather than dividing usage by itself and always reading 100%.

Also lands the AnalyticsPage half of the previous round, which the commit
message claimed but the commit did not contain — only its backend counterpart
was staged. The stable twin has carried it since it was written, so this is the
parity gap in the unusual direction.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:53:39 +02:00
Paul Nothaft 1366d6d14c fix(previews): preserve alpha and animation in the preview tier (#1171)
* fix(gallery): stop the lightbox loading originals to display a photo (#1166)

The lightbox read `preview_url`, which the server only emits once an admin has
flipped lightbox_preview_enabled — off by default. So a stock install fell
straight through to `url`, the untouched original: a reporter measured 16.5 MB
for a photo whose preview is 345 KB. The lightbox renders its neighbours too,
so opening one photo pulled three originals.

`slideshow_url` is the same /preview/:id URL, watermark query included, and
has been emitted unconditionally for images since #1015 — the slideshow never
had a fallback worth taking. Preferring it fixes every existing install with no
migration and no admin action, and `url` still backstops videos, where both
derivative URLs are null.

Verified on the local rig with the toggle off, so the photos API returns
preview_url: null exactly as filed. Opening one photo:

  before   GET /photo/82, /photo/81, /photo/21      (3 originals)
  after    GET /preview/82?w=1280, /preview/81, /preview/21

397 KB -> 23 KB per image on that gallery's test photos.

The toggle no longer decides whether the lightbox uses previews, so its copy
said something untrue; it now describes what it still does, which is
pre-generate rather than wait for the first guest to open a photo. Updated in
en/de/fr/sl, the locales that carry those keys.

* fix(gallery): cover the layouts the lightbox fix missed (#1166)

External review found the fix was incomplete, and the review of it found one
more.

Premium galleries were untouched. PhotoGridWithLayouts returns early for
gallery-premium, which builds its own yet-another-react-lightbox slides with
`src: photo.url` — so those galleries kept pulling full originals and the
reported bandwidth problem remained. They now use lightboxImageUrl for the
display source; `download` deliberately stays on photo.url, because what a
guest saves must be the original.

The Story layout was worse, and neither the issue nor the review caught it:
StoryPhotoCard rendered the full original as its GRID TILE, at object-cover in
a small card. That is the one place where "hundreds of megabytes for a gallery"
was literally true. It now uses the per-device thumbnail tier like PhotoCard,
and its PhotoSwipe source uses the preview tier.

Animated GIFs keep the original. generatePreviewImage always encodes JPEG, so
routing an animated source through the preview tier would have replaced the
animation with its first frame — a regression the toggle-off default never
had. Animated WebP has the same problem and cannot be distinguished by MIME
alone; that needs the backend to report it (Sharp's `metadata.pages > 1`) and
is left rather than costing every static-WebP gallery the bandwidth fix.

The settings copy claimed too much. "Pre-generate lightbox previews" does not
generate anything on save — it unlocks the regenerate button and keeps
preview_url emitted. Reworded to say that, in en/de/fr/sl.

Not changed: the review's P1 said this bypassed the secure-image route on
enhanced/maximum galleries. It does not. AuthenticatedImage collects
requiresToken and secureUrlTemplate into an explicitly-voided unusedProps and
never substitutes {{token}}, so on those protection levels photo.url was a
literal `.../secure/82/{{token}}` that returns 400 — the lightbox was falling
back to the 300px thumbnail, not to a protected image. Verified against a live
maximum-protection gallery. Codex withdrew the finding on that evidence.

* fix(gallery): keep premium downloads working and story framing intact (#1166)

Second review round, three findings — two of them regressions this PR
introduced.

Premium Download became a no-op. handleDownloadFromLightbox recovered the
photo with `filteredPhotos.find(p => p.url === slide.src)`, and slide.src is a
derivative now, so the lookup found nothing and the button silently did
nothing. The slide carries the photo id and the handler resolves by that;
what Download hands over is still the original.

Story cards were reframed. thumbnail_fit is seeded to 'cover' on every
install, so thumbnails are square centre-crops — and story cards are not
square (400x500 in the carousel, fixed-height in the desktop grid), so the
card's own object-cover cropped them a second time and every photo shifted.
They now use the preview tier, which is fit:'inside' and therefore the whole
frame: the card looks exactly as it did before, without pulling an original.

APNG joins the animated-format guard. It declares image/apng and the preview
route would serve a static frame. Animated WebP still cannot be detected from
MIME and remains the documented gap.

* fix(gallery): keep PNG on the original, alpha and all (#1166)

Third review round.

generatePreviewImage encodes JPEG, which drops ALPHA as well as animation — a
transparent PNG came back flattened against a solid background. And an APNG is
normally reported as image/png, so the image/apng check alone missed the
common upload path. PNG now stays on the original: it is where transparency is
the norm, and rare enough in an event gallery that the bandwidth given up is
small.

Animated or alpha WebP still cannot be detected from MIME and remains the
documented gap; it needs the backend to report Sharp's `pages`/`hasAlpha`.

Two further findings are acknowledged and deferred rather than fixed here:

- Story cards now request /preview on mount, so a cold gallery generates its
  previews in one burst. That is a new CPU cost, not a regression — those cards
  previously fetched full ORIGINALS on mount, which is strictly worse. Doing it
  properly means viewport-gating AuthenticatedImage, which is a change to a
  component every gallery surface uses and belongs in its own PR.
- The premium layout memoizes slide URLs, so rotating the device before opening
  the lightbox can leave a photo on the tier chosen for the old geometry. The
  result is a slightly undersized image, and the fix is a resize subscription
  this PR does not otherwise need.

* fix(gallery): load Story images on approach, and give the hero its own tier (#1166)

Every card in a Story gallery mounts at page load — `whileInView` gates the
animation, not the render — and AuthenticatedImage fetches from an effect on
mount, so all of them requested at once. That was tolerable while they pointed
at photo.url, because nothing was generated; pointing them at the preview tier
meant a gallery with cold previews would Sharp-decode every original in one
burst. The image now waits until the card is within 200px of the viewport,
using framer-motion's useInView — the same observer the entrance animation
already relies on — with `once` so a card never unloads on scroll-away.

Verified on a 62-photo Story gallery: 3 images fetched at load, growing to 15
as you scroll, where all 62 would have fired before.

While confirming that, the hero turned out to be doing the same thing the
cards were. StoryHero rendered photo.url as a full-bleed object-cover
background — a full original on the critical path for first paint of every
Story gallery — when hero_url exists for exactly this and is a 1920x1080 cover
crop emitted unconditionally for every photo (gallery.js:1139).

That gallery now issues no /photo/ request at all: hero_url for the hero,
the preview tier for the cards, and only as they come into range.

* fix(previews): preserve alpha and animation in the preview tier

Follow-up to #1166, which had to bypass the preview tier for GIF, APNG and PNG
to avoid a visible regression. This removes the cause.

generatePreviewImage encoded JPEG unconditionally. JPEG has no alpha channel
and no second frame, so a transparent PNG came back flattened onto a solid
background and an animated GIF came back as its first frame — for every
consumer of this tier, not just the lightbox: the slideshow (#1015), admin
previews, and the face avatars that read it as a whole-frame rendition. It was
only invisible by default because the lightbox served originals.

Sources with alpha, or more than one page, are now encoded as WebP, which
carries both and is still far smaller than the original. Ordinary photos stay
JPEG — the common path pays nothing.

Two things had to move with it:

- The output extension now matches what was written. A PNG source previously
  produced `preview_foo.png` holding JPEG bytes; harmless while the route
  hard-coded image/jpeg, wrong once the encoding varies. Existing keys keep
  working — they are still JPEG and still served as such.
- The preview route derives Content-Type from the key. With `nosniff` set,
  mislabelling would show a broken image rather than being silently corrected.
  The watermark branch re-encodes to JPEG, so it labels itself explicitly;
  preserving animation through the watermark compositor is a separate problem.

The frontend guess-by-MIME goes away entirely — including the case it could
never get right, since a still and an animated WebP declare the same type.

Verified on the local rig: a transparent PNG round-trips as
`Content-Type: image/webp`, `hasAlpha: true`, 8.3 KB; an ordinary photo still
serves `image/jpeg` from a `.jpg` key.

* fix(previews): retire the legacy preview keys, and stop mislabelling watermarked ones

External review of the stable twin found two defects, both on this branch too.

Legacy keys collide with the new naming. The old generator kept the SOURCE
basename verbatim while always writing JPEG, so a `.webp` upload produced
`previews/preview_shot.webp` holding a JPEG. My PR body claimed "pre-existing
keys have no .webp suffix and are JPEG" — that was simply wrong. The route now
derives Content-Type from the key and the response carries nosniff, so every
photo uploaded as WebP would have rendered as a broken image in the lightbox.
Legacy `.png` keys are wrong the other way: flattened JPEGs of what may have
been transparent sources, which isPreviewValid would have let stand forever.

Migration 188 clears photos.preview_path outright — all of it, not just the
suspicious extensions, because a `.jpg` key can equally be a flattened
rendition and nothing in the key says so. Previews regenerate lazily on next
view under the new encoder, so the cost is one regeneration per photo actually
viewed. Storage is untouched, as elsewhere.

The watermark branch mislabelled its output. applyWatermark PRESERVES the
source format (watermarkService.js:200-211: png stays png, webp stays webp),
and its input is the preview — so the output already matches the key the
header was derived from. Forcing image/jpeg mislabelled every watermarked WebP
preview, and nosniff means the browser would not correct it. The override is
gone; the animation loss through the compositor is documented where it
happens.

* fix(gallery): make the Story hero fix actually work on external galleries (#1166)

External review of the stable twin, both applying here too.

hero_url was inert for external media. ensureHeroImage only ever called
resolvePhotoStorageKey, which returns null for external/reference photos by
design — and that null was handed straight to withLocalCopy, which throws, so
the hero route caught it and redirected to the full ORIGINAL. #1078 fixed
exactly this shape for ensurePreviewImage and nobody carried it across. It
stayed invisible until this PR pointed the Story hero at hero_url: on a
managed gallery that is a real saving, on a reference-mode gallery it quietly
changed nothing. ensureHeroImage now has the same external branch
ensurePreviewImage does — direct fs read, per-photo output basename — and
returns null instead of throwing for a reference-mode row with no
source_origin.

The format bypass trusted mime_type, which is not trustworthy here. Migration
039 backfilled every pre-existing photo to image/jpeg regardless of what it
was, and adminExternalMedia inserts rows with no mime_type at all — so a
mislabelled PNG sailed past the guard and came back flattened. It now checks
the filename extension as well.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:53:05 +02:00
Paul Nothaft 77953c15c1 fix(gallery): stop the lightbox loading originals to display a photo (#1166) (#1169)
* fix(gallery): stop the lightbox loading originals to display a photo (#1166)

The lightbox read `preview_url`, which the server only emits once an admin has
flipped lightbox_preview_enabled — off by default. So a stock install fell
straight through to `url`, the untouched original: a reporter measured 16.5 MB
for a photo whose preview is 345 KB. The lightbox renders its neighbours too,
so opening one photo pulled three originals.

`slideshow_url` is the same /preview/:id URL, watermark query included, and
has been emitted unconditionally for images since #1015 — the slideshow never
had a fallback worth taking. Preferring it fixes every existing install with no
migration and no admin action, and `url` still backstops videos, where both
derivative URLs are null.

Verified on the local rig with the toggle off, so the photos API returns
preview_url: null exactly as filed. Opening one photo:

  before   GET /photo/82, /photo/81, /photo/21      (3 originals)
  after    GET /preview/82?w=1280, /preview/81, /preview/21

397 KB -> 23 KB per image on that gallery's test photos.

The toggle no longer decides whether the lightbox uses previews, so its copy
said something untrue; it now describes what it still does, which is
pre-generate rather than wait for the first guest to open a photo. Updated in
en/de/fr/sl, the locales that carry those keys.

* fix(gallery): cover the layouts the lightbox fix missed (#1166)

External review found the fix was incomplete, and the review of it found one
more.

Premium galleries were untouched. PhotoGridWithLayouts returns early for
gallery-premium, which builds its own yet-another-react-lightbox slides with
`src: photo.url` — so those galleries kept pulling full originals and the
reported bandwidth problem remained. They now use lightboxImageUrl for the
display source; `download` deliberately stays on photo.url, because what a
guest saves must be the original.

The Story layout was worse, and neither the issue nor the review caught it:
StoryPhotoCard rendered the full original as its GRID TILE, at object-cover in
a small card. That is the one place where "hundreds of megabytes for a gallery"
was literally true. It now uses the per-device thumbnail tier like PhotoCard,
and its PhotoSwipe source uses the preview tier.

Animated GIFs keep the original. generatePreviewImage always encodes JPEG, so
routing an animated source through the preview tier would have replaced the
animation with its first frame — a regression the toggle-off default never
had. Animated WebP has the same problem and cannot be distinguished by MIME
alone; that needs the backend to report it (Sharp's `metadata.pages > 1`) and
is left rather than costing every static-WebP gallery the bandwidth fix.

The settings copy claimed too much. "Pre-generate lightbox previews" does not
generate anything on save — it unlocks the regenerate button and keeps
preview_url emitted. Reworded to say that, in en/de/fr/sl.

Not changed: the review's P1 said this bypassed the secure-image route on
enhanced/maximum galleries. It does not. AuthenticatedImage collects
requiresToken and secureUrlTemplate into an explicitly-voided unusedProps and
never substitutes {{token}}, so on those protection levels photo.url was a
literal `.../secure/82/{{token}}` that returns 400 — the lightbox was falling
back to the 300px thumbnail, not to a protected image. Verified against a live
maximum-protection gallery. Codex withdrew the finding on that evidence.

* fix(gallery): keep premium downloads working and story framing intact (#1166)

Second review round, three findings — two of them regressions this PR
introduced.

Premium Download became a no-op. handleDownloadFromLightbox recovered the
photo with `filteredPhotos.find(p => p.url === slide.src)`, and slide.src is a
derivative now, so the lookup found nothing and the button silently did
nothing. The slide carries the photo id and the handler resolves by that;
what Download hands over is still the original.

Story cards were reframed. thumbnail_fit is seeded to 'cover' on every
install, so thumbnails are square centre-crops — and story cards are not
square (400x500 in the carousel, fixed-height in the desktop grid), so the
card's own object-cover cropped them a second time and every photo shifted.
They now use the preview tier, which is fit:'inside' and therefore the whole
frame: the card looks exactly as it did before, without pulling an original.

APNG joins the animated-format guard. It declares image/apng and the preview
route would serve a static frame. Animated WebP still cannot be detected from
MIME and remains the documented gap.

* fix(gallery): keep PNG on the original, alpha and all (#1166)

Third review round.

generatePreviewImage encodes JPEG, which drops ALPHA as well as animation — a
transparent PNG came back flattened against a solid background. And an APNG is
normally reported as image/png, so the image/apng check alone missed the
common upload path. PNG now stays on the original: it is where transparency is
the norm, and rare enough in an event gallery that the bandwidth given up is
small.

Animated or alpha WebP still cannot be detected from MIME and remains the
documented gap; it needs the backend to report Sharp's `pages`/`hasAlpha`.

Two further findings are acknowledged and deferred rather than fixed here:

- Story cards now request /preview on mount, so a cold gallery generates its
  previews in one burst. That is a new CPU cost, not a regression — those cards
  previously fetched full ORIGINALS on mount, which is strictly worse. Doing it
  properly means viewport-gating AuthenticatedImage, which is a change to a
  component every gallery surface uses and belongs in its own PR.
- The premium layout memoizes slide URLs, so rotating the device before opening
  the lightbox can leave a photo on the tier chosen for the old geometry. The
  result is a slightly undersized image, and the fix is a resize subscription
  this PR does not otherwise need.

* fix(gallery): load Story images on approach, and give the hero its own tier (#1166)

Every card in a Story gallery mounts at page load — `whileInView` gates the
animation, not the render — and AuthenticatedImage fetches from an effect on
mount, so all of them requested at once. That was tolerable while they pointed
at photo.url, because nothing was generated; pointing them at the preview tier
meant a gallery with cold previews would Sharp-decode every original in one
burst. The image now waits until the card is within 200px of the viewport,
using framer-motion's useInView — the same observer the entrance animation
already relies on — with `once` so a card never unloads on scroll-away.

Verified on a 62-photo Story gallery: 3 images fetched at load, growing to 15
as you scroll, where all 62 would have fired before.

While confirming that, the hero turned out to be doing the same thing the
cards were. StoryHero rendered photo.url as a full-bleed object-cover
background — a full original on the critical path for first paint of every
Story gallery — when hero_url exists for exactly this and is a 1920x1080 cover
crop emitted unconditionally for every photo (gallery.js:1139).

That gallery now issues no /photo/ request at all: hero_url for the hero,
the preview tier for the cards, and only as they come into range.

* fix(gallery): make the Story hero fix actually work on external galleries (#1166)

External review of the stable twin, both applying here too.

hero_url was inert for external media. ensureHeroImage only ever called
resolvePhotoStorageKey, which returns null for external/reference photos by
design — and that null was handed straight to withLocalCopy, which throws, so
the hero route caught it and redirected to the full ORIGINAL. #1078 fixed
exactly this shape for ensurePreviewImage and nobody carried it across. It
stayed invisible until this PR pointed the Story hero at hero_url: on a
managed gallery that is a real saving, on a reference-mode gallery it quietly
changed nothing. ensureHeroImage now has the same external branch
ensurePreviewImage does — direct fs read, per-photo output basename — and
returns null instead of throwing for a reference-mode row with no
source_origin.

The format bypass trusted mime_type, which is not trustworthy here. Migration
039 backfilled every pre-existing photo to image/jpeg regardless of what it
was, and adminExternalMedia inserts rows with no mime_type at all — so a
mislabelled PNG sailed past the guard and came back flattened. It now checks
the filename extension as well.

* test(gallery): the hero fixture follows the root-relative relpath contract (#1166)

external_relpath has been resolved from EXTERNAL_MEDIA_ROOT rather than from
event.external_path since #1163 landed. This fixture still carried the
base-relative form — its own comment noted the change was 'a separate stack' —
so the two tests stopped resolving and ensureHeroImage returned null the moment
that stack merged. The production path was never affected.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:46:29 +02:00
Paul Nothaft 410b8f8f6f fix(external-media): record capture dates on import, and backfill existing libraries (#1172) (#1179)
* fix(external-media): record capture dates on import, and backfill existing libraries (#1172)

External imports never read EXIF, so photos.captured_at stayed NULL for every
row they created. The gallery sorts "Date Taken" with
COALESCE(captured_at, uploaded_at), which on a bulk import is the import
timestamp — so the sort silently degraded into "order by import batch" with no
error and nothing in the UI to say the sort key was missing. The reporter's
12-day trip came back with its first two days at positions 4204-5296 of 5555,
because those folders happened to be imported second.

- the import reads the capture date next to the sharp().metadata() call that
  already opens the file, so this costs one more read of the same source rather
  than a second pass over the mount. Best-effort like the dimensions: a source
  without EXIF imports with captured_at NULL, as before.
- POST /api/admin/photos/repair-capture-dates backfills existing libraries,
  modelled on the dimension repair beside it — background pass, in-flight
  guard, status endpoint, and resolvePhotoFilePath, which is what reaches an
  external row at all. Not a migration: the originals sit on a mount that may
  be down at upgrade time, reading 8000+ of them would block the boot, and a
  run that found nothing has to be repeatable.
- "no EXIF date" is counted separately from "could not read the file". An
  operator needs to tell "these files carry no date" from "the mount is
  broken" before deciding to re-run.
- the update is guarded whereNull, so an import finishing mid-run is not
  overwritten by a slower pass.
- every sort branch now carries photos.id as a tiebreaker, not just
  capture_date. A bulk import writes hundreds of rows inside one second, so
  uploaded_at and the COALESCE fallback both collapse and the grid reshuffles
  between loads. id is insertion order, which makes the fallback meaningful.

Not addressed: extractCaptureDate reads no OffsetTimeOriginal, and exifr
resolves a naive EXIF timestamp against the HOST timezone — so captured_at is
not a true instant, and the same file imported on two machines yields two
values. That predates this and applies to managed uploads equally; the tests
here deliberately assert ordering rather than an absolute instant so they do
not encode the bug. Worth its own issue.

* fix(capture-dates): read managed originals through storage, skip archived, claim the run flag (#1172)

Four holes in the backfill endpoint, all found in review:

- Managed photos were resolved with resolvePhotoFilePath, which builds a
  STORAGE_PATH filesystem path. On an S3 install nothing is there, so every
  managed row failed. Now split the way the thumbnail regenerator does:
  external rows read from the mount directly, managed rows go through
  resolvePhotoStorageKey + withLocalCopy.
- Archived events keep their photos rows but their originals are deleted on
  archive, so those rows failed every run and kept the button lit forever.
  Excluded from both the job and the status counts.
- isRunning was claimed after the candidate query, so two concurrent POSTs
  could both pass the guard and start a pass. Claimed before the await, with
  every early exit releasing it.
- The noExif comment promised a distinction extractCaptureDate does not make
  (it returns null for unreadable files too). Reworded to what it is.

* chore: drop a stray node_modules symlink committed by mistake

The .gitignore pattern is `node_modules/`, which matches a directory and
not a symlink of the same name, so a local convenience link slipped past it.
It pointed at an absolute path on one machine and would dangle everywhere
else, breaking `cd backend && npm install`.

* fix(capture-dates): gate the backfill as system maintenance, stop overstating the counters (#1172)

The endpoint walks every event in the install and rewrites their metadata,
but required only photos.edit — which the built-in team_photographer preset
holds (175_granular_permissions_and_presets.js:106). That role exists for a
contributing shooter, who should not be able to start a whole-library S3/NAS
scan or touch another owner's photos. Now system.manage, with the status
endpoint on system.view so the panel simply stays hidden for everyone else.

The "without EXIF date" wording also promised a distinction the code does not
draw: extractCaptureDate returns null for an unparseable file as well as for
one that genuinely carries no date, so both land in that bucket. Reworded to
"no date found" / "unreachable" in en, de and fr, which is what the two
numbers actually separate.

* docs: point the permission note at the follow-up PR (#1172)

The dimension repair's matching gate landed in #1182, so the comment no
longer needs to describe it as unaddressed.

* fix(i18n): align the Slovenian capture-date wording with the other locales (#1172)

sl was missed when the counters were reworded from 'without EXIF date' /
'unreadable' to what they actually measure.

* fix(capture-dates): gate the status card on the permission the button needs (#1172)

system.view and system.manage are independent grants, and StatusTab has no
permission gate of its own — a successful status payload is what renders the
card and its enabled button (StatusTab.tsx:637). Gating the status endpoint on
system.view therefore handed a system.view-only role a live Backfill button
whose every click 403s, with no error surfaced by the mutation.

The comment above it already claimed this endpoint matched the POST. Now it
does.

* fix(gallery): make the Date Taken sort correct on SQLite (#1172)

photos.captured_at does not hold one type on SQLite. Three writers put three
different things in it:

  integer  managed uploads — photoProcessor.js:488 hands knex a Date, which the
           sqlite3 binding stores as epoch milliseconds
  text     external imports and the backfill, which write ISO-8601
  null     no capture date, so the sort falls through to uploaded_at, itself
           text in knex's 'YYYY-MM-DD HH:MM:SS' default shape

A plain COALESCE over that is not an ordering. SQLite sorts INTEGER before TEXT
unconditionally, so every managed photo carrying EXIF came back ahead of every
photo that did not, whatever the dates said — a 2027 capture landing before a
2020 one. Among the text values 'T' (0x54) also outranks the space (0x20), so a
same-day ISO 01:15 sorted behind a fallback 23:00.

Both failures predate this branch — the first needs only two managed photos —
but making that sort correct is what #1172 is about, so it is fixed here rather
than left for the issue it belongs to.

Normalised in the ORDER BY rather than by rewriting the column: the data fix
would have to touch every existing row and every writer, which is a far heavier
change than the sort it corrects. The cost is that this sort no longer uses
idx_photos_captured_at on SQLite — an acceptable trade on the fallback engine,
where the alternative is an index-assisted wrong answer. Postgres is untouched:
captured_at is a real timestamp there and COALESCE already compares correctly.

The regression tests drive the real gallery route on real SQLite. They write
the epoch-millisecond integer directly, because the Date that produces it in
production cannot be reproduced inside jest — there the binding's type dispatch
misses sandbox Dates and stores "[object Object]" (CLAUDE.md). All four
behavioural tests fail on the unfixed ORDER BY; verified by reverting it.

* fix(gallery): normalise epoch-integer uploaded_at too, and stop polling a 403 (#1172)

Two follow-ups from review.

uploaded_at is not always text on SQLite either. A legacy archive restore
leaves epoch milliseconds in it — there is a test pinning exactly that
(__tests__/integration/sqliteEpochTimestamps.test.js) — and the fallback branch
read it with substr(), so '1830297600000' was compared against
'2020-01-01 00:00:00' as text and a 2028 upload sorted first. Both columns now
get the integer/real branch.

The status card also polled every ten seconds regardless of permission. With
the endpoint correctly requiring system.manage, anyone who can open the Status
tab but cannot run the job would have had a 403 and a logged denial every ten
seconds for a panel they were never shown. The query is now gated on the same
permission the endpoint requires, so it never starts.

* style: quote convention in the capture-sort test (#1172)

* fix(capture-dates): skip watcher-imported videos, and make the status counts consistent (#1172)

Three follow-ups from review.

fileWatcher.processNewPhoto sets type='video' and a video/* mime but never
media_type (fileWatcher.js:128-130), so those rows keep the 'image' default
from migration 048. Filtering on media_type alone queued every such video on
every run — extractCaptureDate returns null for a video, captured_at stays
null, and the backlog never cleared. Candidate query and status scope now check
all three markers.

The status counts were two separate queries, so an import committing a dated
photo between them could be counted by the second and not the first: the card
then showed withCaptureDate > total and a negative backlog, with the button
enabled to "fix" it. One aggregate now.

And the card's render checked only the cached payload. TanStack keeps that
after `enabled` flips false, so a lower-privileged admin logging in behind a
system.manage user inside the cache lifetime would still have seen the card and
a button whose POST 403s. The permission is part of the render condition now.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:43:13 +02:00
Paul Nothaft a7b74bcd87 fix(external-media): store external paths from the media root (#1163) (#1168)
* fix(external-media): store external paths from the media root (#1163)

Importing a second folder into an event silently invalidated every photo
already in it. photos.external_relpath was stored relative to
events.external_path, and every import overwrites that column — so the older
rows were rebased onto the new folder and their originals resolved to paths
that do not exist.

Nothing errored, and the grid still looked intact: thumbnails are written to
local storage during the import while the base path is still correct. Only
what needs the original broke — preview generation, the lightbox, downloads —
which presents as a gallery that looks slow rather than one that is broken.
The reporter had 7547 of 8004 rows pointing into the void and spent a while
chasing it as a CPU problem.

- external_relpath is now relative to EXTERNAL_MEDIA_ROOT, so a row is
  self-describing and nothing an admin does to the event afterwards can move
  an already-imported photo.
- migration 187 folds each event's base path into its rows. Where the current
  resolution is missing on disk it walks up the base path for an ancestor
  under which the file IS there — the already-rebased case — and where it
  finds nothing it leaves the row resolving exactly where it resolves today.
  Skipped entirely when the media root is unmounted, since every file looks
  missing then.
- the fold also runs after a .picpeak restore: knex_migrations is excluded
  from the archive, so a pre-#1163 backup would otherwise land base-relative
  rows on a migrated instance.
- drops the duplicate-leaf-segment guess in photoResolver. It papered over
  this same double-prefixing and actively corrupts a root-relative path whose
  first segment legitimately repeats (base 'Trip', row 'Trip/x.jpg').

* fix(external-media): verify provenance and fold atomically (#1163)

External review found four real defects in the fold.

Repair could adopt the wrong file. Existence alone was accepted as proof that
an ancestor candidate was the row's original — so a row whose file an admin
simply deleted would adopt any same-named file one directory up (base
`Trip/Sub`, relpath `photo.jpg`, an unrelated `Trip/photo.jpg`), and downloads
would then serve a different photo. Worse than a dead link. An ancestor must
now also match photos.size_bytes, which the import recorded from the very file
the row describes; rows carrying no size are never repaired from an ancestor.
The CURRENT base is still accepted on existence alone, because nothing is
being inferred there — that is where the row already resolves.

The fold was not atomic. Every UPDATE committed independently and the marker
came last, so a process killed mid-fold left converted and unconverted rows
with no marker — and the next run folded the converted ones a second time,
putting every original one directory deeper with no undo. Probing is now a
read-only first phase (so a slow cold NAS does not hold a write transaction
open), and every rewrite plus the marker commit together.

Failed rewrites certified a partial conversion. The per-row catch counted any
error as a collision, carried on, and wrote the marker anyway — leaving that
row in the old format for a resolver that now reads it differently. It also
could not tell a genuine duplicate from a SQLite lock or I/O fault. Target
collisions are now resolved in the planning phase, where they can be
identified honestly, and a write that fails rolls the whole fold back.

Restore ordering. The fold ran after the face requeue, with the worker live —
so a worker could claim an external row while it was still base-relative,
resolve it against the wrong path, and burn it to 'failed', a state only an
explicit Re-scan clears. The fold now runs first, for the same reason the
requeue already sat after restoreFiles.

* fix(external-media): close the fold's remaining stranding paths (#1163)

Second review round, three findings.

A collision loser was left stranded. When an event imported one file through
both `Trip` and `Trip/Sub`, two rows folded to the same path and the loser was
skipped — keeping a base-relative value that the root-only resolver then reads
as `<root>/<relpath>`, permanently wrong, with the marker claiming conversion
was complete. It is a duplicate by construction, so it now goes through
migration 186's deleteDuplicatePhotos, which reparents its feedback and marks
and reconciles the face clusters instead of orphaning them. This branch is
rebased onto #1162 for that helper.

The other restore path had the same face-ordering bug. restoreService queued
face scans in step 6, before step 7c runs pending migrations — so a pre-187
full or database restore handed the live worker rows whose paths were still
event-relative, and it burned them to 'failed', a state the later fold does
not clear. The requeue now happens after the migrations, where the files
already are.

A failed conversion was reported as a clean restore. The fold is
transactional, so a failure leaves every external path in the old format under
a resolver that reads from the media root — every original unreachable. It was
logged as a warning and the restore returned success. It now returns
externalPathsConverted/externalPathError, and suppresses the face requeue,
which would otherwise mark those photos failed on top.

* fix(external-media): make the fold safe against its own intermediate states (#1163)

Third review round, four findings.

A one-pass rewrite could collide with itself. Every FINAL path is distinct,
but a final value can equal another row's CURRENT one — `photo.jpg` repairing
to `Trip/photo.jpg` while the row already holding `Trip/photo.jpg` folds
deeper — so the update violated migration 186's unique index halfway through.
On Postgres that surfaces as 23505, which run-migrations-safe.js mistakes for
"schema already exists" and records 187 as applied after the rollback, leaving
every path unconverted with nothing to retry. Rows now park on a per-row
staging value first, and migration 187 re-throws without the driver's code so
the runner cannot misread it.

The bulk update targeted rows the plan never saw. Phase 1 probes outside the
transaction and can run for minutes; an import finishing in that window
inserts an already root-relative row, and `where event_id` prefixed it again
with the stale base. It now updates by the ids phase 1 captured.

The restore UI never showed a conversion failure. The API carried
externalPathsConverted, but PicpeakBackupCard neither declared nor read it and
showed a green success either way — so an admin whose external originals were
all unreachable was told the restore worked.

restoreService requeued faces even when the migrations failed. The step 7c
catch is deliberately non-fatal, so a pre-187 backup whose fold never ran
still handed the live worker event-relative paths to burn to 'failed'.

* fix(external-media): the fold's staging value must be storable on Postgres (#1163)

External review of the stable twin caught this, and it was on both branches.

The two-pass rewrite parks each row on a temporary value, and that value was
written with a leading NUL. SQLite stores NUL in TEXT without complaint;
Postgres rejects it outright — "invalid byte sequence for encoding UTF8: 0x00"
— so migration 187 rolled back on exactly the installs that need the two-pass
repair, and only on the engine most of them run. Restores hit the same wall
and reported the conversion as failed.

The prefix is ordinary text now. It still cannot collide with a real relative
path and is still obviously wrong if a crash leaves one behind.

Adds a gated Postgres test alongside the existing picpeakRestorePg one,
because a SQLite-only suite structurally cannot catch this class: restoring
the NUL makes exactly the two-pass repair case fail with that error, and
nothing else.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:36:45 +02:00
Paul Nothaft e4a8be8e7e fix(gallery): no Logout button on galleries that don't require a password (#1149) (#1152)
showLogout was hard-coded true, so a gallery with no password showed a Logout button. Logging out of it is meaningless — no credential to drop, nothing to return to — and it stranded the visitor: GalleryPage's auto-login is a one-shot latch, so clearing the session left the page on its skeleton until a manual reload. That is the 'turns blank' in the report.

The button is gated on requiresPassword || isClient || viaCustomer at both call sites. The full-page layouts render it on the callback being present rather than on a flag, so withholding the callback is how the gate reaches them.

Session kind now comes from /auth/session rather than sessionStorage, which is per-tab while the cookie is per-browser: a gallery reopened in a second tab lost 'client' while the backend kept serving it as one. viaCustomer marks a portal token, which bypasses reveal mode and so is a credential that does not look like one.

The public-gallery branch no longer returns the skeleton unconditionally — once auto-login has run and left us unauthenticated it shows the reason and a Retry. That state was otherwise unrecoverable, and it also swallowed loginError entirely.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:06:47 +02:00
Luca e2844d1909 feat(gallery): colour labels for client proofing, and one global default per feedback type (#1044) (#1137)
Colour labels for client proofing, plus the photographer's own stars and colours in the admin grid.

- Guest colour labels alongside likes/reactions, opt-in per event (defaults off so live galleries do not change mid-proofing), with 'colors' and 'lightroom' keybind schemes.
- One global default per feedback type, replacing the per-type scatter.
- Admin marks live in their own table (photo_admin_marks) so they can never reach a guest-facing surface.
- XMP export prefers a real label, keeping the rating-derived mapping as a fallback.

Review: concurrent-write loss on the mark update path, migration index idempotency and error classification all fixed in 7139bcae; migrations renumbered to 182/183 in 8fecdfae after 180/181 were taken on main.

Merged with admin privileges: bypass-size-gate is a required check that fails on size alone for review-bypass authors and never re-evaluates on review, which is its designed behaviour once a maintainer has approved.
2026-08-23 11:15:01 +02:00
Paul Nothaft 3583c924da feat(faces): consolidate look-alike clusters after a scan, and suggest the rest (#1107)
consolidate() has existed since #1074 and described this exact symptom in its own
comment, but its only caller was recluster() — i.e. when an admin pressed
Re-group people. After a normal background scan the centroids converged and
nobody looked, so a gallery settled with 14 people that should have been 8.

It now runs when a scan drains. There is no scan-finished event to hook, so an
idle worker asks whether the events it touched have actually drained — 'a worker
went idle' is deliberately not treated as sufficient, because with concurrency
above one the others may still be working.

The uncertain band asks instead of acting: pairs between the assignment
threshold and the stricter auto-merge one surface as accept/dismiss suggestions,
with sticky dismissals. Nothing merges silently — a pass that merged anything
reports it and points at Split.

Review rounds hardened it against overruling explicit decisions: it no longer
absorbs ignored clusters (mergePeople ORs is_ignored onto the survivor, which
would have hidden a real person), no longer merges dismissed pairs, no longer
undoes a manual Split (which now records a separation), and no longer runs after
detection is switched off. The dismissal read fails closed, a failed pass is
retried with backoff rather than lost or hot-looped, and the new table follows
event_people out of exports and backups.

Name autocomplete needs no endpoint — the people list already open is the source,
and it is event-scoped on purpose.

Known limitation, tracked in #1132: separations are keyed on person ids, so a
full re-scan loses them.

Reported by @BraynArts.
2026-08-22 21:39:15 +02:00
Paul Nothaft 25fbefc703 docs(faces): link the face-recognition guidance from where people look (#1125)
Every other feature routes readers to docs.picpeak.app. Face recognition was the
one that either pointed somewhere else or pointed at nothing — poor placement
for the feature with the highest read-before-you-enable burden anything here
ships.

.env.example referenced docs/feature-face-recognition.md, which does not exist —
and creating it is not the fix, because .gitignore:89 ignores docs/feature-*.md
outright, so the file would be invisible to anyone who cloned. That was the only
pointer to legal guidance an operator got while editing the variables that turn
Art. 9 processing on.

Also: the README linked the sidecar's developer README for the feature name and
had no row in the documentation table, docs/single-container.md left readers who
wanted the feature nowhere to go, ml/README.md had no backlink, and the admin
consent callout had no link at all. It does now, inline at the end of the
obligation.

Reported by @Luca-Timo.
2026-08-22 21:38:25 +02:00
Paul Nothaft 87115b28e8 fix(gallery): give masonry tiles their real shape back (#1130, #1131)
Two independent causes of the same symptom — an aspect-ratio layout that does
not lay anything out.

gallery-premium discarded the tile height MasonryPhotoAlbum computed from
photos.width/height and set height:auto on both card and image, so the rendered
shape came from the intrinsic ratio of whatever rendition was served. With
thumbnail_fit seeded 'cover' by migration 040 every rendition is square, so the
layout drew identical squares and was indistinguishable from grid. The card now
uses the height it is given and the stylesheet's existing height:100% applies.

The bundled CSS templates pinned images to a fixed pixel height, which has
specificity (0,1,1) and beats the .h-full utility (0,1,0) six of the seven
layouts use. Elegant Dark is seeded is_default, so that was the out-of-the-box
result for any layout other than grid/timeline.

Migrations 052/053 corrected for fresh installs; 181 repairs the rows already
seeded. Whitespace-tolerant because sanitizeCSS strips newlines from any
template ever saved through the editor — an exact-text migration would have
silently no-opped on most real installs. The height property is matched with a
lookbehind so line-height/max-height/min-height are untouched, grouped selectors
are handled, and nested rules are skipped rather than mis-rewritten.

Both reported, measured in the live DOM, by @BraynArts.
2026-08-22 21:38:17 +02:00
Paul Nothaft 24e11df299 fix(faces): dark-mode styling for the People surfaces (#1106) (#1126)
Every component added by #1074 was styled for light mode only. In the admin dark
theme the three toggle labels rendered invisible — including "Detect people in
this gallery", the switch that starts GDPR Art. 9 processing — and the
Manage-people modal rendered as a light panel over a dark page because its shell
was a hardcoded bg-white.

Admin surfaces pair each neutral with a dark: variant; guest surfaces read the
gallery theme tokens, because galleries carry their own dark themes that the
admin dark class knows nothing about.

Beyond the issue's inventory: the cover picker and face-in-context viewer that
landed after it was filed, the magnifier chip whose bg-white/90 would have
carried light glyphs, PeopleSheet's own hardcoded bg-white shell, the selected
avatar's white ring-offset halo, and both dismiss buttons whose hover darkened
into the background.

External review found one defect, fixed: the sheet's avatars ring against
--color-surface, not the page background.
2026-08-22 18:24:13 +02:00
Paul Nothaft 38c27d097c feat(faces): show a detected face in its source photo, outlined (#1120)
Phase 2 of #1096. Stacked on the phase-1 branch — it needs the Postgres fix
there, or the face list this reads comes back empty.

A 64px avatar answers "is this a person", not "is this the same person as that
other cluster". The reporter's revised use for this is the pre-merge decision:
who they were standing next to, what the occasion was. So the face opens in
its own photo with the detected box drawn, and prev/next walks that person's
other appearances without leaving the modal.

The box is positioned in PERCENTAGES of the original frame, not measured
pixels: the container carries the photo's aspect ratio, so the same four
numbers land correctly at any rendered size, with no resize listener. Verified
against real data before writing the component — bbox [221.9, 174.9, 294.5,
405.8] on a 750x750 frame resolves to left 29.6% / top 23.3% / width 39.3% /
height 54.1% and lands squarely on the face.

Preview rendition, never thumbnail, and that is load-bearing rather than a
quality preference: thumbnail_fit is seeded to 'cover' on every install, so a
thumbnail has had its edges cut off and ratios taken against the ORIGINAL land
nowhere on it. That was #1100, and it presented as a broken detector.

Not built on AdminPhotoViewer, deliberately. It wants full AdminPhoto objects
(this endpoint returns photo_id + bbox + dimensions), it carries delete and
category actions that are wrong for "who is this?", and there is no seam to
draw the box.

Three things review caught, all real:

- The container had a height cap but no width cap, so a panorama derived its
  width from the aspect ratio and overflowed the modal sideways, taking part
  of the outlined face off-screen.
- The per-tile affordance was hover-only, so on a tablet it was permanently
  invisible and there was no way to inspect a specific tile.
- The row action opened index 0, which is the TOP-SCORING face — the same
  thing as the cover only until someone uses phase 1 to pick a different one,
  at which point the row showed one face and opened another. It now resolves
  to the cover's own index.

Round 2 found three more, all real:

- The counter called a list truncated whenever it hit 500, so a person with
  exactly 500 faces was told their complete list was capped. It now compares
  against total_face_count.
- facesLoading goes false with an empty array on a zero-face person or a failed
  request, so the panel sat on a spinner that would never resolve.
- Five 32px actions plus a 64px avatar exceed a 320px row, and the name is what
  got pushed out. flex-wrap alone did not fix it — the toolbar still claimed
  its max-content width first — so its basis is capped at small sizes and the
  buttons wrap to a second line instead.

A cover that falls outside the capped list opens the first face instead. That
case implies the list IS capped, so the truncation note already explains it —
real pagination is a bigger change and is not in this.

Verified end to end: picked the 5th of 13 faces as cover, and the row action
opened at 5 / 13 rather than 1 / 13. Frontend suite 178 passing, build clean,
no new type errors.
2026-08-21 19:36:59 +02:00
Paul Nothaft bbce3cd2a2 feat(faces): let the photographer choose which photo represents a person (#1119)
Phase 1 of #1096.

Clustering picks the cover, and its idea of a good one and a human's do not
always agree. A cluster whose avatar is turned away or softer than the rest
stays that way in the guest-facing people strip too, and nothing in the UI
could change it.

A picker reachable from each person row, reusing the face list the split
dialog already loads — same query, same grid, different action on a click.

Making the choice actually stick took four changes
---------------------------------------------------------------------------
event_people.cover_face_id has existed since migration 177 and the PATCH
already accepted it, so the first version of this was frontend-only. It was
also a no-op:

- facePeopleService.listPeople SELECTED cover_face_id and then discarded it,
  recomputing the cover as the best-scoring VISIBLE face on every read. The
  picker saved, said so, and the avatar reverted immediately. It now prefers
  the stored pick whenever this audience can see it, and falls back to the
  score-ordered choice otherwise — so visibility scoping still wins, and a
  guest is never handed a crop of a photo they cannot open.
- recomputeCentroid overwrote cover_face_id unconditionally. It runs on
  rescan and on photo replacement, so any reprocessing silently undid a
  deliberate choice. It now keeps the chosen face while it is still a member
  of the cluster.
- The face list is cached per person, and split/merge move faces between
  people. Until now the only reader closed itself after acting, so nobody saw
  the stale copy; the picker is a second reader of the same key.
- cover_face_id meant two things. assignFaces seeded it with whichever face
  opened the cluster and recomputeCentroid overwrote it with the highest
  scoring one, so an automatic guess was indistinguishable from a deliberate
  choice — and honouring it would have pinned every UNCURATED person to that
  guess, which is worse than the fallback it replaced (the fallback is
  computed per audience and skips photos a guest cannot open). Both writers
  are gone, migration 179 clears the stored guesses, and the column now means
  one thing. That also removes the need to defend the choice against rescans:
  nothing overwrites it, and a dangling id self-heals to the derived cover.

Clearing existing values is safe rather than destructive: no install has ever
been able to SET a cover, so every stored value is an automatic guess by
construction.

Also fixes a PostgreSQL-only 500
---------------------------------------------------------------------------
GET /admin/events/:id/people/:personId/faces joined `photos` but did not
table-qualify its WHERE, and photo_faces and photos BOTH have an event_id:

  column reference "event_id" is ambiguous

Postgres refuses it, so the endpoint 500s and the Split dialog — its only
consumer until now — has been broken on every PostgreSQL install since the
join was added. SQLite resolves the ambiguity silently, which is why the suite
stayed green. Reproduced against a real Postgres before and after.

The query is now a named builder the route calls and the test imports, rather
than a copy: an earlier version of that test re-declared the query, so the
route could regress to the bare form while the assertions kept passing.

Merge and recluster preserve the choice as well. Both already carried labels
and privacy flags across; the chosen cover is human state of the same kind, so
it now rides along — through a merge when the target has none, and through a
recluster by following its FACE into whichever cluster ends up holding it,
rather than the majority-descendant rule the label uses.

The picker and the endpoint disagree past 500 faces, so the picker now says
when it is showing a capped list rather than presenting it as exhaustive.

Frontend suite 178 passing, backend 23 across the touched suites, build clean,
no new type errors. Mutation-checked twice: dropping the cover preference fails
the new listPeople test while the visibility-scoping test still passes, and
restoring the auto-seed in assignFaces fails it too.
2026-08-21 19:26:52 +02:00
Paul Nothaft 887bdbe6e5 feat(gallery): responsive grid thumbnails (#1095) (#1109)
* feat(gallery): responsive grid thumbnails (#1095)

The half of #1095 that #1099 deliberately left out. Grid tiles are ~175
CSS px at the mobile 2-column default — about 530 device px on a DPR-3
phone — so the 300px thumbnail is upscaled ~1.8x and faces visibly mush.

Backend mirrors the preview tiers exactly: ?w= on the gallery thumbnail
route, whitelisted to 300/600/900, cached by width in storage, never
written to photos.thumbnail_path, and keyed by photo id for every source
type — basenames are not unique across events and a tier is served from a
cache hit without re-reading the source, which is how the preview tiers
nearly leaked one gallery's photo into another. The tier is in the ETag,
or a client holding the 300px file gets a 304 for its 600px request.
Cleanup and regenerate invalidation are wired the same way.

generateThumbnail now takes width/height overrides; it keeps the
configured `fit`, because the grid renders with object-cover and tiers
that were framed differently would visibly jump as the viewport changes.

The srcset only advertises tiers the SOURCE can fill. Thumbnails are
generated withoutEnlargement, so a 400px original asked for 900 comes
back at 400 — advertising "900w" would have the browser pick that
candidate and upscale it, which is the reported softness made worse. That
exact trap is why this was held back from #1099; the photo's own
dimensions are now the guard, measured on the SHORT edge because
thumbnails are square and a 4000x600 panorama can still only fill a 600
tile. A source that clears only one tier gets no srcset at all rather
than a single pointless candidate.

Two things this surfaced, both worth knowing separately:

`npx tsc --noEmit` type-checks NOTHING in this project — the root
tsconfig is `files: []` with project references, so the real command is
`tsc -b`, which is what build:check runs. Under tsc -b the repo has 43
files with pre-existing type errors; this branch adds none, and the one
error in a file I touched (PeopleManagerModal:91) is on main already and
unrelated to the line I changed.

* fix(gallery): wire grid tiers into the component that actually renders

The srcSet landed in PhotoGrid.tsx, which nothing imports — GalleryView
renders PhotoGridWithLayouts, and every grid layout funnels its tile
through the shared PhotoCard. The frontend half of #1095 shipped nothing.

Moved to PhotoCard, and switched from srcSet to a single sized URL, the
same shape PhotoLightbox already uses for preview tiers. AuthenticatedImage
fetches its src with the gallery bearer token and renders the blob; an
<img> carrying a w-descriptor srcSet ignores src entirely, so that fetch
would have been discarded and the browser would have issued its own —
unauthenticated, and resolved against the page origin rather than the
configured API host. One URL keeps the auth path and halves the requests.

The tier comes from the tile's measured width via the IntersectionObserver
entry, read on the same render that reveals the image so nothing is fetched
twice. Column counts differ per layout and shift again with thumbnailScale,
so the breakpoint table is only a fallback.

Also closes what the tier cache leaked or served stale:

- ensureThumbnailAtWidth short-circuits videos. Their thumbnail is a poster
  frame, so the tier path handed the video file to Sharp — after downloading
  it in full on S3, uncached, once per request.
- The ETag names the tier actually served, not the one requested. A fallback
  to the canonical thumbnail was caching a 300px image under a 900px key.
- Tier height scales from the configured aspect ratio instead of forcing a
  square; with fit:'cover' a 300x200 canonical and a 600x600 tier are two
  different crops and the photo reframed between tiers.
- The canonical short-circuit compares against the configured thumbnail_width,
  not the 300 default, so a 600px install stops generating duplicate tiers.
- Tier invalidation on /admin/thumbnails/regenerate, above the local-file
  check that skips S3 and external rows.
- Tier cleanup in replacePhoto and deleteEventCascade. Both derive keys from
  the photo row, so the rows have to be read before they change or vanish.
  Preview tiers had the same two holes and are swept alongside.

The clamp no longer drops a tier when the source falls between them: a 400px
short edge asked for 600 returns all 400 pixels, where clamping to 300 threw
100 of them away.

Backend 18 tier tests, frontend 22. Full suites green: 293 backend across the
touched areas, 185 frontend, build clean, no new type errors.

* fix(gallery): measure the tile, and stop regenerating the w300 tier

Follow-up to the review of #1095. Closes the three items left open there,
plus a defect the previous commit introduced.

**The w300 tier regenerated on every request.** Decoupling the canonical
short-circuit from the hardcoded 300 left generateThumbnail still tagging
against DEFAULT_THUMBNAIL_WIDTH. On an install with thumbnail_width=600 a
w=300 request wrote `thumb_<name>` while the caller probed for
`thumb_w300_<name>`: the cache never hit, so every request re-downloaded the
original and ran Sharp, and the file it left behind was in no cleanup list.
The tag now follows the configured width, and thumbnailTierKeys lists all
three widths — which one is canonical is a setting, so excluding 300 stranded
exactly the file a 600-configured install generates.

**The tier is chosen from the tile's measured width.** The observer entry
only exists for `lazy` cards, and Mosaic, Masonry and Timeline don't pass it
— Mosaic is 1-up on mobile where Grid is 2-up, so they are the layouts a
breakpoint guess gets most wrong. Measured in a layout effect and gated: the
image is not rendered until the width is known, so AuthenticatedImage never
mounts with a src it has to replace. Attaching the observer ref
unconditionally instead refetches every tile, since React flushes passive
effects before the sync re-render a layout effect triggers — removing the
gate makes the new single-request test fail, which is how that was confirmed
rather than assumed.

**Gallery Premium has its own card** and never reached the shared one, so its
tiles kept pulling the canonical thumbnail. MasonryPhotoAlbum already hands
the laid-out width to the render prop, so it needed no measurement.

**Event rename orphaned tiers.** The key embeds the basename, so the DB
update is the point past which the old keys cannot be derived. Dropped inside
the filename-changed branch, not the loop body: unconditional would fire four
storage deletes per photo on every rename, 20k calls against S3 for a
5,000-photo event that merely had its slug adjusted. Preview tiers had the
same hole and are swept alongside.

Carousel is the seventh layout and deliberately gets no tiering: its
filmstrip thumbs are 80 CSS px, under the canonical 300 even at DPR 3.

Tests: first PhotoCard suite (6), backend tier suite 21. Both new behaviours
mutation-checked — reverting the width tag, the render gate, the measurement,
or the rename sweep each fails a test. Full suites green: 298 backend across
the touched areas, 191 frontend, build clean, no new type or lint findings.

* fix(gallery): mount masonry cards once, into a measured layout

Found while capturing screenshots for this PR, by attributing every thumbnail
request to a photo id rather than eyeballing the grid.

Masonry columns mode starts at 3 columns and runs its greedy distribution off
a hardcoded 300px estimate until the container has been measured. Cards
mounted into that guess are torn down when it settles — photos move to a
different parent column, so React unmounts them — and since #1095 each mount
picks its tier from its own width, the two mounts request two DIFFERENT urls.

Measured on a 1440px desktop, production build, 62 photos:

  before   45 photos fetched at canonical AND w600, 17 stuck on w600
           107 requests
  after    62 photos, canonical only, 62 requests

Mobile was already landing on one tier either way, so both mounts produced the
same url and the second was a cache hit — which is why it looked clean and the
desktop case did not.

The fix is the gate the rows/justified mode in this same file already applies
for the same reason (line 346): hold the cards back until containerWidth is
known. Only columns mode was missing it. Grid and Justified take their column
counts from CSS breakpoints, so they have no transient measured value to
discard and are unaffected.

Worth noting this was NOT visible on main: without tiering both mounts request
the same url, so the browser cache absorbs the duplicate. Tiering is what turns
a harmless remount into a second download — the regression is this PR's, which
is why it is fixed here rather than deferred.

Frontend suite 194 passed (3 new). Mutation-checked: removing the gate fails
the mount-once and placeholder tests.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-21 19:26:21 +02:00
Paul Nothaft d241919604 fix(ui): stop iOS Safari zooming in on 14px form fields (#1113)
Closes #1105.

iOS Safari zooms the whole page in when a focused form control computes to
under 16px, and it does not zoom back out. Unlocking a gallery is a
client-side transition rather than a document navigation, so the zoom the
password field triggers carries straight into the gallery: the layout pans
horizontally and the header actions sit off-screen until the visitor
pinch-zooms out by hand. A real page load would have reset it.

`.input` and `.input-themed` are `text-sm`, so the field is 14px on every
phone, and GalleryPage inverts the breakpoint on top of that
(`text-sm sm:text-base` — 14px below 640px, where iOS zooms, and 16px above
it, where it never does).

Keyed to the POINTER, not a width. The zoom depends on the computed font size
and a touch device, never on how wide the viewport is — and a phone in
landscape is 667-956 CSS px, above any width you could call "phone". Measured
on the admin login page, which has no `sm:` override:

  main   portrait 390x844    14px    zooms
  main   landscape 844x390   14px    zooms
  main   iPad 820x1180       14px    zooms
  fixed  all three           16px
  fixed  desktop (mouse)     14px    unchanged, no zoom off touch

One media query rather than flipping each call site. `.input` alone backs 334
`<Input>` usages, but there are also ~440 raw inputs, selects and textareas
carrying their own `text-sm`, and Tailwind utilities sit in a later layer than
@layer components — so a fix at the component definition misses most controls
and any new `text-sm` silently reintroduces the bug.

The `:not()` on each selector is load-bearing, not decoration: it buys the
specificity to beat a utility class. Measured in a browser —

  input.text-sm     16px   (0,2,1 beats .text-sm)
  select.text-sm    14px   (0,0,1 loses)
  textarea.text-sm  14px   (0,0,1 loses)

24 selects and textareas in the tree carry `text-sm`, so the bare form would
have left them zooming. Checkbox and radio stay excluded so font-size never
sizes their box.

max(16px, 1em, 1rem) is a FLOOR, not a size. A flat 16px would make controls
that are already bigger smaller: Typography -> Large sets --font-size-base to
18px on body, so anything inheriting it would be clamped down and the setting
quietly ignored. Each term covers a case the others miss:

  normal (body 16)       16px      Large theme (body 18)    18px
  Small theme (body 14)  16px      browser default 20px     20px

The viewport meta is deliberately left alone: `maximum-scale=1` would suppress
the zoom by disabling pinch-to-zoom for everyone.
2026-08-21 19:25:47 +02:00
Luca 9431b9f094 feat(setup): configure the public address and SMTP in the wizard, not .env (#1104)
* feat(setup): configure the public address and SMTP in the wizard, not .env

A fresh install could not configure its own public address. `general_site_url`
and the `email_configs` row already existed as admin settings, but nothing
could reach them:

- docker-compose.yml injected FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
  and Dockerfile.aio baked in ENV FRONTEND_URL=http://localhost:3000, so
  getFrontendBaseUrl() returned on its first branch every time and the setting
  was never read. .env.example shipped the same value as an uncommented
  placeholder for FRONTEND_URL / ADMIN_URL / API_URL.
- the wizard never asked for the address at all, and skipped its whole config
  step unless a CRM-ish feature was selected — so a gallery-only install was
  also never offered SMTP, despite gallery links, guest invites and expiry
  warnings all going out through email_configs.
- eleven call sites read process.env.FRONTEND_URL directly rather than the
  resolver, three of them defaulting to placeholder hosts that reached real
  recipients: https://app.example.com in payment-reminder emails, localhost:3005
  in admin invitation emails, https://app.example.com in dev template previews.

Stop injecting a default anywhere, and resolve the origin instead:
FRONTEND_URL -> general_site_url -> the origin the request arrived on ->
whichever exists -> ''. A loopback candidate is treated as unconfigured so the
installs that already have http://localhost:3000 baked into their environment
self-heal; the same guard previously lived inline in routes/gallery.js for the
slideshow QR (#848) and is now shared. The empty return is preserved because
shareLinkService and the SSO redirects in routes/auth rely on it to emit
relative urls — callers needing an absolute url use getAbsoluteFrontendUrl(),
which still ends at http://localhost:3000.

The wizard now persists window.location.origin right after the admin account is
created, so an install that skips the rest still has a usable origin for
background jobs that have no request to derive one from, and offers it as an
editable "Public address" field. Settings -> General shows the field read-only
when FRONTEND_URL pins it, instead of silently ignoring edits.

Also drop the `|| 'mailhog'` fallback when seeding email_configs: that host only
exists in the dev compose profile (which does not even start by default), so a
fresh install came up with a live config pointing nowhere while the wizard
showed empty SMTP fields. With no row, blank fields are the truth and
emailProcessor logs "No email configuration found". Developers set
SMTP_HOST=mailhog explicitly.

backend/src/services/emailService.js is deleted: nothing in backend/ references
it, and it was the only consumer of the SMTP_* variables, which misrepresented
how mail is configured.

Refs #705

* fix(setup): keep FRONTEND_URL ahead of ADMIN_URL/APP_URL when resolving links

The previous commit routed two call sites through the resolver but put the
site-specific variable FIRST, silently reversing precedence:

  userManagementService  was: FRONTEND_URL || ADMIN_URL || localhost:3005
                    became: ADMIN_URL || resolver
  adminEvents/crud       was: FRONTEND_URL || APP_URL || ''
                    became: APP_URL || resolver

An install with both variables set would have flipped which one won. Call the
resolver first instead — it starts with FRONTEND_URL, so the original relative
order is preserved and only the final fallback changes: localhost:3005 (not
even the frontend's port) and '' (a relative link inside an email) both become
the resolved origin.

Refs #705

* fix(setup): unpin loopback FRONTEND_URL, keep ADMIN_URL/APP_URL reachable

Review feedback on #1104.

isEnvPinned() reported ANY FRONTEND_URL as authoritative, including the
loopback values getFrontendBaseUrl() deliberately demotes. An install
upgrading with the old compose default FRONTEND_URL=http://localhost:3000
therefore resolved its origin from general_site_url correctly, but got the
Site URL field rendered read-only in Settings and skipped by the wizard's
seeding - locking the exact operators this change exists to unblock out of
configuring a public address anywhere. The predicate now mirrors the
resolver, and the derived general_site_url_effective the General tab reads
comes from the same helper instead of re-normalising process.env inline.

APP_URL and ADMIN_URL had become dead code: getFrontendBaseUrl() only
returns falsy when NOTHING is configured, so `|| process.env.ADMIN_URL`
after it never ran once a site URL existed - which after this PR is the
normal case. A split-origin install pointing ADMIN_URL at a separate admin
host got invite links on the public gallery origin instead. They are now
passed as an explicit `override` that resolves directly below FRONTEND_URL,
preserving the historic FRONTEND_URL-before-ADMIN_URL order while beating
the database- and request-derived fallbacks.

general_site_url now feeds the CORS allowlist and the
Access-Control-Allow-Origin header, not just email links, so a schemeless
value is an allowlist entry no browser origin can match. Validate it
server-side in PUT /general (isURL with require_protocol, require_tld off
so LAN/NAS installs on http://nas:3000 still work) and client-side in both
surfaces that write it - type="url" never fires in either, since neither
input sits inside a form.

Two more wizard fixes: the General tab no longer reposts general_site_url
while it is env-pinned, because the field then holds the effective env
value rather than the stored one and the round-trip read as a change to a
protected key, 403ing a settings.edit-without-settings.domains admin on an
unrelated save. And SetupConfigStep validates the From address before
posting - /admin/email/config rejects a blank one, which used to surface as
a generic warning while the wizard advanced from its finally block anyway,
discarding every SMTP value the user had typed, password included. A failed
save now keeps them on the step.

* fix(setup): surface a rejected public address instead of swallowing it

Review round 2 follow-up on #1104, pushed onto the branch.

saveSiteUrl() caught and discarded every error. That was defensible before
round 2 added a server-side URL check, but PUT /general can now answer 400 —
and the two validators disagreed:

  http://my_nas.local      client: accepted   server: rejected
  http://foo_bar:3000      client: accepted   server: rejected

validate() let those through, the 400 was swallowed, `failed` stayed false and
onDone() ran. The operator finished the wizard believing the public address was
stored when nothing had been. That is the silent misconfiguration this whole
change exists to remove, landing on the LAN and NAS installs it targets.

Three parts:

- saveSiteUrl() throws. finish() resolves it before anything else is posted and
  puts the message on the address field rather than the generic "some settings
  could not be saved" warning. Skip for now still always leaves, by contract,
  but warns instead of dropping the value in silence.
- allow_underscores on the server check, for the same reason require_tld is
  off: browsers resolve http://my_nas.local and the client accepts it, so
  rejecting it server-side only produced the mismatch above. Both validators
  now agree across the LAN/NAS, IDN, bare-IP and scheme-less cases.
- LOOPBACK_BASE_RE anchors its host token. Bare prefix matching also demoted
  https://localhost-nas.example.com, and now that this predicate gates the
  whole resolver rather than just the slideshow QR, being demoted means a
  configured address is silently ignored. 127. stays a bare prefix on purpose:
  all of 127.0.0.0/8 is loopback.

Resolver suite 31 passing, up from 26. Mutation-checked: restoring the
unanchored regex fails the three new host-boundary cases.

* fix(settings): don't lock the General tab on a site URL nobody typed

Review follow-up on #1104, pushed onto the branch.

general_site_url was free-text until this PR added a server-side check, so an
upgraded install can hold something schemeless that predates it. The tab
flagged that on load, and `disabled={!!siteUrlError}` then killed Save for
EVERY General setting.

An admin holding settings.edit but not settings.domains could not clear it
either: correcting the address is a change to a protected key and 403s. The
tab has no permission gating, so that role was simply locked out of the tab
with no self-service way back.

That is the same role adminSettings.js:85-95 documents the no-op round-trip
allowance for. The allowance only helps if the request is made, and this
blocked it in the browser first.

Validation now waits until the field is actually edited, and an unchanged
value is dropped from the payload rather than reposted — matching what the
env-pinned case already does one line above, and for the same reason.

  stored value invalid, untouched   Save works, key not sent
  edited to something unusable      Save blocked
  edited to a usable absolute url   saved

Four tests, first coverage for this feature. Mutation-checked: removing the
dirty gate fails the untouched-value case.

---------

Co-authored-by: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
2026-08-21 12:54:14 +02:00
Paul Nothaft b3a7ab27ea fix(faces): face avatars were cropped against a cropped rendition (#1100)
* fix(faces): face avatars were cropped against a cropped rendition

Found while triaging #1096, which reported the People manager showing
unusable cluster covers — a bare shoulder, the back of a head, a patch
of background — and asked for more sample faces to compensate. Most of
that is not a detector problem and not a UI limitation. It is a bug.

faceCropStyle positions an avatar by scaling the WHOLE frame and
offsetting so the face lands centre. That holds only while the rendition
shown is the entire image at a uniform scale. Thumbnails are not:

  imageProcessor.js:93   DEFAULT_THUMBNAIL_FIT = 'inside'
  migration 040:6        thumbnail_fit seeded to 'cover'
  imageProcessor.js:229  fit: settings.fit

The 'inside' constant is only a fallback for a missing settings row, and
the row is seeded on every install — so thumbnails are centre-cropped
essentially everywhere, and every face avatar rendered against one is
silently offset on any non-square photo. The reporter read the setting
as safe because of that constant, and the code comment at :87-92 says
the same thing; all three places disagree with what is actually stored.

It presents as a bad detector, which is why it survived: the boxes are
right, the frame they are drawn against is not.

All three surfaces — the admin manager and the guest-facing strip and
sheet — now read a preview, which uses fit: 'inside' and is therefore
the whole frame. At w=640: plenty for a 64px avatar at DPR 3, and small
enough that a strip of a dozen people does not pull a dozen 1920px
renditions. Face scanning already calls ensurePreviewImage for anything
it scans, so a preview exists for every photo that has a face.

Adds the admin preview route the manager needed; the gallery already had
one. Both whitelist ?w= the same way.

The first version of the call-site test passed with every surface still
reading thumbnail_url, because an import alone satisfied it. It now
matches inside the src={...} expression, and each of the three surfaces
was individually reverted to confirm the test fails.

* fix(faces): size the face tier by bbox, and keep admin_preview auth

The face half of the external review; the tier-key and long-edge fixes
live on the #1099 branch this is stacked on.

Face avatars used one fixed 640 tier. In a 6000px group shot a 200px
face is ~21px there, and faceCropStyle then blows that up ~9x to fill a
64px avatar at DPR 3 — mush, and indistinguishable from the
mis-positioning bug this PR exists to fix. The tier is now derived from
the bbox's share of the frame, so a face across a hall gets 1920 and a
close-up still gets 640.

The synthesized face URL also dropped admin_preview. verifyGalleryAccess
only accepts the admin cookie when admin_preview=1 is on the request
(middleware/gallery.js:28), and the preview flow deliberately mints no
gallery JWT — so every avatar 401'd in exactly the mode an admin uses to
check a gallery before sending it to a client.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-20 14:17:25 +02:00
Paul Nothaft 011f6ae7ec feat(gallery): sized preview tiers so phones stop pulling 1920px (#1095) (#1099)
* feat(gallery): sized preview tiers so phones stop pulling 1920px (#1095)

A phone can display ~1170px at most, but the preview tier is a single
1920px JPEG with no size parameter — so every lightbox swipe ships
roughly twice the bytes it can use, and the slide track preloads
neighbours, which multiplies it. On the reporter's all-external install
a null preview_url falls back to the untouched NAS original, which makes
it worse again.

Backend: ?w= on the gallery preview route, whitelisted to 640/1280/1920.
A whitelist rather than a free-form width because every distinct value
is a permanent rendition on disk — an open parameter is an invitation to
fill the volume. Unrecognised or absent values fall through to the
canonical 1920 preview, so old clients and hand-typed URLs behave
exactly as today.

Extra tiers are cache, not state: ensurePreviewImageAtWidth keys them by
width, looks them up in storage and generates on miss, and never writes
photos.preview_path. That column owns the canonical rendition, and
threading a width through it would mean the last size anyone requested
silently becomes "the" preview. Requesting 1920 resolves to the existing
preview rather than a w1920 duplicate, so no install grows a second copy
of every preview it already has.

The tier is part of the ETag. Without it a client holding the 1920
rendition gets a 304 for its 640 request and renders the wrong size,
which is this feature inverted.

Frontend: the lightbox picks a tier from innerWidth x devicePixelRatio,
capped at DPR 3 — uncapped, a DPR-10 device asks for 3900px and lands
straight back on the desktop rendition. At the top tier the URL is left
byte-identical so existing caches and ETags stay valid and desktop sees
no change at all. saveData and a 2g/3g effectiveType drop one tier;
both are Chromium-only, so they are a bonus rather than the mechanism.

Grid thumbnails are NOT tiered here, deliberately. generateThumbnail
resolves its width from admin settings rather than an argument, so
tiering it is a separate change — and shipping a srcset whose candidates
the server ignores would be worse than shipping none: the browser would
take the "600w" candidate, receive the 300px image and upscale it, which
is the reported softness made slightly worse. That half of #1095 lands
separately.

* fix(gallery): scope tier keys per photo, size by long edge, clean up tiers

External review. Three findings against the tier work, one a
cross-gallery leak.

The tier cache key was the photo's BASENAME. Managed uploads keep camera
basenames, so two events can each hold an IMG_0001.jpg — and a tier is
served straight from a cache hit without re-reading the source, so the
second gallery gets the first gallery's photo. Keys are now scoped by
photo id for every source type. The RAW branch passed proc.outputBasename,
which would have dropped that scoping again; it now passes the scoped name.

Tier selection used viewport WIDTH, but ?w= bounds the LONG edge
(fit:'inside'). On a 390x844 phone at DPR 3 a 2:3 portrait is bound by
height and renders ~1755 device px, so width-only picked 1280 and made
portraits softer than today; landscape on the same phone needs ~1170. It
now computes the rendered long edge from the photo's own dimensions and
falls back to the top tier — today's behaviour — when they are unknown.

Tiers live outside photos.preview_path, so nothing else knew they
existed: delete, bulk-delete and archive left them orphaned in previews/
forever, and regenerate-previews refreshed only the canonical rendition
while phones kept the stale copy. previewTierKeys derives them from the
same deterministic scheme and all four paths clean up. Deliberately
outside the preview_path guard — a tier can exist when the canonical
rendition never did, so keying cleanup off preview_path would strand
precisely the photos only ever viewed on a phone.

The existing tier tests encoded the old width-only semantics and were
updated rather than kept; that is a behaviour change, not a test fix.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-20 14:08:07 +02:00
Paul Nothaft 37a15e3d49 fix(faces): restore the :beta image tag and surface sidecar health (#1087)
* fix(faces): restore the :beta image tag and surface sidecar health

Both halves of what a user hit on discussions/1069: the People card sat
at "Scanning… 0 of 227" for 30 minutes with no explanation, because the
sidecar container could never have started.

docker-build.yml — republish `:beta`. It used to come for free via
`type=ref,event=branch` when the active development branch was literally
named `beta`; the rename to `main` silently retired it. backend:beta has
been frozen at 2026-06-29 (448da950) ever since while :main moved on, so
PICPEAK_CHANNEL=beta has been serving a seven-week-old build across every
image. The ml sidecar was added after the rename and so never had a
`:beta` at all, which left docker-compose.production.yml:158 unable to
resolve ghcr.io/picpeak/picpeak/ml for any documented channel — the
image simply does not exist as :beta or :stable, only as :main and
pinned versions. Tag added to all four merge jobs, gated on main.

`:stable` stays absent for ml on purpose: it is gated on refs/heads/stable
and the sidecar does not exist there. stable's docker-compose.production.yml
carries no picpeak-ml service, so nothing can reference the missing tag.

FaceRecognitionCard — show when the sidecar is unreachable. An
unreachable sidecar is not an error by design: faceQueue.js:132-136
releases the photo back to `pending` and retries forever so a restart
does not burn the queue. The cost was that a stopped container looked
exactly like a slow scan, indefinitely, and the only signal was a
backend log line rate-limited to once per five minutes.
/admin/events/faces/health already existed and nothing in the frontend
called it. It is now polled while a scan is in progress, and a failing
check replaces the spinner with the sidecar URL, the underlying error
(which distinguishes a stopped container from a token mismatch) and the
command to start it.

Health is only polled while a scan is running — an idle card has no
reason to care whether the sidecar is up.

* fix(faces): tell the three sidecar failure modes apart

Follow-up to the health surface in this branch, from an external review
pass. The original warning was right about "the sidecar is not working"
and wrong about almost everything after that.

faceClient.checkHealth now returns a `reason` rather than only a message,
because the caller has to know whether photos survive:
  - 'unauthorized' (401) and 'rejected' (any other 4xx) both become
    SidecarRejectedError in classify(), which workerLoop does NOT retry —
    every claimed photo is marked 'failed'. Telling the admin the scan
    resumes on its own was simply untrue there; both now say to fix the
    cause and Re-scan.
  - 'unreachable' (refused/DNS/timeout/5xx) is the retryable one.

The card also no longer cries wolf. /faces runs inference synchronously
inside an `async def`, so one slow photo blocks the event loop and stalls
/info past its 5s timeout — a healthy sidecar can fail a probe. Verified
with an isolated uvicorn repro: a blocking call in an async handler
stalled the sync /info endpoint to 5.01s. The warning now needs three
consecutive failures AND no drop in `pending`. Three because a single
/faces call may legitimately run to FACE_ML_TIMEOUT_MS (30s) and two
probes 15s apart both fit inside that window; `pending` rather than
`scanned` because scanned counts only 'done', so a run producing
skipped/failed photos is progress that counter misses.

A 4xx burns the queue with no backoff, so it can empty before anyone
opens the card — in_progress goes false and only "227 failed" is left.
The probe therefore also runs when a finished scan has failures, and the
notice renders under the counts instead of replacing them. It is worded
as present-tense service state, not as a claim about those specific
failures: a live probe cannot know whether they came from this
misconfiguration or from corrupt images earlier. Attributing them exactly
needs stored face_error rows, which is a bigger change than this.

Also adds the missing-token case to the unreachable text: FACE_ML_TOKEN
has no default and the container refuses to start without it, so the most
likely first run fails as a plain connection refusal that "just start it"
does not fix.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-19 16:49:47 +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 52db982661 fix(gallery): make per-event banner overrides actually work, both banners (#440, #932) (#1064)
* fix(gallery): make per-event banner overrides actually work, both banners (#440, #932)

The promo banner shipped with a per-event inherit/custom/off override that
never reached a guest. GalleryView reads promo_mode from the /photos payload,
and /photos never sent it — so every gallery resolved to 'inherit'. Setting a
gallery's promo banner to "Off" did nothing; the global banner kept rendering.
The info banner (#932) mirrored that shape and inherited the same gaps.

Four places dropped the fields; all four now carry both banners:

1. GET /gallery/:slug/photos — send promo_mode/promo_markdown alongside the
   info fields. This is the fix that makes "Off" mean off.
2. POST /admin/events — the validators accepted both banners and the insert
   discarded them, so an API client could POST info_mode:'off', get 201, and
   find the row on 'inherit'. Markdown is stored only for 'custom', matching
   the PUT rule.
3. POST /admin/events/:id/duplicate — copy both from the source row. The
   dialog promises the copy "inherits the branding, behaviour, feedback, and
   category configuration"; a muted gallery un-muting on duplication is the
   opposite of that.
4. PUT /admin/events/:id — resolve the effective mode from the STORED row when
   a partial update sends only the markdown. Previously updates.promo_mode was
   undefined on such a request and the text was parked on an inherit/off
   gallery, then resurfaced when someone later switched it to 'custom'. The
   lookup is lazy: one extra query, only on that path.

The two normalisation blocks are now one loop over both banners, so the pair
can't drift apart again.

Verified in a browser, both directions against the same global banner:
promo_mode='off' -> not rendered; 'inherit' -> rendered. The /photos payload
went from promo_mode ABSENT to carrying the value.

* fix(gallery): thread promo into the reveal view, drop stale markdown on duplicate

External review, round 1 on this PR. Two gaps in the plumbing it introduced:

- The reveal-hidden branch copied only the info fields from /photos. Now that
  /photos carries promo too, a reveal-hidden gallery with promo_mode 'off'
  still fell back to 'inherit' and showed the global banner on the first load
  after login. Thread both banners there.

- The duplicate copied markdown verbatim. A row written before the PUT
  normalisation landed can hold text while its mode is 'inherit'/'off', so the
  copy inherited hidden text that would resurface the moment someone switched
  it to 'custom' — violating the very invariant this PR establishes. Copy
  markdown only when the source mode is 'custom'.

Test covers the stale-markdown source explicitly.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 21:37:17 +02:00
Paul Nothaft b48fa62eea feat(gallery): info banner above the photo grid (#932) (#1063)
* feat(gallery): info banner above the photo grid (#932)

A short informational note rendered at the TOP of a gallery, above the
photos. Distinct from the promotional banner (#440), which stays by the
footer for marketing copy — the reporter's case is an onboarding hint ("use
the menu button to filter"), which is useless below a gallery the guest has
to scroll past first.

Mirrors the promo feature's shape rather than inventing a second one: a
global default in Settings → Branding (branding_info_markdown) plus a
per-event inherit/custom/off override. Markdown via the existing
MarkdownContent sanitiser — no raw HTML, no CSS injection. Empty global
default means nothing renders, so upgrading changes nothing visible.

Deliberately NOT included: an alignment knob (this is short helper copy, not
marketing layout) and guest dismissal — the issue lists dismissal as a
nice-to-have, and it needs per-guest persistence that is its own decision.

Migration 176 is idempotent (hasColumn / existing-key guarded).

Note on the payload plumbing: the per-event fields travel in the /photos
response, not just /info. GalleryAuthContext seeds its cached event from the
gallery LOGIN response — a small identity subset — so anything absent there
is undefined right after a guest signs in. /photos is the payload that
refreshes on every gallery load, which is why the fields were added there
and why GalleryView reads them from `data.event`. Verified in a browser
across all three modes; reading them from the context event instead silently
collapsed every override back to 'inherit'.

* fix(branding): map branding_info_markdown on read so saving can't wipe it (#932)

External review caught this. BrandingSettings declared no info_markdown and
formatBrandingSettings never mapped branding_info_markdown, so BrandingPage's
hydration — setBrandingSettings(prev => ({ ...prev, ...formatted })) — kept
the empty-string initializer instead of the persisted value. The form loaded
blank and the next Save posted '' back, wiping a configured banner. Silently:
the gallery keeps rendering the old copy until that save lands.

This is the same bug the footer/promo fields hit in #441 + #440 / #460, which
the read mapper still carries a comment about. Add the field to the interface
and the mapper, and pin the round-trip for the whole editable branding set so
the next field added is caught by a test rather than by a user losing copy.

Verified: the new test fails 3/4 with the mapper line removed.

* fix(gallery): honour the info-banner override in the reveal-hidden view (#932)

External review, round 2. The hidden-until-reveal branch renders GalleryLayout
with the context `event`, which is seeded from the gallery login response and
carries no banner fields — so while a gallery was hidden, a per-event 'off'
silently resolved to 'inherit' and the global banner appeared on a gallery the
admin had muted.

Resolve the fields there the same way the main render path does. The two
full-page layouts (gallery-premium, gallery-story) are deliberately left alone:
they return before GalleryLayout and render no header, footer or promo banner
either — injecting a wrapper into layouts documented as having 'their own
integrated UI' would be a design change, not a fix.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 21:33:13 +02:00
Luca 8809564aad feat(backup): open sqlite → pg .picpeak restore as the supported upgrade direction (#1041) (#1043)
Reshaped onto main after #1039 landed the coercion engine
(typedColumnsFor / epochToIso / coerceForTargetEngine) — this PR is now
only the policy delta on top of it:

- validateManifest: replace the CLI-only allowEngineSwitch opt-in with a
  direction rule — sqlite → pg allowed (upload UI and CLI alike),
  pg → sqlite refused with a message naming the supported direction
- importFromPicpeak: derive crossEngine from the manifest's engine
  (absent field = target engine, the exact pre-change behavior), log it,
  return it; route passes it through
- scripts/migrate-sqlite-to-postgres.js: rely on the shared gate, drop
  the flag
- restore card: direction stated in the intro, cross-engine notice after
  a converting restore; both strings in en.json + de.json; removed the
  orphaned settings.backup.picpeak locale node (unreferenced, stale copy)
- picpeakCrossEngine.test.js: direction policy, epochToIso (ms, seconds,
  numeric strings), coerceForTargetEngine units, plus
  PICPEAK_PG_TEST_URL-gated real-Postgres stored-value assertions

Co-authored-by: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
2026-08-16 19:01:36 +02:00
Luca b118695474 feat(permissions): granular permission gating + role editor & presets (#747, phase 1 of #743) (#1045)
* feat(permissions): granular permission gating + role editor & presets

Make every admin feature permission-gateable so multi-user studios can
split capability across roles (#747, and phase 1 of #743).

- Split the catch-all settings.edit into dedicated dangerous-config perms
  (banking / domains / security / integrations / features): a team member
  can no longer change IBAN, domains, SSO, webhooks, API tokens or feature
  flags. Reads keep an OR with settings.view so existing roles keep
  visibility. The site-URL write inside /general is change-gated on
  settings.domains.
- Add dedicated perms for admin surfaces miscategorised under settings.*
  (whatsapp, event_types, image_security, notifications, system) plus
  roles.manage and vat_codes.view; gate the previously-ungated VAT read.
- Boot self-heal (_permissionsBoot.js): super_admin always holds every
  permission (tracks-all) so new perms never need a compensation
  migration; all other roles stay frozen (no silent escalation on upgrade).
- Seed two presets: Solo Photographer (full operator) and Team
  Photographer (contributor — view events + manage photos + read-only CRM;
  no settings/users/billing edits, no events.edit).
- Role editor: adminRoles CRUD (create/edit/clone/delete + permission
  matrix; system roles protected, super_admin immutable) and a Roles tab
  with a category-grouped matrix and preset cloning.
- Settings page tabs are permission-gated with snap-back; i18n en/de.

Migration 174. Backward-compatible: admin/editor/viewer unchanged.

* feat(permissions): hide in-page action buttons a role can't use

Wrap mutating controls on the surfaces restricted roles actually reach
(Events list, Archives, gallery photo grid, event detail) in
PermissionGate so they are HIDDEN when the user lacks the permission,
rather than shown-then-403:

- Events list: create / bulk archive / bulk delete / row archive /
  row delete / download-archive.
- Archives: restore / download / delete.
- Photo grid: single + bulk delete (photos.delete), per-photo download
  (photos.download), bulk move/hide/show (photos.edit).
- Event detail: edit / rename / publish (events.edit), duplicate
  (events.create), archive (events.archive), create-invoice
  (bills.manage); the Actions card is hidden entirely for view-only roles.
- Photos tab: upload / external import (photos.upload), export menu
  (photos.download).

Backend already enforces these with 403; this is the matching UX so a
Team Photographer never sees delete/settings controls.

* fix(permissions): close settings-split bypass via generic settings writers

Security review found the settings.edit split was bypassable: the generic
settings writers (/general, /analytics, /seo, /security) upsert arbitrary
setting_keys, so a role holding only settings.edit (or settings.security)
could write keys owned by a narrower permission — repointing the public
site URL (settings.domains), security policy (settings.security) or
VAT/accounting config (settings.banking) via the wrong endpoint.

Add stripUnauthorizedProtectedKeys(): before every generic upsert, drop
any protected key the caller isn't permitted to write (general_site_url →
settings.domains, security_* → settings.security, accounting_* →
settings.banking). Dedicated routes still work because their caller holds
the matching perm. Replaces the narrower in-handler site-URL guard.

Also fix two tests affected by the RBAC changes:
- authzPermissionGaps: API-token management moved to settings.integrations,
  so grant that (not settings.edit) to exercise the ownership 404.
- AdminPhotoGrid.viewToggle: stub PermissionGate (its buttons are now gated
  and the test renders without a PermissionsProvider).

* fix(permissions): address upstream review (#1045)

- Renumber migration 174 -> 175 (174 now taken by 174_sqlite_nullable_event_dates
  from #1035; the collision made picpeakImportService's forward-only restore
  guard treat both as order 174 and accept a newer .picpeak onto an older schema).
- Contain the roles.manage blast radius (delegation, not root escalation): a
  non-super_admin can no longer edit their own role, nor grant any permission
  their own role doesn't already hold (createRole + updateRole).
- Protected-key denial now 403s (naming the keys + required perms) instead of
  silently stripping and reporting "saved" (adminSettings generic writers).
- Reserve team_photographer so a custom role can't squat the preset name.
- Boot self-heal: per-step try/catch so a role_permissions insert race on one
  replica doesn't skip preset seeding.
- Forward-project the feature .manage perms that also replaced settings.edit
  gates (whatsapp/event_types/image_security/notifications/system), matching the
  settings.* split projection so the pattern is symmetric for phase-2.
- Guard exports.down's roles/admin_users queries with hasTable.

* fix(permissions): change-detection on protected-key 403 + commit guard tests (#1045)

Round-2 review:
- The protected-key 403 fired on key PRESENCE. The General tab re-posts
  general_site_url on every save, so a settings.edit-only role (the office
  manager this PR enables) got 403'd on every General save even when the URL
  was unchanged. Restore change-detection: compare the incoming value against
  the stored one and 403 only on an actual change; unchanged protected keys are
  dropped so the rest of the save proceeds. Only /general is affected.
- Commit the self-amplification guard test (was run locally, never staged):
  adminRolesGuards.test.js — non-super can't grant perms it lacks, can't edit
  its own role, can't escalate another role; super_admin bypasses;
  team_photographer name reserved.
- Add adminSettingsProtectedKeys.test.js pinning the change-detection: an
  unchanged general_site_url saves, an actual change 403s, super_admin changes it.
2026-08-16 14:59:52 +02:00
Paul Nothaft 89dc9623c1 fix(feedback): persist guest feedback settings, unshadow the guest route (#1030) (#1031)
Enabling Guest Feedback on an event could silently do nothing.

1. `updateEventFeedbackSettings` spread the request body straight into the
   knex UPDATE. The admin event form posts its whole client-side state,
   including three keys that were never columns on event_feedback_settings
   (`enable_rate_limiting`, `rate_limit_window_minutes`,
   `rate_limit_max_requests`), so the write threw and the route answered 500.
   Writable columns are now whitelisted; identity columns and timestamps stay
   server-managed.

2. EventDetailsPage swallowed that 500 in a bare `catch {}` ("Error already
   handled by mutation" — it is a different request), so the admin was left
   looking at "Event updated successfully" while the toggle never persisted.
   The error is surfaced now and the settings query is invalidated on success.

3. gallery.js declared a duplicate `GET /:slug/feedback-settings`. server.js
   mounts galleryRoutes before galleryFeedback, so it shadowed the real
   handler and dropped the per-guest caps (#655) from the guest payload — the
   gallery could never render the favorite/like limits or their counters.

Timestamps are written as ISO strings so they round-trip on both engines.

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

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:50:52 +02:00
Luca 27dedb13f3 docs: flip README links to docs.picpeak.app + delete docs/_to-migrate (#1000 phase 3) (#1023)
Phase 3 (final) of #1000. The deep content now lives on the docs site (PicPeak/docs#7), making docs.picpeak.app the single source of truth and removing the in-repo copies.

README links flip to docs.picpeak.app; the roadmap table is retired in favour of GitHub Issues. Deletes docs/_to-migrate/ and the five migrated pages. docs/migration-to-org.md stays — it's repo-transitional, not docs-site content.

In-app references to the deleted files are repointed at the docs site, including the CRM disclaimer strings in en.json/de.json and the contract-editor fallback.

Closes #1000.
2026-08-11 10:38:35 +02:00
Paul Nothaft 8e3573788b feat(downloads): per-gallery download resolutions (#858) (#1022)
Clients who need smaller files no longer make the photographer re-export. Two capabilities, both off by default.

STANDARD RESOLUTION — the size a gallery hands out for every ordinary download (single, selected, download-all). Global default in Settings, overridable per gallery with the NULL=inherit tri-state. The pre-built download-all zip is built AT the standard resolution, so changing it invalidates those archives, including a fan-out to inheriting galleries.

RESOLUTION PICKER — opt-in modal letting guests choose a different size. Custom archives are built as a DB-backed job the client polls, never cached. The picker never offers a size above the standard, and Original reappears only when the admin explicitly allows it.

Resize is fit:'inside' + withoutEnlargement — aspect preserved, never upscaled — applied before the watermark, since the mark is sized relative to its input.

Three rounds of external review hardened this: job archives are bound to the requester's visibility scope and re-validated at delivery, the streamed download-all path applies the cap, queue admission is bounded, and rejected resolutions no longer inflate download stats.

Closes #858.
2026-08-11 09:46:46 +02:00
Paul Nothaft 75bfad2b6a fix(slideshow): stop "no crop" fit letterboxing a pre-cropped frame (#1015) (#1018)
The slideshow resolved its image as preview_url || hero_url || url. preview_url is only emitted when lightbox_preview_enabled is on (default false), so a default install fell through to hero_url — the 1920x1080 fit:'cover' centre crop built for gallery header banners. object-fit: contain then letterboxed an already-cropped 16:9 frame, so portrait photos lost their top and bottom and 'Black Bars (No crop)' looked inert.

Emits slideshow_url (same aspect-preserved preview tier) unconditionally for image photos; the show prefers it and never falls back to hero_url. preview_url stays gated so the lightbox opt-in is unchanged.

Fixes #1015.
2026-08-10 13:32:14 +02:00
Paul Nothaft 1bf19a7caf fix(branding): route the gallery footer through <PoweredBy /> (#1008)
Closes #1003.

#999 centralised the attribution so branding_hide_powered_by is honoured
everywhere, but GalleryLayout kept its own inline guard. The gallery footer
therefore still flashed — it kept `!brandingSettings?.hide_powered_by`, where
undefined is falsy, so a white-labelled instance briefly showed the attribution
on first paint, on the surface a white-label customer is most likely to see.
And there were two implementations of one rule, which is the bug class #999
existed to close.

The footer appends the attribution to its copyright line inside an existing
<p>, so a straight swap would nest a <p> in a <p>. Added an inline variant
rendering a <span> that carries the leading ' | ' itself: the separator belongs
to the component, since a caller placing its own would have to repeat the
visibility guard to avoid leaving a dangling separator when the attribution is
hidden.

No extra request — GalleryView already uses usePublicSettings(), the same hook
and react-query key, so the cache is shared. The footer also picks up
common.poweredBy, so it is translated rather than hardcoded English.

Removes the now-unread hide_powered_by from GalleryLayout's prop type and the
mapping feeding it in GalleryView.

Four cases cover the variant — span not paragraph, separator present, separator
hidden with the attribution when white-labelled, hidden while loading. Each was
checked against the pre-fix shape: rendering a <p> or moving the separator out
breaks one.
2026-08-10 08:27:28 +02:00
Luca 2e495d7c48 feat(transfers): add PicTransfer — cross-event file transfers (#998)
Closes #997.

Send original files from any event as a token-protected download link, with an
optional client-upload channel. Strictly opt-in behind a new `transfers`
feature flag, default OFF.

Migrations 170-172 (transfers, transfer_files, transfer_extra_files,
transfer_uploads, transfer_recipients, transfer_downloads, default settings and
two email templates) — all hasTable/hasColumn-guarded and idempotent, with
destructive statements confined to down().

Backend: transferService (CRUD, 256-bit download token, 6-char upload token,
cross-event ZIP streaming of originals), admin CRUD routes, and two public
token routes. transferCleanupService runs an hourly retention sweep; source-event
photos are never touched. All three routers fail closed via
requireFeatureFlag('transfers').

Review closed two ownership blockers, both the same root cause — permissions
used where ownership was needed:

- photoIds arrived from the request body and were validated only for existence,
  so a scoped admin could bundle any event's originals and hand them out through
  the public download token. filterOwnedPhotoIds now resolves ids to their events
  and gates them through filterOwnedEventIds, on both the create and add-files
  paths.
- The transfer list was unscoped and carried each row's download token, so any
  admin with events.view could read another's token and fetch their originals.
  The list is now scoped by created_by, the token/url fields are stripped from
  the list payload, and a single router.use('/:id', requireTransferOwnership)
  covers all twelve /:id routes, 404ing foreign and missing alike.

The admin photo picker filters its event list to the same rule, so the UI stops
offering picks the API would discard.

Fork-PR workflows had not been approved since the fix commits, so the PR's green
checks were stale against the pre-fix head. Verified by dispatching tests.yml
against the actual head: backend and frontend both green.

Follow-up: neither ownership guard has a regression test yet.

Co-authored-by: Luca-Timo <Luca-Timo@users.noreply.github.com>
2026-08-09 13:40:03 +02:00
Lutthy 3bb4f1a1a8 fix(branding): hide "Powered by PicPeak" on every page, not only the gallery (#999)
branding_hide_powered_by only hid the attribution on the main gallery footer. It
stayed visible on the gallery password screen, client access page, Premium
layout, admin and customer login, accept-invite and CMS pages — AdminLoginPage
rendered it unconditionally with no guard at all, so the setting genuinely did
not apply there.

Routes those surfaces through one <PoweredBy /> component in components/common
that reads the public setting itself (the DynamicFavicon pattern) and renders
nothing when white-labeling is on, including while the settings are still
loading so a white-labelled instance never flashes the attribution.

Also collapses three duplicate translation keys (gallery.poweredBy,
adminLogin.poweredBy, customer.login.poweredBy) into a single common.poweredBy,
and translates pages that had 'Powered by' hardcoded in English across all 8
locales.

Fork-PR workflows were never approved so CI did not run. Verified locally
against cf243b44: tsc --noEmit clean, ESLint clean, vitest 124 passed across 24
files, and npm run build succeeds.

GalleryLayout.tsx keeps its own inline guard and is not routed through the new
component; tracked separately.

Co-authored-by: lbossuyt <lbossuyt@users.noreply.github.com>
2026-08-09 11:16:18 +02:00
Luca f00661511c feat(gallery): admin preview skips the password on protected galleries (#981)
Closes #868.

A logged-in admin opening a published, password-protected gallery is let
straight in, mirroring the existing draft-visibility bypass.

Mechanism: an explicit ?admin_preview=1 intent flag AND a verified admin session
read from the httpOnly admin_token cookie (or an admin-typed Bearer) — never a
token from the URL. This retires the old ?preview=<raw-admin-JWT> scheme, which
leaked a 24h admin token into the address bar, referrers and proxy logs.

Per-request bypass only: no gallery JWT is minted, the password endpoint is
never reached so the login_attempts lockout buckets stay clean, and admin
previews are excluded from guest analytics (access_logs, download counts,
per-photo view_count, notification bells).

Review (two rounds) closed three blockers and two concerns:

- Transport: verifyGalleryAccess now resolves admin preview before any gallery
  credential, and isAdminPreview reads the admin cookie first and type-checks
  every candidate — so an admin Bearer no longer 403s on the type gate, and a
  coexisting gallery session can no longer shadow the admin cookie.
- Reveal mode (#838) is a second consumer of isAdminPreview; its bypass is
  unchanged, only the transport moves. revealMode.test.js updated off the
  retired scheme and now carries a coexisting gallery Bearer.
- Admin previews no longer inflate per-photo view counts, and the internal photo
  redirects preserve the flag via withPreview() so they still authorise.
- Happy path: GalleryPage renders GalleryView directly for a preview instead of
  attempting the public empty-password auto-login, which 401'd against a
  genuinely protected gallery and stranded the page on the skeleton.

The backend job timed out once at the 10-minute CI limit; a re-run completed in
2m02s, in line with main's ~2m10s baseline, so that was a runner flake rather
than a hang.
2026-08-04 16:48:07 +02:00
Paul Nothaft 137a42f259 feat(admin): surface the registry move through the update check (#993)
Relates to #985 — does NOT close it.

Adds registryMigrationRequired to the update-check payload (stable channel below
3.45.0) and an amber block in UpdateNotification explaining that the retired
registry path still responds, so `docker compose pull` appears to succeed while
serving the same frozen build.

Known limitation, established in review and merged deliberately: this cannot
reach the operators #985 describes. PicPeak is self-hosted, so the update-check
code runs inside the operator's own image — a v3.44.0 install runs v3.44.0's
backend forever, and the only external call returns release metadata, not logic.
Every build containing this predicate is >= 3.45.0, where it is false by
definition. The release-notes fallback fails too: the changelog modal shipped
2026-05-29, two days after the freeze.

Correct for any future rename, no runtime cost, but #985 stays open — the
population it describes still has no in-app channel. Viable routes are external
(retired GHCR package description, repo README, docs).

'0.0.0' is excluded from the predicate: that is getCurrentVersion's fallback for
an unreadable package.json, i.e. a broken install, not a pre-rename one.
2026-08-04 16:36:50 +02:00
Paul Nothaft 4b53b64277 fix(accounting): gate cross-add counters on the permission their endpoint checks (#984)
Closes #983.

The two cross-add counter queries added in #979 were enabled on customers.edit,
but neither endpoint checks that permission:

  HoursSection      -> GET /expenses/inbound/by-customer/:id  needs accounting.view
  CustomerCrmPanels -> GET /customers/:id/hour-entries        needs customers.view

An admin holding customers.edit but not the corresponding read permission fired
a guaranteed 403 on every customer-detail render. It degraded safely — the count
stayed at its 0 default so the cross-add was never offered, which is the right
outcome for that role — so this was request noise rather than broken behaviour.

Each guard now requires both: the read permission to fetch the count, and the
write permission because there is no point offering the cross-add to someone who
cannot create the combined invoice.

No seeded role is affected: migration 123 grants accounting.view and
accounting.manage together, and customers.edit projects forward from
customers.create, which migration 090 always grants alongside customers.view.
2026-08-04 14:17:34 +02:00
Luca 165cebdb5c feat(accounting): re-bill proof attachment, CRM panel & hours↔re-bills cross-add (#979)
Closes #866.

Three features, all behind the `incomingInvoices` feature flag:

1. Attach the stored supplier proof PDF to the client-invoice email when a
   captured invoice is re-billed/passed through, as a SEPARATE attachment so
   invoice immutability holds. Global default (off), per-customer tri-state
   override, and per-file selection in a new Send dialog. A missing proof at
   issue time stamps inbound_documents.proof_attach_error rather than silently
   dropping, and never blocks the send. Proof filename is a configurable
   template with {INVOICE} {SUPPLIER} {YEAR} {MONTH} {SEQ}/{SEQ:0Nd} tokens.

2. Re-bills & passthrough panel under CRM → Customer, grouped Open/Sent/Paid
   with status derived from the linked invoice lifecycle rather than a
   duplicated column.

3. Cross-add dialog rolling open hours and open re-bills into one invoice,
   symmetric from both entry points. The two stay distinct, contiguous line
   groups — never merged into shared line items.

Migration 169 is additive, hasColumn-guarded and idempotent.

Review (two rounds) closed two concerns:

- Storno stranding: nothing cleared inbound_documents.billed_invoice_id when a
  covering invoice was cancelled, so a Storno'd re-bill showed as Open in the
  new panel while every billing path filters on that column being NULL — the
  supplier cost could never be re-billed. releaseRebillsForCancelledInvoice now
  detaches the linkage on both invoice-cancel paths, with a regression test on
  the issued-cancel path.

- Permission gating: the new controls rendered on data presence alone while
  their endpoints require accounting.view / accounting.manage / customers.edit.
  Now gated at both the query and render layers.

Known follow-up: two cross-add counter queries are gated on a permission their
endpoint does not check (HoursSection.tsx:174, CustomerCrmPanels.tsx:270) —
degrades safely, one line each.
2026-08-03 22:03:31 +02:00
Paul Nothaft 67592fc569 fix(projects): stop the cockpit offering email controls the API rejects (#976)
Closes #969.

The cockpit's email feed rendered preview/resend/cancel/retry/send-now for every mail, consulting neither the caller's role nor their permissions, producing controls that always failed:

404 - requireOwnedQueuedEmail scopes queued mail through email_queue.event_id AND ownership of that event. CRM document mail carries no event_id; and project ownership does not imply event ownership, so a project the caller owns can hold another admin's event.
403 - preview needs events.view but the four write actions need email.send.

getProjectOverview now stamps each email with an authoritative canAct, mirroring filterOwnedEventIds; created_by is selected only for that check and stripped before the response. The cockpit reads canAct and combines it with email.send. A missing canAct reads as false.

Regression from the GHSA-93x4 fix in #960/#966, which added the ownership middleware.
2026-08-03 14:48:50 +02:00
Paul Nothaft 1b4e5fee3e fix(security): bound inbound-mail resources, redact secrets from logs (GHSA-2qf9, pgmp, r794) (#959)
* fix(security): bound inbound-mail resources, redact secrets from logs (GHSA-2qf9, pgmp, r794)

GHSA-2qf9 — emailIntakeService downloaded, parsed and persisted every message
with no size, attachment-count or attachment-byte limit, reachable
unauthenticated by anyone who can email the operator's mailbox:
- fetch the envelope with `size` (same cheap pass) and refuse an oversized
  message BEFORE downloading its source;
- cap attachment count and cumulative attachment bytes;
- limits env-overridable, defaults generous for real supplier invoices.

The teeth were in the dedup key. received_emails.message_id is varchar(512)
UNIQUE, and the failure path wrote `err-<uid>-<Date.now()>`, which can never
match the envelope-derived messageId the dedup pass compares against — so an
oversized (or overlong-Message-ID) mail was re-downloaded every poll forever,
and an OOM-kill/restart just resumed the loop. Size-skips are now recorded
under the REAL message id, and overlong ids collapse to a stable sha256 key
that always fits the column.

GHSA-pgmp / r794 — new sanitizeForLog() util (key-name deny-set, recursive,
cycle-safe) applied to the three request-body log sites in adminEvents/crud.js,
plus sanitizeValidationErrors() because express-validator's errors.array()
embeds the SUBMITTED value per field — a rejected plaintext password was still
logged. Scope is wider than filed: the update path also logged
client_password_hash and a LIVE client_share_token bearer credential.

Also: the one-time setup token was logged at warn AND printed to stdout on
every first boot, putting a live first-admin credential in combined.log,
security.log and `docker logs`. It is now written to the 0600 token file and
only surfaced when that write fails — the last-resort path it existed for.

* fix(security): codex round 3 — repair the first-run token recovery flow (GHSA-r794)

Two regressions from keeping the setup token out of the logs.

1. server.js decided whether to print the token by calling existsSync() on the
   candidate path. That answers a different question than "did the write
   succeed": a stale, read-only or directory-shaped SETUP_TOKEN reports as
   present, so the banner suppressed the live token and pointed the operator at
   content that is not it — leaving the current token only in combined.log
   under default production logging. setupService now records the path the
   write actually produced and exposes it via writtenSetupTokenFile().

2. The setup screen, its EN/DE strings, README, SIMPLE_SETUP and .env.example
   all still told first-time users to run
   `docker compose logs backend | grep -i "setup token"`. On the normal path
   that command now returns a path banner and no credential, so the documented
   browser-first onboarding could not be completed. They now point at
   `docker compose exec backend cat /app/data/SETUP_TOKEN`, with the log
   fallback described as what it is — the failure path.

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:17:11 +02:00
Paul Nothaft 1c8f7d58a8 fix(security): redact gallery share tokens from analytics tracking (GHSA-7m6c) (#952)
* fix(security): redact gallery share tokens from analytics page-view tracking (GHSA-7m6c)

* fix(security): codex round-1 — actually disable raw auto-tracking (GHSA-7m6c)

The previous patch was inert: App.tsx passed autoTrack:true (so Umami's
data-auto-track=false was never set) and the sanitized trackPageView had no
caller (useAnalytics sits outside <Router>), so the raw token URL still hit
the collector.

- Umami: drop autoTrack:true → data-auto-track=false; page views now come
  from a sanitized manual tracker.
- Rybbit: its initial-load auto pageview can't be intercepted client-side, so
  use native data-mask-patterns=['/gallery/**'] to strip the token on every
  auto-tracked view; skip manual tracking for it to avoid double counting.
- Mount <AnalyticsRouteTracker/> INSIDE <Router> so manual tracking runs.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:38:33 +02:00