Commit Graph

2146 Commits

Author SHA1 Message Date
Paul Nothaft 73b08a7b5c fix(email): give gallery_created a real German translation
translations.de for gallery_created was the English copy word for word, while
nl/pt/ru/fr/es/sl are all localized. This is the mail sent on every gallery
creation, so German-default installs have been silently mailing English.

Root cause chain, fresh installs only: 001_init seeds the English template;
059 introduces the multilingual columns and fills subject_de/body_html_de/
body_text_de from their _en counterparts (its own comment: "Copy to German as
default"); 075 then materialises exactly those columns as the `de` row. The
real German only ever existed in migrations/legacy/026, and run-migrations.js
runs core/ only for fresh installs -- so every install created since 059 has
the English-as-German row.

A code-only fix would have changed nothing: knex will not re-run 059/075, so
existing installs would keep the bad row forever. Fixed as a content migration
following the repo's precedent for template repairs (094, 172).

Conservative about what it touches: the German row is rewritten only while it
is still byte-identical to English (or empty) -- precisely the broken state --
so a legacy install whose German came from 026, or any admin-edited template,
is left alone. Also repairs the legacy _de columns, which are still
emailProcessor's fallback path. Idempotent, hasTable-guarded, no-op down()
(reverting would restore English-as-German).

Placeholder parity with the English original is exact and test-asserted:
host_name, event_name, event_date, gallery_link, gallery_password, expiry_date.

Two related gaps found but deliberately not fixed, both outside the reported
bug: expiration_warning, gallery_expired and archive_complete are German-is-
English on fresh installs through the identical 059 mechanism (legacy 026
fixed all four). And nl/pt/ru/fr/es/sl additionally wrap a
{{#if welcome_message}} block that the English original lacks, even though
welcome_message is passed at send time -- so EN and now DE drop the
photographer's personal note. That is an English-side gap needing its own
decision.

Refs testplan REPORT.md #16 (Part 3, J.04).
2026-09-01 16:40:02 +02:00
Paul Nothaft 76a1453fa7 fix(calendar): don't put a fixed reference date in the month header
dayHeaderContent assumed arg.date is always the real column date. It is in
the time-grid views, but FullCalendar v6 fills it from an internal reference
week (1970-01-04..10) for dayGridMonth headers, so the month header read a
fixed "Mo 05.01. ... So 04.01." regardless of the visible month. Body dates
were correct; only the header row was wrong.

Interpretation (flagged as ambiguous): a month-view column header labels seven
generic weekday columns shared by every week in the grid -- it has no single
date, so forcing one in is wrong by construction rather than just mis-computed.
Month view now renders the localized weekday alone, which is also FC's own
default there; timeGridWeek keeps weekday + DD.MM. since each column really is
one date.

Branches on view.type === 'dayGridMonth' exactly, not a dayGrid prefix:
dayGridWeek/dayGridDay do have real per-column dates and a prefix match would
break them if either is ever added.

Extracted to an exported formatDayHeader so it is testable without mounting
the page; the test mounts a real FullCalendar in both views, so an upgrade
that changes the arg.date contract fails rather than silently regresses.
FullCalendar dependency untouched.

Refs testplan REPORT.md #10 (Part 8, S9).
2026-09-01 16:31:09 +02:00
Paul Nothaft fc7cb226f4 fix(archives): run search, filter and sort server-side
ArchivesPage fetched one 20-row page and then filtered and sorted only that
array in memory, while "Showing X of 802" / "Page 1 of 41" kept reporting the
full unfiltered count. Searching for an archive that exists but is not on the
current page returned a false "0 results" with no hint the search was
page-scoped.

The backend did not support the params (it read only page/limit and hardcoded
orderBy archived_at desc), so all three are new. Follows adminEvents/crud.js
for the shape and customerAccountsService for the case-insensitive predicate:
whereRaw with a bound parameter, never interpolated, and sortBy whitelisted to
date/name/size before it reaches orderBy. The same applyFilters() closure runs
against both the count query and the row query, so the total cannot drift from
the rows again.

Frontend mirrors EventsListPage: 300ms debounce, reset to page 1 on any query
change, placeholderData so keystrokes don't flash the spinner.

Two interpretation calls:
- sortBy=size orders by summed photo bytes, not the zip's on-disk size. The
  Size column comes from a per-row fs.stat done after pagination and there is
  no archive_size column, so a global sort on the real zip size would stat all
  802 files per request. Ordering is near-identical except for rows whose zip
  is missing. Adding events.archive_size would be a migration, out of scope.
- No LIKE-metacharacter escaping. escapeLikePattern() does .replace(/'/g,"''"),
  which corrupts a bound value ("Sarah's Birthday"), and its backslash escaping
  is a no-op on SQLite without an ESCAPE clause. Matched customerAccountsService
  instead. A literal % typed by an admin acts as a wildcard in a read-only
  search; no injection risk.

Pre-existing and untouched: the four stat cards still aggregate the current
page only.

Refs testplan REPORT.md #9 (Part 3, I.01).
2026-09-01 16:31:09 +02:00
Paul Nothaft 3f6c81a846 fix(photos): treat category_id 0 as uncategorized instead of storing it
Genuine product bug, found behind the adminPhotos.reference suite (which was
failing for an unrelated reason -- see below).

parseInt('0') is 0 and !isNaN(0) is true, so a '0' category_id was written
literally. photo_categories.id is an increments() column, so 0 can never be a
real category, and every read path already assumes it cannot happen: the list
mapper does `category_id || type` (0 is falsy, renders as uncategorized) and
the list filter explicitly skips '0'. The result was a filter black hole -- the
photo matches no numeric category filter, and misses the "uncategorized"
filter too because that is whereNull(). Displayed as uncategorized, reachable
by nothing.

null rather than a 400: unparseable input ('abc' -> NaN) already falls through
to null, so 400ing on '0' while silently accepting 'abc' would be incoherent,
and '0' is just the HTML <select> shape where the "none" option carries
value="0".

Fixed at all three call sites that share the branch -- PATCH /photos/:photoId,
POST /photos/bulk-update, and the upload route, where the dangling 0 was
written at creation time and the scope-validation guard
(`if (parsedCategoryId && ...)`) skipped on the falsy 0 and let it in
unvalidated. Only the PATCH one was behind the failing test; leaving the other
two would have left the bad state creatable.

The suite's 3 failures were all masked by a fixture gap, not this bug: it
stubs middleware/auth but not middleware/permissions, so requirePermission's
admin_users JOIN roles query hit tables the fixture never creates and every
request 500'd before reaching a handler. Stub it, bring the photos fixture up
to the 7 migrations it had drifted behind, and correct a stale 200 that became
202 when uploads went async in 851744c3.

Known adjacent gap, not fixed (wider than this bug): PATCH and bulk-update
accept any positive category_id with no existence or scope check, unlike the
upload route which validates event_id = X OR is_global per #500/#525 -- so a
photo can be PATCHed into another event's category.

Refs testplan REPORT.md #22 (Part 1.2.01).
2026-09-01 16:30:46 +02:00
Paul Nothaft 18715b5efd fix(gallery): show a guest's own upload without a hard reload
Correction to the QA root cause: the 304 is correct server behaviour, not a
stale cache. The guest upload route answers 202 and queues the file, so the
row lands as processing_status 'pending', and the photos list returns only
completed rows. The immediate post-upload refetch therefore produces a
byte-identical payload, express's body-derived weak ETag matches, and the
browser is answered 304. Cache-busting would not have fixed it -- a busted
request 200ms after the upload returns a 200 whose body still lacks the
photo. The hard reload only worked because it happened seconds later.

Poll instead: refetch immediately and every 2s until the photo count exceeds
the pre-upload baseline, with a 60s deadline and cleanup on unmount. This
also replaces two window.location.reload() callbacks, which could not have
waited for the worker anyway and threw away scroll and folder state.

Not done (out of scope, recommended follow-ups): GET /api/gallery/:slug/photos
sets no cache headers at all for private per-guest data and relies on
heuristic freshness -- noStoreCache.js already exists and would fit. And the
guest upload flow has no progress signal, so the UI polls blind where a
processing-status endpoint (or pending counts in the photos payload) would let
it say "processing...".

Refs testplan REPORT.md #12 (Part 4, P4-E.01).
2026-09-01 16:30:06 +02:00
Paul Nothaft 9d4bd7ab30 fix(gallery): stop devtools protection from breaking the whole page
With enable_devtools_protection on, every click on the gallery failed and
trivial script evaluation hung -- confirmed on two independent events. A
guest with DevTools open for an unrelated reason (network tab, a CDP-attaching
extension) got a silently unresponsive gallery with no error shown.

Mechanisms found, all in the hook (both callsites were innocent):

1. detectByDebugger ran a bare `debugger;` on every tick at medium/high
   sensitivity -- and the per-event flag maps to medium. With any debugger or
   CDP client attached the renderer paused there continuously. This is why
   Runtime.evaluate hung on 1+1 and clicks reported their target gone.
2. Four separate detectors called console.clear() -- the observed clear loop.
3. handleDevToolsDetected was useCallback([options]) over a fresh object
   literal, so runDetection changed identity every render and the effect tore
   down, rebound and re-ran detection on every render -- a 1s interval turned
   into a tight loop.
4. detectByConsole monkey-patched console.log/error/warn/info every tick
   inside a try/catch that swallowed throws, so a throw between patch and
   restore left the guest's console permanently hijacked.
5. contextmenu was preventDefault'd document-wide regardless of target,
   killing the menu on text, links and form fields -- disable_right_click is
   the separate setting meant to cover the whole page.

Kept: the DevTools shortcut keys (only those exact combos; everything else
passes through), the docked-DevTools viewport heuristic as a pure measurement
on resize plus one check at mount, right-click blocked on IMG/CANVAS/VIDEO
targets only. The public API (onDevToolsDetected, redirectOnDetection,
redirectUrl, isDetected, reset) is unchanged, so PhotoLightbox needed no edit.

Removed: debugger traps, console.clear, console monkey-patching, the
timing/element/toString probes, the polling interval, document-wide
contextmenu blocking. Undocked DevTools is now deliberately undetectable --
every technique that catches it costs the page its responsiveness for
everyone. This is a deterrent, not a security boundary.

Also raised the viewport threshold (100 -> 160/200/260 by sensitivity):
browser chrome with a bookmarks bar is ~140px, so the old check false-positived
on ordinary windows, which at protectionLevel 'maximum' redirected legitimate
guests off the gallery.

Refs testplan REPORT.md #3 (Part 4).
2026-09-01 16:30:06 +02:00
Paul Nothaft c6cb01865e fix(settings): derive the sidebar preview from the real sidebar declaration
SidebarPreview kept its own hand-maintained 6-item array with only two gates
wired (analytics, userManagement), so toggling e.g. Workflows changed nothing
in the preview even though it does add a real sidebar entry once saved.

Export AdminSidebar's `navigation` as `adminNavigation` (2 lines) and derive
the preview from it, so every gate -- transfers, messaging, analytics,
userManagement, clients incl. its featureFlagsAny set, accounting, workflows --
is covered and the two can't drift again.

Note the report's item list was partly wrong: Quotes, Contracts, Invoices,
Hours, Projects, Calendar and the CRM dev tools have no top-level sidebar
entries at all -- they are sub-nav inside /admin/clients and surface in the
preview through the CRM entry's featureFlagsAny.

Permission filtering is deliberately not applied (unchanged): the preview
answers "what do these flags do to the sidebar", not "what can this admin see".

Refs testplan REPORT.md #20 (Part 3, J.14).
2026-09-01 16:29:33 +02:00
Paul Nothaft 3e16b81be8 fix(settings): clear the accounting flag when its parent is turned off
Turning Invoices off left the Accounting master flag -- and its sidebar
entry -- silently on and freshly unlocked, because the bills=true =>
accounting=true force-enable had no reverse.

A dependency model already exists and handles every true parent->child pair
(quotes->bills, calendar->calendarBooking, accounting->{taxReport,
incomingInvoices,expenses}), mirrored client-side in applyDependencyRules and
server-side in adminFeatureFlags.js. The gap is only this asymmetric rule.

Interpretation, two decisions:

- Cascade on the client at toggle time, not on the server at persist time.
  applyDependencyRules is a pure invariant over a single state (the GET
  handler runs it too), so it structurally cannot distinguish "accounting is
  on because the admin wants it" from "...because bills forced it". The
  Features tab PUTs the full flag set, so on the wire an explicit true and a
  stale forced true are byte-identical -- a server-side transition rule would
  silently discard an admin who turns Invoices off and deliberately keeps
  Accounting on in the same save. The client is where the gesture is known.
  The persisted result is still server-enforced: the client sends
  accounting:false and the existing server invariant forces the sub-flags off.

- Re-enabling the parent does NOT restore children. Flags are state, not
  history, and silently re-lighting a sub-feature with its routes and sidebar
  entries is the exact failure this bug is about.

Refs testplan REPORT.md #8 (Part 8, S9).
2026-09-01 16:29:33 +02:00
Paul Nothaft 3790156fc9 fix(accounting): let "bill to a customer" work with the portal off
CustomerAccountPicker returns null when customerPortal is off. That is right
for its original use -- the event form assigns portal logins that bypass the
gallery password -- but the Accounting flows reuse it as-is, so their required
"Client" field rendered a bare label with no input and the submit button could
never enable, with no explanation. Accounting-on + CRM-off is a valid,
UI-supported flag combination.

Took option (a): the bill-to-customer path does not depend on the portal.
POST /admin/expenses/:id/invoice is gated by requireExpenses + accounting.manage
only, and /admin/customers{,/search} are permission-gated rather than
flag-gated -- POST /admin/customers exists precisely to create passive,
portal-less customers "to attach a quote / invoice / gallery to". The
un-gated CustomerPicker used by the quote/bill/contract editors is the
precedent. (The comment claiming search 410s with the flag off was stale.)

Add portalAssignment (default true) so the gate and the portal-specific
label/help text apply only in event-assignment mode; the accounting call
sites render their own label. Event-form behaviour is unchanged.

Also fixes AccountingInboxPage's TriageModal, which has the identical
label-only failure on the rebill disposition from the same root cause --
outside the reported surface, but leaving it would half-fix the bug.

Refs testplan REPORT.md #7 (Part 8, S10).
2026-09-01 16:29:33 +02:00
Paul Nothaft 1d84c738d8 test: repair four stale backend suites
All four asserted contracts the product has since moved past. No genuine
product bugs behind any of them; assertions were tightened, not loosened.

adminAuth (3 tests): never mounted errorHandler, so ConflictError/
ValidationError arrived as empty Express defaults. The route also checks
username before email, so the "email conflict" fixture was hitting the
username branch. Mount the handler, fix the fixture, match the real response
shapes.

backupService.enhanced (12 tests): three stacked drifts -- the db mock had no
.returning(), so every runBackup threw at the insert; ensureDatabaseDumpForBackup
now lazily requires ./databaseBackup inside the run, which fails under
mock-fs; and the rsync path moved from exec(shell string) to
spawnAsync('rsync', args) with an isHostAllowed SSRF preflight. Also updates
getBackupStatus to its current shape (frontend aliases, nextScheduledRun null
when no schedule is enabled, #871).

adminSettings.logo: POST /logo gained requirePermission('settings.edit');
the hand-rolled db mock returns a bare Promise from select(), so the
permission lookup threw a TypeError into a 500. Mock the permissions
middleware alongside the already-mocked auth.

crmMintPaths (2 tests): macOS-only. The expected prefix was realpath'd while
the services persist under the raw STORAGE_PATH -- identical on Linux CI
(/var vs /private/var only diverges on macOS), which is why it passed there.
The comment justifying the realpath referenced process.cwd() behaviour the
services no longer have.

Refs testplan REPORT.md #22 (Part 1.2.01).
2026-09-01 16:29:03 +02:00
Paul Nothaft c5c5a6b0c8 fix(webhooks): write delivery timestamps as ISO strings
Applies the repo's documented Jest+SQLite guidance (CLAUDE.md) to the webhook
delivery path, which was the last one still passing raw Date objects into
knex writes. Under jest those store as the literal string "[object Object]",
so next_retry_at came back NaN and the retry/backoff test could not assert on
it. Production (PG, and SQLite outside jest) was unaffected.

Convert the timestamp writes -- and the `next_retry_at <=` due comparison,
which has to stay type-consistent with them -- to .toISOString(), matching
the existing precedent in downloadJobService.js.

Refs testplan REPORT.md #22 (Part 1.2.01).
2026-09-01 16:29:03 +02:00
Paul Nothaft 31ffbc8ae4 fix(users): give the cancel-invitation dialog a distinct confirm label
The cancelInvitation dialog type fell through to the generic
t('userManagement.cancel'), colliding with ConfirmDialog's own dismiss
button -- two buttons both reading "Cancel", where clicking the wrong one
does the opposite of what the user intends.

Reuse the existing userManagement.cancelInvitation key: "Cancel Invitation"
vs "Cancel" (EN), "Einladung abbrechen" vs "Abbrechen" (DE). No new key.

Refs testplan REPORT.md #19 (Part 3, I.04).
2026-09-01 16:28:31 +02:00
Paul Nothaft c19e944b99 fix(events): guard create-event submit against re-entrant submissions
Correction to the QA root cause: the submit Button has carried
`disabled={createMutation.isPending}` since 3424bd22, and it does disable
synchronously after the first click (validateForm's setErrors forces a
re-render that re-reads the mutation snapshot), so an ordinary double-click
could not by itself produce two POSTs.

What was actually missing is a re-entrancy guard in handleSubmit, so any
submission that never touches the button -- implicit form submission, a
programmatic requestSubmit, or two submit events dispatched in one task,
which is the likely shape of the QA repro -- still fired two mutate() calls
racing the same computed slug, one of which 500'd on events_slug_unique.

Add isSubmittingRef (matching the isMountedRef idiom already in this file),
cleared in onSettled. Test proves 2 submit events -> 1 POST.

Not done: turning the backend's raw 500 on events_slug_unique into a
graceful "event already exists" 409. That is an adminEvents.js change and a
separate concern from the client-side race.

Refs testplan REPORT.md #6 (Part 7.03).
2026-09-01 16:28:31 +02:00
Paul Nothaft 673f05556d fix(settings): don't crash on a fresh load before permissions resolve
On a hard navigation or deep link, usePermissions() starts out empty, which
filters every settings nav group down to nothing. allItems is then [], so
`allItems.find(...) ?? allItems[0]` yields undefined and `<activeItem.icon>`
threw -- sometimes into the error boundary, sometimes racing past it.
Reproduced 6+ times across the webhooks/moderation/slideshow/security/events
tabs; in-app SPA navigation never hit it.

Extend the file's existing early-return to `isLoading || permissionsLoading`.
activeTab lives in useState seeded from ?tab= at mount, independent of the
gate, so deep links still land on the right tab once permissions arrive.

Also null-guard activeItem before the section heading: a role holding zero
settings-tab permissions crashes identically even after permissions finish
loading, which the loading gate alone does not cover.

Refs testplan REPORT.md #11 (Part 3, J.08).
2026-09-01 16:28:21 +02:00
Paul Nothaft c2428aa23a fix(events): render a not-found state instead of hanging on a 404
EventDetailsPage gated on `if (eventLoading || !event)`. The backend returns
a clean 404 for a nonexistent id, but once isLoading settled false `event`
stayed undefined forever, so /admin/events/999999 sat on the loading spinner
permanently with no error state.

Destructure isError and split the gate: spinner while loading, then a
not-found Card. Reuses the existing `events.notFound` key (already used by
EventFeedbackPage for the same entity) and the Card padding="lg" not-found
shape from contracts/ContractDetailPage. No new i18n keys.

Refs testplan REPORT.md #5 (Part 7.02).
2026-09-01 16:28:21 +02:00
Paul Nothaft 3489610cb8 fix(analytics): warn about the CSP allowlist on every tracker provider
A self-hosted Umami/Rybbit domain configured in Settings -> Analytics is
always blocked by the static script-src allowlist, silently, with only a
console error. The amber CSP warning that explains this already existed but
was rendered only inside the "custom" provider panel -- not on the two
providers where an admin actually types a self-hosted URL.

Extract it to a local CspWarning and render it in the Umami and Rybbit panels
too. Both translation keys already exist in en.json/de.json.

Interpretation: the dynamic-CSP option was investigated and rejected as not
reachable for the header that actually governs these documents. In the Docker
deployment nginx.conf:58 does `proxy_hide_header Content-Security-Policy`, so
helmet's CSP and the res.setHeader CSP at server.js:445 are stripped before
they leave the stack -- nginx's static server-level CSP is the only one the
browser sees for the SPA documents the tracker is injected into. nginx.conf is
COPYied verbatim by the Dockerfile (only index.html goes through envsubst),
and the tracker URL lives in the DB rather than the environment, so making it
reflect the setting would need start-time templating plus a DB read. The CSP
itself therefore still has to be edited by hand; the warning now says so where
the admin can see it.

Refs testplan REPORT.md #18 (Part 3, B.02).
2026-09-01 16:23:40 +02:00
Paul Nothaft 5fa04e647e fix(categories): validate category name length instead of 500ing
photo_categories.name is varchar(100). Neither the input nor the route
checked length, so a 267-char name hit a raw Postgres "value too long",
came back as a 500, and the form silently stayed open with no toast.

Add isLength({ max: 100 }) to POST / and PUT /:id (the update route had the
identical gap) so it returns the route family's normal 400 { errors: [...] }
shape that the toast helper already renders, and maxLength={100} on the three
category-name inputs (create + inline edit in CategoryManager, create in
EventCategoryManager).

Refs testplan REPORT.md #4 (Part 7.01).
2026-09-01 16:23:28 +02:00
Paul Nothaft 6f7aa59fad fix(feedback): align word-filter severity vocabulary with the admin UI
WordFilterManager.tsx sends low/moderate/high/block; the validator only
accepted mild/moderate/severe, so 3 of the 4 UI levels 400'd with "Invalid
severity level" -- including "block", the strongest advertised tier.

Aligning isIn() alone would have made "block" accepted but semantically
inert: feedbackModeration.js branches on 'severe'/'moderate', so "block"
would fall through to the flag-only branch and behave as the weakest level.
Map the UI vocabulary onto the existing outcomes instead, per the legend the
UI itself renders: block -> reject, moderate/high -> needs approval,
low -> flag only.

'severe' stays an accepted alias in the blocking predicate so any row written
through the old validator (the field is optional, so a direct API caller
could have stored one) keeps blocking. No data migration needed: the column
is a bare varchar(20) default 'moderate' with no CHECK, no enum and no seed
rows, and 'mild' already lands in the flag-only branch that 'low' now means.

Refs testplan REPORT.md #2 (Part 3, J.11).
2026-09-01 16:23:22 +02:00
Paul Nothaft e18ab0d842 fix(upload): enforce the configured per-file size limit on admin uploads
getMaxFileSizeBytes() (general_max_file_size_mb, default 50MB) was only read
by adminSettings.js to display the value. The admin upload routes streamed
against a hardcoded ceiling instead, so the dropzone's "max. 50MB pro Datei"
was never enforced:

- adminPhotos.js POST /:eventId/upload    -> 10GB hardcoded
- adminPhotos.js POST /:eventId/chunked-upload/init -> 10GB hardcoded
- v1/events.js   POST /events/:id/photos  -> 100MB hardcoded

Resolve the cap per request (it is admin-configurable at runtime) and build
the multer instance from it, mirroring what gallery.js and adminTransfers.js
already do. The 400 names the configured limit and reuses gallery.js's exact
error string so the frontend surfaces it identically. getMaxFileSizeBytes()
clamps to MAX_ALLOWED_FILE_SIZE_MB, so the 10GB hard ceiling still bounds
everything.

gallery.js (guest upload) already enforced this correctly and is unchanged --
the report's claim that it did not is stale.

Interpretation: general_max_file_size_mb is a single per-file cap with no
photo/video split, and gallery.js already applies it blanket to guest video
uploads, so admin video uploads now share it too. On a default install that
means a 200MB video needs the setting raised first -- which is what the UI
has been advertising all along.

Refs testplan REPORT.md #1 (Part 7.06).
2026-09-01 16:23:04 +02:00
Paul Nothaft afaa00f428 chore: remove issue screenshots from the source tree (#1259)
Two PR screenshots landed at the repo root in #1241 and have been shipping as
part of the source tree since. Nothing references them.

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

Verified no other tracked file matches the new patterns.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-01 09:34:40 +02:00
Paul Nothaft 884580d849 chore(main): release 3.122.2-beta.0 (#1258)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 11s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / smoke-aio (push) Failing after 11s
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.122.2-beta.0
2026-09-01 07:30:45 +00:00
Paul Nothaft 66989d70f1 fix(upload): let Android guests reach the camera without breaking video (#1244)
* fix(upload): let Android guests reach the camera without breaking video

Recent Android versions route an <input> whose accept list is entirely
image/video types to the system photo picker, which has no camera entry —
so a guest standing at the event can only pick an existing photo, not take
one. Including a type that picker can't handle forces the general chooser,
which does offer the camera.

Two corrections to the original approach in #1117:

- the .pdf is gated on the Android UA. It was appended unconditionally, so
  desktop and iOS pickers — which behave correctly — gained a selectable
  PDF that only produces an error when chosen.
- no image-only guard. #1117 rejected every non-image file before the
  existing allowlist check, which breaks video uploads outright on any
  install configured for them (fileTypes.ts maps mp4/m4v/webm/mov/avi and
  general_allowed_file_types is admin-editable). The guard was also
  redundant: extensionsToMimeTypes only emits types it has a mapping for,
  so application/pdf can never be in allowedMimeTypes and the existing
  "Invalid file type" check already rejects a picked PDF.

The empty-string fallback to 'image/*, .pdf' goes too — extensionsToMimeTypes
already falls back to the configured default set, and image/* was broader
than the admin's allowlist.

Lives in fileTypes.ts as a pure function so the UA behaviour is testable;
the component keeps a one-line useMemo.

Co-authored-by: Zszywany <Zszywany@users.noreply.github.com>

* fix(upload): use android/allowCamera instead of .pdf for the chooser fallback

Same mechanism, better token. Chrome on Android 14/15 sends an input whose
accept list is all media types to the photo picker, which has no camera tile;
adding a value that picker cannot satisfy makes it fall back to the general
chooser, which does offer the camera.

`.pdf` achieves that but advertises PDFs as selectable — pick one and the
existing allowlist check answers "Invalid file type", which is a dead end we
put in front of the guest ourselves. `android/allowCamera` is the token the
workaround converged on: not a real MIME type, matches no file, so it flips
the picker without offering anything.

Neither token ever widened what is accepted — addFiles validates against
extensionsToMimeTypes, which only emits types it has a mapping for — but not
showing the guest a choice that cannot work is worth the one-line change.

Verified in a browser rather than asserted: the real component rendered under
an Android UA emits

  image/jpeg,image/png,image/webp,android/allowCamera

and under a desktop UA

  image/jpeg,image/png,image/webp

with the visible modal identical in both, and the format hint still reading
"JPG, JPEG, PNG, WEBP" — the token does not leak into anything a guest sees.

* fix(upload): keep the camera token off Firefox for Android

External review round. The gate was a bare /Android/i, which Firefox for
Android matches — so it received a token invented to reroute Chromium's photo
picker, a picker it does not use. The doc comment two lines up already said
Firefox behaves correctly; the code did not agree with it.

Inert at best, and at worst it perturbs a chooser that was working. Narrowed
to Android minus Firefox, which is the Chromium-family set the behaviour was
actually observed on (Chrome and Edge, Android 14/15), with a UA test to pin
it.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: Zszywany <Zszywany@users.noreply.github.com>
2026-09-01 09:24:24 +02:00
Paul Nothaft 7e6bfbecb2 chore(main): release 3.122.1-beta.0 (#1254)
Build and Push Docker Images / smoke-aio (push) Failing after 10s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 11s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.122.1-beta.0
2026-09-01 06:35:22 +00:00
Paul Nothaft 6938bad107 fix(events): apply the gallery password policy to publish and send-later (#1253)
Both routes re-hash password_hash from a plaintext the admin re-types, and
both validated it with nothing but express-validator's isLength({min:6}).
So the configured complexity — moderate by default, meaning 8 chars plus
upper, lower and a digit — governed creation and reset while these two doors
accepted 'aaaaaa' and made it the live gallery password.

Fixed for both at once, deliberately. Fixing only the newer send-later route
would have made a quiet-publish password valid at publish time and rejected
by send-later, leaving the admin unable to mail a gallery that is already
live under exactly that password.

Not an escalation — it needs admin auth plus events.edit, and such an admin
could already set the same weak password through /publish. It is a policy
gap: the UI promised a complexity level these two endpoints did not enforce.

BEHAVIOUR CHANGE: an API-only consumer publishing with a sub-policy password
now gets 400 with the same body shape event creation returns (error, details,
score, feedback) instead of silently weakening the gallery. Two existing test
fixtures had to change for the same reason — their intent was that the
supplied password is carried and persisted, not that a weak one is accepted.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-01 08:30:17 +02:00
Paul Nothaft a35d2bad66 fix(archives): restore categories for original-filename archives on main too (#1252)
main's #1240 landed the manifest lookup in its first form; the hardening that
followed only ever reached stable, via #1243. So main still silently loses
every category when restoring an archive written while
general_use_original_filenames_for_downloads was on: archiveService names
each ZIP entry after the ORIGINAL filename while the manifest stays keyed by
the internal photos.filename, so the lookup misses every entry.

Ported as one unit rather than piecemeal, since a third variant of this
function helps nobody:

- index by original_filename, and by sanitizeForZipEntry(original_filename)
  as the ZIP would actually have written it
- two passes, canonical names claimed before any alias, so the result no
  longer depends on manifest iteration order (the archive query has no
  ORDER BY)
- a name two rows both claim is dropped rather than guessed — including the
  canonical/alias clash, where which file the ZIP emitted depends on a
  naming mode the manifest does not record
- globals count as existing, event-scoped rows win over them, and the global
  arm requires event_id IS NULL so one event's legacy row can't be adopted by
  another event's restore
- an invented category is explicitly is_global false; the column defaults to
  TRUE, so a restore was leaking this event's naming into every gallery
- categories resolve inside the !existingPhoto branch, so a restore that
  skips its inserts stops creating unused rows from stale manifest names
- a duplicate category name is logged and resolved by lowest id instead of
  engine order

main-only code is untouched: the face-data cleanup (#1074, #1132) and the
uploaded_at toISOString fix both survive — stable still has the bare
new Date() there, which is the documented Jest/SQLite landmine and worth a
separate look.

15 tests, ported from #1243.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-01 08:30:02 +02:00
Paul Nothaft 63f3fb4629 chore(main): release 3.122.0-beta.0 (#1251)
Build and Push Docker Images / smoke-aio (push) Failing after 10s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 11s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
v3.122.0-beta.0
2026-09-01 06:27:11 +00:00
Paul Nothaft 1ef2b3c85b feat(events): publish without notifying, and send the gallery email later (#1235) (#1241)
* feat(events): publish without notifying, and send the gallery email later (#1235)

Publishing queued the gallery_created email whenever any customer email
existed, with no opt-out. A photographer working with a client who has no
address yet — the Instagram-team case in discussion #1086 — had to type their
OWN address into the required field, publish, receive the client-facing email
themselves, and hand the link over by DM. Turning off
`event_require_customer_email` is not the answer either: that is global, and
the same photographer usually does collect addresses.

Two halves, because a checkbox alone is only half a workflow:

- `notify_customer` on publish, default TRUE. Absent means notify, so the v1
  API, an older frontend and any script keep behaving exactly as before. When
  false the gallery goes live and nothing is queued — not the gallery_created
  email, not the assigned-customer-account notice, not WhatsApp. Publishing
  still logs activity and still fires the event.published webhook, because
  those describe a state change rather than a message to a customer.

- POST /:id/send-gallery-email for an already-published gallery. Deliberately
  not restricted to galleries published quietly: re-sending is a normal thing
  to want (spam folder, wrong address since corrected) and refusing would push
  people to unpublish and republish, changing gallery state to work around a
  mail problem. Refused for a draft, whose link would not work yet, and for an
  event with no recipient.

The email composition is now one helper shared by both, so an email sent a
week later is identical to one sent at publish.

UI: a checkbox in the publish dialog (checked by default, hidden when nobody
would be notified anyway), and a "Send gallery email" action on published
galleries that have a recipient. The password field follows the checkbox —
unchecking it means nothing is being sent, so there is no plaintext to carry
and no reason to demand it. EN + DE strings.

7 integration tests. Two fail without the change, verified by forcing
notifyCustomer true and re-running; the rest pin the default, the draft and
no-recipient refusals, and that a gallery with no recipient still publishes.

* fix(events): make the publish dialog description follow the checkbox (#1235)

Caught by screenshotting it. With "Send the gallery email now" unchecked, the
paragraph above still read "...and sends the notification email to
tina@example.com" while the control directly beneath it said nothing would be
sent — the dialog contradicted itself at exactly the moment the admin is
deciding whether anything goes out.

It now reads "No email will be sent — you can send it later from this page."
when the box is clear. EN + DE.

* fix(events): close six gaps in publish-quietly found by external review (#1235)

TWO CORRECTIONS TO MY OWN VERIFICATION FIRST. `npx tsc --noEmit` in frontend/
is a NO-OP — the root tsconfig is solution-style with references and no
include, so it checks nothing. Every "tsc clean" I claimed on this branch came
from that. The real check, `tsc -p tsconfig.app.json`, showed two TS2339s I had
introduced: `event.host_email` does not exist on the frontend Event type, which
the admin API normalises away. Both recipient checks now use `customer_email`.

PASSWORD ON SEND-LATER. The action promised to send the link and password but
always called the endpoint without one, so a protected gallery got the
"(set at creation)" sentinel — unusable — and this is most needed right after a
quiet publish, the path that never collects a password. New
SendGalleryEmailDialog asks for it, same shape and reasoning as the publish
dialog (#627). Galleries with no password skip the field.

WHATSAPP-ONLY GALLERIES COULD NOT PUBLISH QUIETLY. willNotify ignored
customer_phone, so a phone-only gallery hid the opt-out AND told the admin
nothing would be sent — while publish queued the WhatsApp anyway. Phone now
counts, with its own description line.

ASSIGNED-ACCOUNT NOTICES COULD NOT BE SENT LATER. The dialog promised it; the
endpoint rejected anything without an inline recipient. It now falls through to
the same customer-account path publish uses.

EDITORS COULD NOT SEE THE ACTION. The send button was nested inside the
events.archive gate, so the default editor role — events.edit, no archive —
never saw a button for an endpoint it is allowed to call. Separate gates now.

DEAD LINKS. The endpoint only checked is_draft, so an archived, inactive or
expired gallery would send a link the gallery middleware rejects. All three are
refused with a reason.

9 backend tests (2 new), 22 across the event suites. eslint clean on every
changed frontend file; crud.js keeps its 2 pre-existing errors.

* fix(events): persist the send-later password, and fix a long-standing isGalleryPublic misuse (#1235)

Round 2 of external review.

THE EMAIL COULD CARRY A PASSWORD THE GALLERY REJECTS. The send-later dialog
invites "or pick a new one", but the route queued that plaintext without
touching password_hash — so the customer got credentials that do not open the
gallery. Worse than the sentinel it replaced, because it looks usable. The
route now hashes and persists first, exactly as publish does.

isGalleryPublic TAKES A VALUE, NOT AN EVENT — and this is pre-existing.
normalizeRequirePassword returns its default for anything that is not a
boolean/number/string, so isGalleryPublic(event) is ALWAYS false and
`requirePassword` was always true. The publish dialog on main has demanded a
password for public galleries for exactly this reason. Both call sites now
pass event.require_password. Fixing the older one alongside mine rather than
leaving a broken copy one line above a fixed one.

ASSIGNED-ACCOUNT GALLERIES HAD NO BUTTON. The route falls through to the
customer-account notice when there is no inline email, and the publish dialog
promises that notice can be sent later — but the button only appeared with a
customer_email, making the promise unkeepable.

WHATSAPP CLAIM SOFTENED. Publish only queues WhatsApp when the config exists
and is enabled, which the dialog cannot see. It now says the customer is
notified there "if WhatsApp is configured" rather than asserting a send.

10 backend tests (1 new, covering the rehash). eslint clean on every changed
frontend file; crud.js keeps its 2 pre-existing errors.

* fix(events): don't reset the password for an account-only notice, hide unusable actions (#1235)

Round 3 of external review. The first is a harm my own round-2 fix introduced.

PASSWORD RESET FOR NOTHING. Round 2 persisted the supplied password before
knowing which mail would go out. For a protected gallery with no inline email
but assigned accounts, the dialog still demands a password, the hash was
rewritten, and then the fallback sent customer_gallery_assigned — which links
to the customer portal and never mentions a password. Net effect: the live
gallery password silently changed and everyone holding the old one was locked
out, in exchange for nothing. It is now persisted only when the mail that
carries it is actually being sent.

BUTTONS THE BACKEND WOULD REFUSE. The send action rendered for expired and
inactive galleries, and counted assigned accounts the endpoint filters out as
inactive — walking the admin through a dialog to reach a generic error toast.
The card now mirrors the endpoint's eligibility rules, and only active accounts
count toward having a recipient.

11 backend tests (1 new, pinning that the hash is untouched on the account
path), 24 across the event suites. tsc and eslint clean on the changed files.

* fix(events): make the send-later action agree with what the endpoint will do

Three findings from an external review round, all the same shape: the UI
predicted the endpoint's behaviour and got it wrong.

GET /admin/events/:id mapped customer_accounts without is_active, so the
"only ACTIVE accounts count" filter in OverviewTab compared undefined and
excluded nothing. A gallery whose only assignments were deactivated showed
the send action, and the endpoint then filtered every recipient and
returned 400. is_active is exposed now, and the count applies the same
predicate the fallback uses — active AND holding an address.

is_active is coerced through toBoolean rather than compared with === false.
On the default SQLite backend it comes back as 0, and 0 === false is false,
so an inactive gallery kept offering a send that parseBooleanInput then
rejected. Same class as #1028.

The password prompt is gated on there being an inline recipient. With no
customer_email the backend takes the account fallback, which sends
customer_gallery_assigned — a portal link that never mentions a password —
and deliberately skips the rehash. Asking for one there blocked the send
behind a six-character value nothing consumes, and the dialog's promise
that it would be rehashed was false.

Frontend suite: 291 passed. tsc and eslint clean.

* fix(events): don't mail a portal link to a customer who cannot sign in

Round-2 finding from the external review.

A passive customer — created directly and never invited — is an active
account with a real address whose password_hash IS NULL. The account
fallback happily mailed it customer_gallery_assigned, which links to
/customer/dashboard, and customerAuth rejects login without a hash: the link
goes to a door that will not open. Worse than failing, the route counted it
and reported success, so the admin believed the customer had been told.

getAssignmentsForEvent now derives can_sign_in (the predicate, never the
hash) and the three call sites share one canReceiveGalleryNotice helper —
publish, send-later, and the payload the UI predicts from all have to agree
or the button appears and then 400s. The UI mirrors it.

Sending passive customers an invitation instead of skipping them is the
better product answer, and a separate feature. Refusing visibly beats a
silent non-delivery in the meantime.

Test asserts the refusal; it fails without the can_sign_in arm.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-01 08:18:00 +02:00
peipeimo bb2f709fdd fix: single-photo gallery downloads 404 on S3 storage backends (#1048)
* fix(gallery): route single-photo downloads through the storage backend

The route resolved a local filesystem path unconditionally and handed it
to res.sendFile. On an S3/R2 deployment managed photos are never on local
disk, so every per-photo download failed — while download-all and
secure-images worked, because they already went through getStorage().
That asymmetry is why it went unnoticed: the gallery looks healthy until a
guest clicks the download button on one photo.

Measured rather than assumed: because sendFile is called WITH a callback,
Express does not send a response when the file is missing and the callback
only logs. The request does not 404, it hangs until the client gives up.
The new tests pin this — all five backend-path cases time out against the
previous implementation.

Two existing pieces do the work, so this mostly deletes code:

- renderPhotoForDownload (#858) already owns resize-then-watermark ordering
  and the storage fetch, and the zip builders in this same file already use
  it. The inline duplicate of that logic goes.
- the pass-through case branches on storage.kind(). Local disk keeps
  res.sendFile: it emits Content-Length, Accept-Ranges, ETag and
  Last-Modified and answers Range with a 206, and sharing one bare
  stream.pipe(res) with S3 would silently drop all of it — a resumed
  download would append a second full body onto the partial file. On S3 the
  parts that matter for a download are reproduced via stat() and getRange().

Ranges are parsed defensively; an unchecked parse yields NaN bounds and a
206 with a nonsense Content-Range, which corrupts a resumed download rather
than failing it. Malformed or unsatisfiable ranges fall back to a 200.

The pre-stream 404s now run before any image header is staged, so the error
goes out as JSON instead of a .jpg attachment containing JSON.

Co-authored-by: peipeimo <peipeimo@users.noreply.github.com>

* fix(gallery): open the stream before staging download headers, honour If-Range

Both from an external review round on this PR.

stat() succeeding does not mean get() will — a concurrent delete or replace,
or a transient backend error, lands between them. The fetch was awaited
AFTER the headers went out, so:

- the range branch had already called writeHead(206), leaving the outer
  catch nothing to do but throw ERR_HTTP_HEADERS_SENT. In practice the
  request hangs: the new regression test sat for the full 120s jest timeout
  against the previous code instead of returning.
- the full branch would have sent its 500 JSON underneath the staged
  image/jpeg attachment headers — a .jpg file full of JSON, which is the
  exact failure this PR set out to stop doing on the 404 paths.

Opening the stream first also lets a vanished object answer 404 and a
transient failure answer 500, instead of both surfacing as a broken body.

If-Range: emitting Last-Modified without honouring the validator built from
it is the dangerous half of the feature. A client resuming after the object
was replaced — the watcher re-importing a swapped file, an admin re-upload —
would get 206 from the NEW bytes and splice two versions into one corrupt
file. A validator that does not match now falls back to a full 200.

4 new tests; 3 of them fail against the previous commit, the fourth is the
matching-validator control that must keep returning 206.

* fix(gallery): HEAD without egress, classify render failures, stage 206 headers

Round-2 findings from the external reviewer.

Express routes HEAD through this GET handler and Node discards the body,
but the pipe still drains the whole object out of S3 first — a metadata
probe from a download manager cost a full transfer in egress and latency.
Everything a HEAD needs is already in stat().

renderPhotoForDownload rejections were all reported as 404. It can equally
fail because getToFile timed out, tmp filled up, or sharp died; calling that
"photo not found" misleads the guest and hides the incident from us. Now
classified the same way the pass-through branch already does.

The 206 path uses status()+set() instead of writeHead(). writeHead commits
the response immediately, so a stream that resolved and then errored before
its first chunk left pipeStreamToResponse able only to destroy the
connection. Staged headers flush on the first body write, so an error at
byte zero now returns a clean retryable status with keep-alive intact.
Credit to the reviewer for the correction — I had assumed deferring the
commit required buffering.

Writing the test for that surfaced one more: pipeStreamToResponse cleared
Content-Type, Content-Length, ETag and Content-Disposition but not the range
headers, so the 500 went out still advertising Content-Range: bytes 0-9/40 —
telling a resuming client the error body IS the partial content.

Not taken: binding response metadata to a fetched object version. That needs
an ETag/versionId on the storage abstraction and conditional GETs in both
adapters; the reviewer agreed it belongs in its own PR rather than blocking
this one.

Backend suites: 485 passed.

* fix(gallery): answer HEAD before the counters and the render

Round-3 finding. The HEAD short-circuit was inside the storage branch, which
sits below both the download_count increment / access_logs insert and
renderPhotoForDownload — so a download manager's metadata probe was recorded
as a real download, and on a watermarked or resized gallery it also pulled
the original from S3 and ran sharp over it to build a body Node then throws
away.

HEAD now leaves the handler right after the access checks, with no side
effects and no bytes read. Content-Length is included only when the photo
ships untransformed and the size is readable from stat(); a watermark or
resize changes the length and the only way to learn the new one is to do the
work this branch exists to avoid. HEAD may omit it.

Not taken, again: binding the read to the statted object version. The
reviewer already agreed in a follow-up that it needs an ETag/versionId on the
storage abstraction plus conditional GETs in both adapters, and belongs in
its own PR. Re-raising it does not change that.

Tests assert the probe moves neither download_count nor access_logs.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: peipeimo <peipeimo@users.noreply.github.com>
2026-09-01 08:17:32 +02:00
peipeimo 202c553a08 fix(events): delete stored objects when cascading an event delete (#1051)
* fix(events): delete stored objects when cascading an event delete

* fix(events): sweep watermarks and the archive zip on cascade delete too

Two more objects in the same class as the originals: both are written
through the storage backend, both were only ever removed with fs.unlink,
so both outlive the event on S3.

- photo.watermark_path — a canonical key, deleted via getStorage() on the
  single-photo path (watermarkService.deleteWatermarkFile) and on archive
  (archiveService.js:227). The cascade neither selected nor removed it.
- event.archive_path — written by storage.putFromFile (archiveService.js:160)
  and typically the largest single object an event owns.

event.hero_logo_path is deliberately NOT included: multer writes logos to
local disk with diskStorage regardless of backend (adminEvents/logo.js:19-28),
so they are never bucket objects and the existing fs.unlink is correct.

Collect into a Set — an unresized gallery can carry one object in both
hero_path and preview_path, and the second delete would log a spurious
failure.

* fix(events): sweep the download caches, and delete objects concurrently

Both from an external review round on this PR.

The download caches are the subtle case: the pre-built "Download All" zip
(events.download_zip_path) and one zip per custom-resolution download job
(download_jobs.zip_path) both live under
events/active/{slug}/.download-cache/. On local disk the recursive fs.rm
already covered them, which is exactly why they were easy to miss — on S3
that prefix is not a directory, nothing covered them, and both are
gallery-sized. downloadZipService exposes a cleanup() documented as "used
on event deletion" that the cascade never called.

The job rows are read before the transaction for the same reason the photo
rows are: download_jobs.event_id is ON DELETE CASCADE, so on Postgres they
vanish with the event and take their keys with them. Guarded with hasTable
so a pre-#173 install doesn't abort the delete.

Deletes now run through a bounded pool instead of one await per key. A
400-photo gallery owns ~1600 objects once derived tiers are counted, and
that many sequential DeleteObject round trips runs to minutes — long enough
for a proxy to time the request out AFTER the commit, leaving the event
deleted and the sweep half-finished. A pool rather than Promise.all over
every key, so the fan-out can't exhaust the S3 client's connection pool.

* fix(events): never delete a derivative another gallery still uses

Round-2 findings from the external reviewer.

Canonical thumbnail/hero/preview keys are not event-scoped: the basename is
the photo's filename (imageProcessor passes no outputBasename for managed
photos, so the key is thumbnails/thumb_w300_<filename>), and filenames are
not unique across events — the responsive-tier code says so in as many
words, which is why THOSE keys carry a p{id}_ prefix. A legacy gallery can
therefore share a canonical derivative with a photo in another event, and
deleting it here blanked a surviving gallery's tile. Derived keys are now
checked against photos outside this event and anything still referenced is
left alone; if the check itself fails, every derivative is kept. An orphan
costs storage, a deleted derivative costs someone else's gallery. Originals
need no check — their keys embed the slug.

Also cancel any in-flight or debounced Download All build before snapshotting
paths. A builder that started before the delete would otherwise upload a
gallery-sized zip after the sweep and write its path onto a row that no
longer exists, orphaning it permanently. downloadZipService.cleanup() is the
service's own entry point for this and does all three things: bumps the
version so an in-flight build discards its result, clears the debounce so
nothing rebuilds for a deleted event, and removes the current object.

* revert(events): drop the Download All build cancellation

Reverted for the same reason as on the stable twin, where it was caught:
downloadZipService.cleanup() reaches getStorage() through _cleanup(), so
where the S3 backend is configured but unreachable every cascade delete pays
the adapter's retry backoff. On stable that took the backend CI job from ~2
minutes to past its 10-minute budget, twice, reproducibly. This branch's
suite happened not to trip it, but the same cost lands in the request path
of a real delete — and the twins have to carry the same code.

The race it addressed is narrow and costs one orphaned zip; documented as a
follow-up instead. The shared-derivative guard from the same review round
stays — that one prevented deleting a surviving gallery's thumbnail.

---------

Co-authored-by: Peifu Mo <peipeimo@Peifus-MacBook-Pro.local>
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-01 08:17:04 +02:00
Paul Nothaft bdeb5a2151 chore(main): release 3.121.4-beta.0 (#1249)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / smoke-aio (push) Failing after 10s
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.121.4-beta.0
2026-09-01 06:10:24 +00:00
peipeimo 4f352dec39 fix(auth): treat zxcvbn suggestions as advice, not blocking errors (#1050)
validatePassword() appended zxcvbn's feedback.suggestions to the errors
array unconditionally, and validity is errors.length === 0 — so any
password that merely earned a suggestion was rejected even when it
satisfied every configured rule. The effective policy was stricter than
the configured complexity level and invisible to the admin.

Suggestions now surface only alongside a real strength failure. They stay
available to callers in result.feedback.suggestions, so a UI can still
show them as guidance while typing.

The weak-password fixture is assembled from parts rather than inlined: an
8-char alphanumeric literal next to validatePassword( reads as a hardcoded
credential to the required GitGuardian check. Both fixtures pin their
zxcvbn score — the compliant one is load-bearing at exactly the moderate
minimum (2), and a future zxcvbn bump promoting it to 3 would leave the
test green while no longer covering the bug.

Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>
2026-09-01 08:05:42 +02:00
Paul Nothaft c7ce79afb1 chore(main): release 3.121.3-beta.0 (#1242)
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / smoke-aio (push) Failing after 10s
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 11s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
v3.121.3-beta.0
2026-08-30 15:07:47 +00:00
Marian 0d340f4e81 fix(archives): take the restored category from the manifest (#1240)
* fix(archives): take the restored category from the manifest

The archive writer already persists `category_name` per photo in
photos_manifest.json — that is why the manifest exists, and the comment
above it says so: "(and category linkage) can't be derived from the
extracted files alone". The restore route then read only
`original_filename` out of it and kept deriving the category from the
ZIP's first path segment.

Archives store photos exactly as they sit on disk, so an event whose
photos live in the gallery root produces a FLAT zip. `path.dirname()` is
'.' for every entry, no category is resolved, and every restored photo
lands with `category_id = null` — silently, behind a 200.

Seen on a real restore: 596 photos back, 0 with a category, while the
nine category rows sat untouched in the table.

Now the manifest is the source of truth and the first path segment is
the fallback, so foldered archives and legacy archives without a
manifest behave exactly as before. The find-or-create is pulled into
`resolveCategoryId` so both paths share it and each name is resolved
once per restore.

Tests: __tests__/integration/adminArchives.restoreCategories.test.js
builds real ZIPs (flat with manifest, flat with an existing category
row, foldered without manifest) and drives POST /:id/restore. Without
this change the two manifest cases fail and the foldered one passes —
the fallback is unchanged.

* fix(archives): let the manifest be authoritative when it says "no category"

Review follow-up on #1240, pushed with the author's agreement.

The manifest won for "category X" but not for "none": an entry with a null
category_name fell through to the directory fallback, so a photo the archive
recorded as uncategorized came back filed under a category anyway.

That matters because the directory is not a category. Archive entry names are
the storage key minus `events/active/{slug}`, and that layout is
`individual/{filename}` / `collages/{filename}` — categories have never been
directories there. Reading the first path segment on a real archive invents
categories literally named "individual" and "collages", so the fallback was
overriding an accurate record with a junk one.

The fallback is now confined to photos with NO manifest entry at all: archives
written before the manifest existed, where the directory is the only signal
left and inventing those names still beats losing every category.

Tests: the legacy case now uses `individual/`, the shape a real archive
actually has, instead of a category-shaped folder no archive produces — so it
documents what the fallback really does. Plus a new case pinning that a
manifest saying uncategorized leaves the photo uncategorized and creates no
category row. It fails without this change; the legacy fallback keeps passing.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-30 17:02:14 +02:00
Paul Nothaft 44a8c416c7 refactor(email): stop reading the webhook response body at all (#1225) (#1239)
The transport carried 41 lines of bounded-read-with-deadline to recover a
messageId a receiver MIGHT return. That value is only ever logged — nothing
persists it, there is no email_queue.message_id column — and the code to get
it produced two of the last four review findings: the size cap made a
DELIVERED message retry (axios throws while reading), and the missing deadline
let an unclosed stream hang the queue and resend.

Not reading the body is how that whole class stops being reachable rather than
defended against. responseType 'stream' still keeps axios from buffering; the
stream is destroyed immediately and the id is synthesised as before. The status
was always the delivery verdict, and it is known before any of this.

An 'error' listener goes on before destroy(): destroy can emit on a
socket-backed stream, and an unhandled 'error' on a stream throws — which
would have turned a receiver's teardown into a failed send.

Net: -45 lines of service code, one fewer constant, one fewer test seam, and
three of the hardest cases in the suite replaced by two simpler ones.

23 tests, 63 across the email suites, eslint clean.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-29 13:06:19 +02:00
Paul Nothaft 89e8e41c40 chore(main): release 3.121.2-beta.0 (#1238)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / smoke-aio (push) Failing after 10s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.121.2-beta.0
2026-08-29 10:27:50 +00:00
Paul Nothaft 6ca8baab23 fix(watcher): stop re-importing a photo whose file was replaced (#1226) (#1237)
The existence check matched on filename OR path. replacePhoto regenerates
both — a fresh generated filename and a fresh managed path — so a
watched-folder photo that had its file replaced stopped matching either arm.
The original is still sitting in the watched folder, so the next sweep
imported it again and the gallery ended up holding the delivered edit AND the
untouched original: the same duplicate shape external_relpath prevents for
reference galleries.

source_filename is now a third arm. It is the stable key here — written once
at ingest by this same path and preserved across a replace by design. Rows
predating migration 193 are covered by its backfill: COALESCE(original_filename,
filename), and this path never wrote original_filename, so for watcher rows
that resolves to the basename being compared.

The query is lifted into an exported findExistingPhoto() so the test drives it
rather than a copy — the thing under test IS the query, so a query-builder mock
would only assert that knex was called the way the test expects.

Predates the Lightroom round-trip and applies to the admin replace path too;
it became reachable when #1165 brought watcher galleries into round-trip scope.

Six tests against a real SQLite database. The load-bearing one fails without
the change, verified by removing the arm and re-running; the other five pin
what must not move — filename and path matching, the pre-193 backfill shape, a
genuinely new file, and event scoping.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-29 12:22:50 +02:00
Paul Nothaft 064b1bcb14 chore(main): release 3.121.1-beta.0 (#1236)
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / smoke-aio (push) Failing after 10s
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 9s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.121.1-beta.0
2026-08-29 10:21:22 +00:00
Paul Nothaft 4105c099c9 test(external): make the fold-collision guard test the real code (#745 follow-up) (#1234)
The regression test added with #1165 re-implemented the claim ordering and the
claim loop inside the test file and asserted against its own copy. It never
required externalRelpathFold, so changing the real sort left it green — a guard
against silently deleting a client's delivered edit that guarded nothing.

The ordering is now a named, exported claimOrderFor() and the test drives it.
Verified by sabotage: replacing the comparator with `return 0` fails the test,
where before it passed.

Three cases added while the seam existed: the managed row wins from BOTH input
orders (the original bug was that the survivor was whichever came first, so one
order proves nothing), the sort is stable for rows of the same kind, and it does
not mutate the caller's array.

No behaviour change — the comparator is byte-identical, only lifted out.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-29 12:15:31 +02:00
Paul Nothaft 0d41fe5bf1 fix(email): keep the webhook payload out of the logs, and bound the response read (#1225) (#1233)
Round 4 of external review, on the merged commit. Both findings are
consequences of the round-3 streaming change, which is exactly why the round
was worth running.

An AxiosError carries the request it failed on: `config.data` is the ENTIRE
serialised message, base64 attachments included, and `config.headers` holds
the signature. emailProcessor logs the error object and winston serialises it,
so a DNS blip or a refused connection wrote password-reset links, guest
recovery codes and multi-megabyte invoices into combined.log — verified
against axios rather than assumed. Every rejection is now caught and replaced
with a message-and-code-only error, so nothing downstream can serialise the
request back out of it.

readBounded had no deadline. axios' `timeout` covers the response HEADERS, and
with responseType 'stream' it has already resolved by the time the body is
read — so a receiver that answered 2xx and never closed its body left the
await hanging, the queue row stayed pending, and the next processor pass sent
the same message again. An unclosed stream was duplicate email. There is now a
10s wall clock that destroys the stream, with the timer unref'd so a hung read
cannot hold the process open at exit.

23 transport tests (2 new, both failing without these fixes), 63 across the
email suites, eslint clean.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-29 12:15:25 +02:00
Paul Nothaft 25a7e64951 chore(main): release 3.121.0-beta.0 (#1232)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / smoke-aio (push) Failing after 10s
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.121.0-beta.0
2026-08-29 09:16:49 +00:00
Paul Nothaft d62407f431 feat(email): webhook transport as an alternative to SMTP (#1225) (#1231)
Setting EMAIL_WEBHOOK_URL makes PicPeak stop sending mail itself and POST each
composed message as JSON instead, for something downstream (n8n, Make, a
self-hosted relay) to deliver. Unset, every SMTP path is unchanged.

Settles the four things #1225 left open:

- SSRF: the URL goes through the same DNS-resolving check the outbound webhook
  worker uses, before every send. Private receivers are opt-in.
- Transport security: https is required for anything leaving the machine. The
  HMAC proves who sent the body, not who can read it, and these bodies carry
  password-reset links and guest recovery codes. The private-network opt-in
  doubles as the plaintext opt-in.
- Authentication: EMAIL_WEBHOOK_SECRET is required and signs the body as
  X-PicPeak-Signature, the same scheme as gallery webhooks. A URL without a
  secret leaves the transport OFF and says so once.
- Attachments: carried as base64, not dropped. Oversized ones fail and stay
  queued rather than arriving without the invoice.

Configuration is environment-only on purpose: this redirects every outbound
message including password resets, so it must not be changeable from a
compromised admin session.

Three wiring details decide whether it works at all: docker-compose.yml
declares an explicit environment block, so the vars had to be forwarded
there; a fresh webhook-only install has no email_configs row (migration 001
seeds it only when SMTP_HOST is set), so the From identity falls back to
EMAIL_FROM; and processEmailQueue used to return early when SMTP could not
initialise, which would have left the queue permanently unprocessed.

guestRecoveryService and the admin test-email endpoint were bypassing the
transport — the first dereferenced a null transporter, the second told
webhook-only admins to go configure SMTP. emailIntakeService deliberately
stays on SMTP: it round-trips a specific mailbox's own credentials.

Response handling is streamed and read bounded by hand rather than capped via
axios: maxContentLength throws while reading, so a receiver that delivered the
mail and then echoed a large body would have been recorded as failed and the
message sent again.

Note: docker-compose.dev.yml is gitignored and local-only, so the equivalent
entries there are not part of this change. docker-compose.production.yml needs
none — it passes .env through with env_file.

Three rounds of external review; 21 transport tests, 61 across the email
suites.
2026-08-29 11:10:32 +02:00
Paul Nothaft f4c054a661 fix(export): name the camera master in photo exports, not the delivered render (#1229) (#1230)
#1165 added photos.source_filename to this service's select, with a comment
saying it was there so the Lightroom round-trip could still match after a
re-upload — and then nothing read it. Every output path still used
original_filename, which is overwritten the first time an edited render is
uploaded over a proof (#745).

So after a round-trip the exports named the render. Each of these formats
exists to help a photographer find the master on disk, and the render's name
does not. The XMP case is the sharpest: the sidecar is written next to a RAW
master, so a wrongly-named one is never associated with it.

Two helpers, because the sites want different things when nothing is known:

  cameraName()             source_filename || original_filename || null
  cameraFilenameOrStored() the above, else the stored name

The dedicated `original_filename` fields (CSV column, JSON key) keep reporting
blank/null when unrecorded — echoing the sanitized stored name there would
invite a match against a file that does not exist under it. The places that
must emit some name (text list, CSV filename cell, XMP sidecar) fall back to
the stored one, as they did before.

filename_format='stored' is untouched, and rows with no source_filename still
resolve to original_filename, so nothing moves for installs that have never
run a replacement.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-29 11:10:05 +02:00
Paul Nothaft 4f684eb482 fix(feedback): name the camera original in the exports, not just the stored file (#1224) (#1228)
Both feedback exports carried only `photos.filename` — the sanitized stored
name (`wedding-smith_individual_1755892345.jpg`). Acting on client picks means
finding the master on disk, and that name matches nothing in a Lightroom
catalog, so the export could not be joined to anything.

Adds the camera-original name to the long and pivot shapes, and by extension
to the archive's feedback_data.csv/.json, which reuse the same query.

COALESCE(source_filename, original_filename), not original_filename alone:
the latter is overwritten the first time an edited render is uploaded over a
proof (#745), so an export taken after a round-trip would name the render
rather than the master and silently stop matching. source_filename is written
once at ingest and survives a replace by design (migration 193). That case is
the load-bearing test.

Aliased to `original_filename` — the name the sibling photo export already
uses for this column, and the question the reader is asking. Left empty when
neither is known rather than echoing the stored name: blank reads as "no match
possible", where repeating the sanitized name invites a match attempt against
a file that does not exist under it.

The column is added, not swapped: `filename` is untouched, so anything reading
the old column keeps working.

Reported by the 8digit/picpeak fork, which has carried a narrower version of
this patch (original_filename only) across rebases.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-29 11:09:59 +02:00
Paul Nothaft a8a8b7cc64 chore(main): release 3.120.0-beta.0 (#1227)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 9s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / smoke-aio (push) Failing after 11s
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.120.0-beta.0
2026-08-28 13:50:35 +00:00
Luca 8db8527f9e feat(api): Lightroom round-trip — read proofing marks, put finished edits back (#745) (#1165)
* feat(api): Lightroom round-trip — read marks, put edits back (#745)

Gets a client's proofing verdict into a desktop catalogue and a finished
edit back over its proof, without anyone re-matching files by hand.

Three parts:

**Keep the camera filename.** photos.original_filename is the only carrier
of `IMG_1234.JPG` — the stored filename is rewritten by
generatePhotoFilename. But replacePhoto() overwrites original_filename with
whatever name the new file arrives under, so the first re-upload of a
renamed render destroys the key the NEXT round-trip needs. Migration 185
adds photos.source_filename, written once at ingest and never touched by a
replace, backfilled from original_filename so existing galleries can still
match on their first pass. The backfill sits outside the column guard and
keys on whereNull, so a run that dies partway self-heals instead of leaving
half the rows empty forever.

**Read the marks.** GET /api/v1/events/:id/photos returns each photo with
its client colour tallies, the caller's own marks, and a merged colour +
rating. Guards copied from the sibling upload route (apiTokenAuth +
read scope + photos.view + requireEventOwnership). Filters: marked_only,
mark_source, color_labels, my_color_labels, min_rating, my_min_rating.

The route filters to a page of ids with PhotoFilterBuilder, then enriches
just those through photoExportService.getPhotosWithFeedback — the two
halves already existed and neither does both, and going id-first keeps the
per-colour tally query bounded by page size.

services/markMerge.js decides how three possible opinions (guest colours,
guest star average, the photographer's own row in photo_admin_marks)
collapse into the one colour and one rating Lightroom has room for. Colour
goes to the photographer on a tie — one deliberate choice beats an
aggregate a tie-break already had to guess at. Rating takes the max,
because a rating is a magnitude and losing the higher one quietly demotes
a photo somebody rated highly. Its roundRating matches
XmpGenerator.mapRating exactly so the API and an XMP sidecar can never
disagree about how many stars a photo has.

**Put the edit back.** POST /api/v1/events/:id/photos accepts an optional
replaces_photo_id and routes to the existing replacePhoto(), preserving
the photo's id, feedback, comments and position. The plugin stores the
picpeak id on the catalogue photo, so the id survives the editor renaming
the render — which makes it the reliable key, not the filename. Scoped to
the event in the URL: a token inherits its owner's powers across every
event they can see, so an unscoped id would let one gallery overwrite
another's photo.

For renders whose RAW never went through the plugin, findReplacementCandidate
gains an opt-in number_token mode matching on the trailing digit run.
Deliberately the LONGEST run and never a fixed last-N slice: multi-camera
shoots disambiguate by prefixing the camera index into the number
(cam11234.jpg / cam21234.jpg), and a last-4 slice reads 1234 from both
bodies and reintroduces exactly the collision the prefix removes.
Ambiguity is refused, never guessed.

Also drops the multer temp file on the two new early returns — this route
only unlinks in its catch block.

* refactor(api): one rating-rounding rule, and apply match_mode where it counts

Three things the pre-review pass turned up on the round-trip work:

- `match_mode` reached the photo-cap pre-count but not the loop that
  actually picks the replacement target, so asking for `number_token`
  would have been counted and then quietly ignored. Both call sites now
  take it.

- `number_token` matching read `select('*')` over every photo in the
  event to compare one digit run. It now reads the three columns the
  match needs and re-reads the single winner in full, so a 5000-photo
  event doesn't pull 5000 full rows through memory to answer one
  question.

- `XmpGenerator.mapRating` and `markMerge.roundRating` were the same
  five thresholds written twice — the second way to do one thing that
  drifts the moment either is touched. The thresholds now live in
  markMerge and the generator delegates, which is what keeps a sidecar
  and the v1 API from ever disagreeing about a photo's star count.

* fix(api): keep the new route in the generated OpenAPI spec

The `color_labels` description carried an inline JSON example. In an
unquoted YAML scalar `{ "green": 2 }` parses as a flow mapping, so
swagger-jsdoc threw YAMLSemanticError and dropped the WHOLE route from
the spec — visible only as a warning on boot, with the route still
serving normally, which is exactly the kind of failure that survives to
release.

Found by booting a real instance rather than by reading the diff.

* fix(api): close the four blockers from review on #1165

1. Replacing an external photo silently kept serving the old file.
   resolvePhotoStorageKey gives photo.source_origin precedence and
   returns null for 'reference'/'external', so the edit was uploaded,
   the row updated and 200 returned while every viewer kept getting the
   untouched NAS original and the upload sat orphaned. replacePhoto now
   repoints the row to managed and clears external_relpath. The file on
   the share is never touched — this moves the pointer, not the data.

2. Every replacement leaked its temp file. putFromFile COPIES on local
   and uploads on S3; neither consumes the source, and replacePhoto
   never unlinked it — while the v1 route had disabled its own cleanup
   on the belief that replacePhoto moved the file. Cleanup now lives in
   replacePhoto, which closes the admin path too (adminPhotos only
   unlinks in its new-files branch, so replaced files leaked there as
   well). The v1 route also unlinks on the FAILURE path, which returned
   before any cleanup ran.

3. The download-all ZIP is invalidated after a replacement, as
   adminPhotos.js already does. Without it guests kept downloading the
   pre-edit photo indefinitely, which defeats the point of the feature.

4. The round-trip could not see reference or watcher galleries at all.
   fileWatcher and adminExternalMedia never set original_filename — the
   camera name lives in `filename` for those rows — so the backfill and
   the GET fallback both produced NULL for exactly the galleries most
   likely to be driven from Lightroom. The backfill now COALESCEs, both
   ingest paths set source_filename, and the GET falls back to filename.

Concerns:

- number_token no longer reads every photo row in the event per file. A
  LIKE on the digit run narrows the candidate set in SQL first; the
  exact trailing-run check still decides, so semantics are unchanged.
  The token is a regex-extracted digit run, so it cannot carry a
  wildcard.
- The replacement's activity entry is scoped to event.id instead of
  null. The dashboard feed excludes NULL-event rows for scoped callers
  (GHSA-jhcf), so it was vanishing from the audit trail of the
  photographer who owns the event.

Nit: dropped the unused higherPriorityColor export from markMerge.

Three regression tests cover the external repoint, the temp cleanup and
the COALESCE backfill. 21/21 pass.

* chore(migrations): renumber 185 -> 193 after gallery-folders landed

185_add_category_is_folder.js merged to main while this was in review,
so the number the PR reserved is taken and main is now at 192. Knex keys
on filename rather than the prefix, so both would have run — but
picpeakImportService guards restores with migrationOrder(), which parses
that prefix, and two files answering 185 make the forward-only check
pass a backup onto a schema missing its columns.

Renumbered with every reference: the header comment, the test that
requires the path, and the four call-site comments that cite it. The
'migration 182' reference inside it is the colour-labels migration and
is unrelated; gallery.js:1134 cites upstream's 185 and is untouched.

* fix(api): keep external_relpath when a replacement converts the row

The external-photo blocker fix cleared external_relpath along with
flipping source_origin, which closed one hole and opened another.

adminExternalMedia dedupes a re-scan on (event_id, external_relpath)
— routes/adminExternalMedia.js:195 — and migration 186 puts a unique
index on exactly that pair. With the column nulled, the next scan of
the share would not recognise the NAS original as already imported and
would insert it again, so the gallery would end up holding both the
edit and a fresh copy of the file it replaced.

Only source_origin needs to change: it is what resolvePhotoStorageKey
keys on, and every other consumer of external_relpath reads the two
together and lets source_origin decide. The stale relpath on a managed
row is inert for resolution and still correct as a dedupe key.

Test updated to assert the value is kept rather than cleared.

* fix(uploads): say when exiftool is missing instead of blaming the RAW

A server without exiftool reported `No usable embedded preview in RAW
file X.CR3: spawn exiftool ENOENT` for every RAW upload. The headline
describes a corrupt photo; the actual cause is a package that was never
installed, demoted to a trailing detail. It sends people hunting through
their camera files.

Hit while testing the Lightroom round-trip (#745): an export of RAW
originals failed 11 times with that message, and the file was fine.

RAW upload is the only feature that needs exiftool, so an install can be
missing it indefinitely and only find out when someone uploads a CR3 —
which makes the wording the whole diagnosis.

ENOENT now produces a message naming the dependency and the install
command for Debian/Alpine/macOS, and breaks out of the tag loop instead
of spawning the same missing binary twice more to report the last
failure as if it described the photo. A genuinely preview-less RAW still
gets the original message.

Verified both paths by making exiftool unreachable via PATH rather than
mocking: missing tool and unreadable file now report differently.

* fix(external): a delivered edit must win a relpath-fold collision

Follow-up to keeping external_relpath on a replaced photo. Keeping it is
what lets adminExternalMedia still dedupe the folder re-scan, but it also
leaves the row inside externalRelpathFold's sweep — and that sweep does
not merely rewrite paths, it DELETES collision losers via
externalPhotoDedupe.

The survivor was whichever row happened to be claimed first, which is
iteration order. So a replaced photo — source_origin 'managed', holding
the edit the photographer just delivered — could be deleted in favour of
the untouched camera original sitting next to it on the share.

Managed rows now claim first and therefore survive. The external row
that loses is the recoverable one: it is still on the share and a
re-scan re-imports it. The edit is not recoverable.

Note this is deliberately NOT the "skip managed rows in the fold"
shape suggested in review. Skipping would leave those rows holding a
base-relative path while every other row moved to root-relative, so the
scanner — which computes root-relative — would stop matching them and
import the camera original again as a duplicate. That is the exact bug
keeping external_relpath exists to prevent, reintroduced through a
different door. Rebasing them and protecting them from deletion keeps
both properties.
2026-08-28 15:45:56 +02:00
Paul Nothaft 1d88fa01ce chore(main): release 3.119.0-beta.0 (#1223)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 9s
Build and Push Docker Images / smoke-aio (push) Failing after 10s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.119.0-beta.0
2026-08-28 06:41:39 +00:00
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 27aff7c04e chore(main): release 3.118.0-beta.0 (#1222)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / smoke-aio (push) Failing after 10s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.118.0-beta.0
2026-08-28 06:25:14 +00:00