Compare commits

..

2115 Commits

Author SHA1 Message Date
Paul Nothaft c7ce79afb1 chore(main): release 3.121.3-beta.0 (#1242)
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
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/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
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-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
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) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
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) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
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) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
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) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-28 06:25:14 +00:00
Paul Nothaft 22e00f80b6 feat(feedback): a third identity mode with one shared colour tag per photo (#1197) (#1208)
* feat(feedback): a third identity mode with one shared colour tag per photo (#1197)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Third review round, two findings.

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-28 08:19:41 +02:00
Paul Nothaft 71eaf25d94 chore(main): release 3.117.0-beta.0 (#1220)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-28 06:15:35 +00:00
Paul Nothaft 696c69a6d0 fix(setup): put the setup token where a NAS user can find it (#1218) (#1219)
* fix(setup): put the setup token where a NAS user can find it (#1218)

The token file was never missing — it was in a subdirectory nobody opens. The
all-in-one image points DATA_DIR at /data/db, so the file lands beside the
database inside the single volume; someone browsing that volume from a NAS
container UI sees db/, storage/, logs/, backup/ and gives up. There is no shell
on those boxes to run the documented `docker exec … cat` with, and the token
value is deliberately kept out of the logs, so the install looked like it had
swallowed its own bootstrap credential.

When DATA_ROOT names a different directory, the token is now written there too
— /data/SETUP_TOKEN, the first thing visible on opening the volume. The compose
stack sets no DATA_ROOT and keeps exactly one file, so nothing changes there.

Each copy is written independently: the canonical one failing while the
volume-root copy succeeds still leaves a readable token, and only a run where
every write failed falls back to logging the value. The startup banner names
every copy rather than just the first, which is what sent people into db/.

Both copies are 0600 and both are removed the moment setup completes. That is
what makes a second copy of a single-use bootstrap secret acceptable rather
than careless — and writing the test for it turned up that the burn path had
TWO independent unlinks, one in clearSetupToken and one at the end of
createInitialAdmin. Only the first had been updated, so the volume-root copy
survived the burn: a live-looking token that no longer works, which is worse
than no token at all.

Docs for the same issue are already out (PicPeak/docs#15); .env.example now
names the AIO paths too.

* fix(setup): enforce 0600 on a token file that already exists (#1218)

External review. fs.writeFileSync's `mode` applies only when the file is
created — writing over an existing inode truncates it and leaves its
permissions untouched. A SETUP_TOKEN someone had copied to the volume root by
hand at 0644 would keep that mode, so the first-admin bootstrap credential sat
group- and world-readable on a shared NAS mount while this code claimed 0600.

Unlink then create, rather than chmod after write: recreating gives a fresh
inode with the right mode and no window where the credential is on disk under
the wrong one. The chmod stays as a fallback for an unlink that failed for a
reason other than the file being absent.

Test fails against the un-fixed code.

* fix(setup): drop a token copy that cannot be made private (#1218)

Round 2 of external review. Asking for 0600 is not the same as getting it: a
CIFS/SMB mount — which is what a NAS commonly offers — carries no Unix modes,
so chmod is a silent no-op and the file keeps whatever file_mode= the mount
forces, typically 0644. This feature targets exactly those hosts, so it now
verifies the resulting mode instead of assuming the request took.

A copy that cannot be made private is removed rather than left lying there, and
it does not count as written — so an install where neither copy can be
protected falls through to the existing log fallback, which reaches the
operator alone. Previously a chmod that threw after a successful write left the
credential on disk, and a success on the other path cleared the error, so
nothing reported the exposed copy at all.

Test simulates the mode-less mount with chmod as a no-op and stat reporting
0644; it fails against the un-fixed code.

* fix(setup): never write the token through a foreign inode, or into the logs (#1218)

Round 3 of external review, two findings, both about the credential ending up
readable by someone else on exactly the shared mounts this feature targets.

**The log fallback defeated the point.** When no copy can be made private, the
old branch logged the token at warn — and logger.js writes warnings to
combined.log under LOG_DIR, which in the all-in-one image sits on the same
mount as the token file. The credential moved from a file we had just refused
to leave, into another file just as readable, that outlives setup. The warning
no longer carries the token; server.js already prints it on stdout when no file
was written, which reaches `docker logs` without touching the shared volume.

**A file that could not be deleted was written through anyway.** The
pre-write unlink swallowed every error, so a 0666 SETUP_TOKEN owned by another
user in a sticky or ACL-controlled directory — still writable — received the
live token into its existing inode. Only ENOENT is ignored now. And when the
mode check finds an exposed copy it cannot remove, that is recorded separately
and reported at error level: a success on the other path clears writeError, and
an exposed credential must not be silenced by an unrelated success.

Two tests, both failing against the un-fixed code.

* fix(setup): fail closed on an exposed token, and refuse a raced symlink (#1218)

Round 4 of external review.

**An exposed copy left the token valid.** A directory that permits creation and
denies deletion — ACL-backed or CIFS — could keep a group/world-readable file
holding a live setup token, and /setup/admin went on accepting it: anyone able
to read the mount could take the first super-admin account. Reporting that was
not enough. The token is now revoked when a readable copy cannot be removed,
which turns what is left on disk into a dead string. Private copies are removed
with it, since they hold the same value. The next boot mints a fresh one and
skips the undeletable file rather than rewriting it, so this converges instead
of looping on the same exposure.

**The write followed a raced symlink.** On a group-writable mount another local
user could drop a symlink at the path between the unlink and the write, and the
default 'w' flag would follow it — putting the live token in a file they own.
Now created with 'wx' (O_CREAT|O_EXCL), which neither overwrites nor follows a
link; having just unlinked, anything present again is that race. The mode check
uses lstat for the same reason: it must describe the file, not a link target.

**A verification that threw left the file behind.** writeFileSync succeeding and
lstat then failing — plausible on the network filesystems this targets — left an
unverified live copy on disk, and a success on the other path cleared the error
so nothing said so. Cleanup is now keyed on 'did this iteration create a file',
so every post-creation failure removes it.

Three tests, one new; the new one fails against the un-fixed code. Full backend
suite at the known baseline.

* fix(setup): report the written token path again, so the banner stays quiet (#1218)

A regression I introduced one commit ago. Rewriting the write loop dropped the
three lines after it that publish the result, so writtenTokenFile stayed null
even on a completely successful write.

server.js prints the token itself only when no file was written. With this
reporting nothing, the banner took that failure branch on every fresh install
and put the live super-admin setup token into stdout and `docker logs` — beside
a perfectly good 0600 file. That is the exact leak this path was built to
close, reopened by a refactor that touched none of the logic around it.

Found by external review, not by the suite: nothing asserted the accessor, only
the files on disk. Now guarded — the new test fails against the regression.

* fix(setup): survive a worker race, and revoke a copy that predates this run (#1218)

Round 6 of external review.

**A pre-existing exposed copy was invisible to the revocation.** A restart
reuses the token from the database, so an old file holding that value is a live
credential. If it had become group-readable and could not be deleted, nothing
tracked it — created was false, so the fail-closed path never fired and
/setup/admin kept accepting what was in that file. An undeletable file at the
token path is now treated as live and triggers the same revocation.

**A losing worker printed the token.** The shipped PM2 cluster config runs
several workers against one DATA_DIR. Both pass the unlink, one wins the
exclusive create, and the loser's wx write threw EEXIST — so it recorded
nothing and its banner printed the live token into its own log while a
perfectly good 0600 file already existed. EEXIST now checks the file: private,
regular, and holding the same token counts as this loop's work already done.

**A write that created the file and then threw left it behind.** ENOSPC, a
short write, a delayed close on a network mount — writeFileSync can populate
the inode before failing, and cleanup keyed on the call returning skipped it.
Keyed on the write being attempted now, with an existence check.

Two tests, both failing against the un-fixed code. Full backend suite at the
known baseline (2342 passing).

* refactor(setup): drop the volume-root token copy, keep the hardening (#1218)

The second copy was for discoverability: DATA_DIR points into /data/db on the
all-in-one image, and a NAS user browsing the volume does not open a folder
called db. Six review rounds later it had earned a second inode to race, to
verify, to clean up and to revoke — a symlink guard, an exclusive create, an
lstat check, cluster-race handling and fail-closed revocation, nearly all of it
load-bearing only because there were two files instead of one.

That is a lot of attack surface for a convenience the documentation covers
better. PicPeak/docs#15 now points NAS users at ADMIN_PASSWORD, which creates
the admin on first boot and needs no file at all, and names the db/
subdirectory for anyone who does want the token. Neither needs a second copy.

So: one file in DATA_DIR again, as before. Everything the review turned up
stays, because none of it was about the second copy — the token is created with
O_CREAT|O_EXCL so a raced symlink cannot capture it, its mode is verified with
lstat rather than assumed, a copy that cannot be made private is removed, one
that cannot be removed revokes the token instead of being logged about, a
partial write is cleaned up, a concurrent worker's good file is accepted rather
than triggering the log fallback, and the token never reaches the log files.

setupTokenFilePaths and writtenSetupTokenFiles are gone with their tests; the
hardening tests remain and still fail against unfixed code.

* fix(setup): publish the token atomically instead of racing over one inode (#1218)

Round 7 of external review found a race in the exclusive-create approach: two
PM2 workers reaching the write together, the loser sees the winner's file after
the inode exists but before its content lands, judges it wrong, and deletes it
— after which the winner's own verification fails too, both report nothing
written, and both print the live token into their logs.

Rather than teach the loser to wait, the shared inode is gone. The token is
written to a per-process temporary file, verified there, and published with
rename(2). That is atomic: the file never appears at the published path with
the wrong mode or half its content, a symlink sitting at that path is replaced
rather than followed, and concurrent workers simply publish the same value one
after another. The unlink-then-create dance, the EEXIST handling and the
cross-worker deletion all disappear with it.

Verifying the mode BEFORE the rename is the stronger order too: a credential
that cannot be made private on a mode-less mount now never reaches the
published path at all, instead of being written and then cleaned up.

If publishing fails and something is still sitting at the token path, it is
treated as a live credential we could not replace, and the token is revoked —
unchanged in intent from the previous round, simpler in mechanism.

* fix(setup): drop a dead assignment and an unused import (#1218)

Both flagged by the code-quality review on #1219. `createdTmp = false`
after rename(2) is never read — rename consumes the temp file, so the
catch has nothing left to clean up either way. `os` was never used in
the test.

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Downloads now cover both halves of the requirement:

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

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

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

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

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

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

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

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

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

Seven issues, all verified against the code before fixing.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-28 08:03:49 +02:00
Paul Nothaft 56cf947735 chore(main): release 3.116.1-beta.0 (#1206)
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
2026-08-27 06:50:11 +00:00
Paul Nothaft cec8eff70c fix(images): fence the capture-date backfill on the file it read (#1201) (#1204)
The capture-date backfill committed its result keyed on the row id alone. It
snapshots every candidate up front, then walks them one at a time reading
originals off S3 or a NAS mount — a pass that can run for many minutes.

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

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

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

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

Regression test: a replacement landing mid-run leaves captured_at NULL and is
not counted as updated. Verified to fail against the unfenced code.
2026-08-27 08:44:19 +02:00
Paul Nothaft fb7af502ec chore(main): release 3.116.0-beta.0 (#1203)
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
2026-08-26 19:20:50 +00:00
Paul Nothaft da802169a8 fix(gallery): show colour labels in the Carousel layout (#1189) (#1196)
Carousel paints its own markup instead of going through PhotoCard, so it
inherited none of the colour-label treatment — not other viewers' marks from
#1178 and not the viewer's own from #1044. A photo the client flagged green
looked identical to one nobody had touched, in a layout a photographer can
select like any other. It has been missing since the feature landed.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 21:13:23 +02:00
Paul Nothaft 95e7301909 chore(main): release 3.115.4-beta.0 (#1200)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-26 19:08:03 +00:00
Paul Nothaft edef4d7365 fix(images): backfill orientation for libraries that predate the fix (#1199)
* fix(images): backfill orientation for libraries that predate the fix (#1198)

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

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

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

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

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

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

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

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

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

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

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

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

Six things from review round 2.

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 21:01:51 +02:00
Paul Nothaft c18f54ede0 fix(images): respect EXIF orientation in thumbnails, heroes and previews (#1194)
* fix(images): respect EXIF orientation in thumbnails, heroes and previews (#1185)

generateThumbnail, generateHeroImage and generatePreviewImage went straight
from sharp(imagePath) to .resize(), so a photo whose Orientation tag is not 1 —
routine for portrait shots on bodies that tag rather than rotate the sensor
data — was resized from the raw frame and came out sideways. The same pipelines
then call .withMetadata(false), stripping the tag from the output, so nothing
downstream could correct it either.

The download path already had this right: resizeToBox calls probe.rotate() for
stills, which is why the same photo looked correct on download and rotated in
the gallery. All three generators now do the same, guarded to stills for the
reason resizeToBox already documents — .rotate() flattens a multi-frame source.

The reporter also spotted the half that compounds it: photos.width/height were
stored from sharp's metadata, which reports pixels as STORED, not as displayed.
For orientation 5-8 those are swapped, so a portrait photo landed in the
database as landscape and masonry/justified sized its tile with the wrong
aspect ratio on top of the image being unrotated. A shared orientedDimensions()
helper now does that conversion at all four capture sites — managed upload,
background processing, external import and the dimension repair — so the stored
numbers describe the rotated result the generators now produce.

Existing rows keep their pre-rotation dimensions until the photo is
reprocessed; the images themselves correct on the next thumbnail/preview
regeneration.

Tests fail on the unfixed generators — verified by reverting the rotate calls
and the swap, which fails 4 of the 7.

* fix(images): orient dimensions on every ingest path, and stop guarding rotate where it protects nothing (#1185)

Review found the first cut covered four of eight dimension-capture sites. The
filesystem watcher, the S3 auto-importer, the v1 upload API and replace-by-name
all still persisted raw metadata.width/height, so an orientation 5-8 photo
arriving that way got a correctly rotated thumbnail and a database row
describing it as landscape — the same aspect-ratio mismatch this PR set out to
remove, just on the paths I had not grepped. (I searched for `metadata.width`
and the v1 route aliases it to `meta`.)

The animated guard was also wrong in two of the three generators.
generateThumbnail and generateHeroImage never pass `animated: true`, so they
already flatten a multi-frame source to its first frame — skipping .rotate()
there protected an animation that was being discarded anyway, while leaving the
output in raw orientation against swapped stored dimensions. Both now rotate
unconditionally. generatePreviewImage keeps the guard, because it genuinely
does open animated sources as animated and .rotate() would flatten them.

That leaves one corner unsolved rather than papered over: a multi-frame source
that also carries an orientation tag keeps its raw orientation in the preview
while the thumbnail and stored dimensions describe the rotated one. GIF has no
EXIF and animated WebP effectively never sets it, so it is a real gap but not a
common one, and closing it means rotating frame by frame rather than quietly
dropping the animation. Documented at the guard.

* fix(images): add a recompute mode so existing libraries get corrected too (#1185)

The orientation fix only helped new photos. A row affected by the bug has BOTH
dimensions stored — just in the raw order — so the repair job's NULL filter
could never reach exactly the rows that needed it. Worse, once their thumbnails
regenerated rotated, those rows went from consistently-wrong (sideways image in
a matching tile) to inconsistent: correct image, wrong-shaped tile.

`recompute` widens the candidate set to every image row. Opt-in, because it
re-reads every original.

It also has to deal with the consequence for faces. Detection runs against the
preview and stores boxes in ORIGINAL pixel space, scaled by
`photo.width / previewMeta.width` (faceProcessor.js:220-224) — so a photo whose
stored dimensions change has face data recorded against a coordinate system
that no longer exists, and the overlays crop the wrong region. Photos whose
dimensions actually change are requeued for scanning; ones that were already
correct are not, or a routine repair would rescan the whole library. Rows with
face_status NULL are left alone so installs that never enabled the feature
don't start scanning because of a dimension repair.

Writing the test for that last rule caught a real bug in it: the candidate
query never selected photos.width/height, so `photo.width` was undefined and
every row compared as changed. Both columns are selected now.

* Revert "fix(images): add a recompute mode so existing libraries get corrected too (#1185)"

This reverts commit cb771d08.

Review round 3 found five problems, all of them in this addition rather than
in the orientation fix itself, and one of them an own-goal: requeueing face
scanning makes processPhotoFaces call ensurePreviewImage, which returns the
CACHED pre-fix preview when it is still a valid image — so the rescan reads
unrotated pixels and scales those boxes by the newly corrected dimensions.
That is worse than leaving the data alone.

The rest need work this PR should not be carrying: the dimension repair reads
originals through resolvePhotoFilePath and plain sharp, so it does nothing on
an S3 install and rejects RAW/DNG; recompute pulls archived rows whose
originals were deleted on archive; orientation 2, 3 and 4 change the pixels
without changing width or height, so a dimension-delta test never notices them;
and the dimension write and the face invalidation are not atomic, so a failure
between them leaves a row that no retry will ever requeue.

Split out so it can be designed and reviewed on its own. The orientation fix —
.rotate() in the three generators and orientedDimensions() at all eight ingest
sites — is unaffected and stays.

* fix(images): the watermarked rendition needs orienting too (#1185)

A fourth generator with the same bug, found while reviewing the backfill that
builds on this. watermarkService composites and re-encodes through its own
sharp pipeline with no .rotate(), and gallery.js serves photos.watermark_path
ahead of the original when branding watermarking is on — so on a watermarked
gallery the sideways image is precisely what a guest sees.

Two details this needed beyond the .rotate() itself:

metadata() is read from a separate, unrotated handle. .rotate() does not change
what metadata() reports — a 400x200 source tagged orientation 6 still reads
400x200 — and every use of those numbers here is positioning: watermark scale,
font size, composite extent. They have to be the DISPLAYED dimensions or the
mark is placed against the wrong axis, so they go through orientedDimensions.

The composite offsets are floored. getPositionCoordinates derives from the
SVG's estimated text extent and returns fractional pixels; sharp rejects a
non-integer offset and applyWatermark catches its own error and returns the
image unwatermarked. Landing on a whole pixel was luck, and changing the
dimensions it is computed from ran out of it — the test surfaced a real
"Expected integer for left but received 92.8".

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 20:55:54 +02:00
Paul Nothaft 2d98f5fa9f chore(main): release 3.115.3-beta.0 (#1192)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-26 07:02:21 +00:00
Paul Nothaft 3991dc3ccb fix(admin): gate the dimension repair as system maintenance (#1182)
* fix(admin): gate the dimension repair as system maintenance (#1181)

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

Also from the review:

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

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

Second review round.

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

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

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

Third review round, two findings.

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

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

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

External review of the stable twin.

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Third review round.

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things had to move with it:

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Third review round.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:46:29 +02:00
Paul Nothaft 05e23ef1a1 fix(admin): move the maintenance sweeps' run state into the database (#1181) (#1184)
Both photo sweeps tracked whether they were running in a module-level variable.
Correct on one replica, wrong behind a load balancer: the status poll answers
from whichever process it reaches, so an idle replica reports isRunning false
while another is mid-run, the UI re-enables the button, and the next POST lands
elsewhere and starts a second pass over the whole library. The .whereNull()
guards mean nothing is corrupted; the cost is duplicated S3/NAS I/O and an
operator who cannot tell whether a job is running.

Migration 189 adds one row per job. The claim is a conditional UPDATE whose
affected-row count is the answer — the shape backgroundProcessor already uses
to hand a photo to exactly one worker — so two replicas cannot both match.

The lease is fenced on a per-claim token: taking over a stale claim does not
stop the old runner, so without fencing a superseded runner finishing late
cleared the new owner's flag and overwrote its result. heartbeat() reports
renewal failure and the loops stop on it. Renewal runs on a timer spanning the
claim through release, including the candidate query, because one hung NAS read
can outlast the stale window inside a single iteration.

maintenance_jobs is excluded from .picpeak archives — an archive taken mid-sweep
would otherwise restore a live lease with no runner to release it. The importer
filters the same set, so older archives are skipped too.

Response shape is unchanged, so the frontend needs no change.

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

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

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

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

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

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

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

* chore: drop a stray node_modules symlink committed by mistake

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two follow-ups from review.

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

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

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

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

Three follow-ups from review.

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:43:13 +02:00
Paul Nothaft 9426ade2d3 chore(main): release 3.115.2-beta.0 (#1191)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-26 06:42:21 +00:00
Paul Nothaft a7b74bcd87 fix(external-media): store external paths from the media root (#1163) (#1168)
* fix(external-media): store external paths from the media root (#1163)

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

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

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

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

External review found four real defects in the fold.

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

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

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

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

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

Second review round, three findings.

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

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

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

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

Third review round, four findings.

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:36:45 +02:00
Paul Nothaft 36192708ab chore(main): release 3.115.1-beta.0 (#1190)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-26 06:34:49 +00:00
Paul Nothaft 06da1b9f7e fix(external-media): one row per external file per event (#1162) (#1167)
* fix(external-media): one row per external file per event (#1162)

Two overlapping import-external runs against the same event inserted every
file twice. The route checked for an existing external_relpath and then
inserted, with an fs.stat and a sharp().metadata() read sitting in between —
a window wide enough for both runs to see "not there". A reporter's event
held 8004 rows for 6012 distinct paths.

Nothing at the storage layer stopped it: migration 041 created only a
NON-unique (event_id, source_origin) index.

- migration 186 removes the duplicates that already exist and adds a partial
  unique index on (event_id, external_relpath). The survivor is the lowest id
  that has a thumbnail, so a half-finished import does not cost a grid tile,
  and hero references are repointed first because the FK is SET NULL.
- the route treats a unique violation as a skip and carries on, so a second
  writer this process cannot see (another replica) converges instead of
  duplicating or 500ing.
- a second import while one is already running now gets a 409 rather than
  walking the whole tree to have every insert bounce.

The duplicates' thumbnail files are left behind as unreferenced bytes — a
migration is the wrong place to reach into storage, which may be S3.

* fix(external-media): keep dependent rows and legacy restores intact (#1162)

External review found two real defects in the dedupe half of this fix.

Dangling rows on SQLite. Every FK into photos declares ON DELETE CASCADE, but
PicPeak never sets `PRAGMA foreign_keys = ON` — the codebase says so where it
deletes an event (adminEvents/helpers.js:245) — so on every SQLite install the
cascade is inert and deleting a duplicate photo left its face embeddings,
guest feedback and admin marks behind, pointing at an id that no longer
exists. Biometric data outliving its photo is exactly the invariant the event
delete goes out of its way to hold.

Dependents are now handled explicitly, and moved rather than discarded where
they can be: the duplicates were separate tiles in the grid, so a guest's
comment or an admin's rating could legitimately be on either, and dropping it
inside a fix for silent data loss would be its own bug. Where the target
already holds an equivalent row — the same guest's like, the same admin's
mark, the same transfer's entry — the loser is dropped, because those tables
mean one row per (photo, actor). photo_faces is the deliberate exception: both
rows were scanned, so moving would duplicate every embedding and split the
person clusters built from them.

Legacy restores. Suspending FK enforcement does not suspend a UNIQUE index on
either engine, so a .picpeak backup taken before migration 186 — carrying
exactly the duplicates it removes — would hit the new index mid-batchInsert
and roll the whole restore back, after every table had already been emptied.
The restore now drops the index for the load and rebuilds it after running
the same dedupe.

Also: a failed CREATE INDEX is no longer swallowed. Recording the migration as
applied without it leaves the install permanently racy, with nothing to
trigger a retry.

The shared work moves to services/externalPhotoDedupe.js, which the migration
and the restore both call.

* fix(external-media): reconcile derived state around the dedupe (#1162)

Second review round, four more real findings.

The index throw did not actually stop anything. run-migrations-safe.js treats
23505 as "schema already exists" and marks the migration applied
(run-migrations-safe.js:138) — and a CREATE UNIQUE INDEX that finds duplicate
rows raises exactly 23505 on Postgres. A replica inserting one between the
dedupe and the index lock is a real rolling-deploy shape, and the outcome was
the thing the throw was added to prevent. The index is now verified against
the catalog afterwards, and failure raises a code-less error the runner cannot
mistake for idempotence.

Two people sharing a device were treated as one. photo_feedback carries both
guest_identifier (per device) and guest_id (per person, migration 078), and
feedbackService scopes by guest_id when present. Keying equivalence on the
identifier alone deleted one of two different people's ratings. It now uses
the same COALESCE rule the service does.

Deleting faces raw left ghost people. event_people counts and centroids are
derived from the photo_faces rows being removed, and #1132's separation
snapshots hold a copy of each side's centroid — which is why faceProcessor
exposes purgePhotoFaces and says it is "called from every photo-deletion
path". The dedupe now goes through it.

Reparenting feedback left the survivor's totals stale. photos carries
denormalized feedback_count / like_count / average_rating / favorite_count and
the later reaction and colour counts, so a survivor that now owns feedback kept
rendering zero. updatePhotoFeedbackStats takes a trx so the dedupe can
recompute on its own connection.

Also: the equivalence-key delimiter was a literal NUL byte, which made git
classify the whole file as binary and hide its diff. Escaped.

* fix(external-media): stop the dedupe discarding half-states (#1162)

Third review round. Five findings, four applied.

- is_hidden joins the feedback equivalence key. feedbackService lets a
  moderator-hidden row coexist with the guest's visible replacement and counts
  only the visible one, so ignoring it deleted the visible row as redundant.
- admin marks merge instead of dropping. rating and color_label are written
  independently, so the same admin can have rated one tile and coloured the
  other; the loser now hands over any field the winner has no value for.
- a survivor that loses the only completed scan is requeued. Otherwise the
  purge takes the sole embeddings and nothing re-queues it — the photo just
  silently stops having a face.
- view_count and download_count are carried over. Those are real interactions
  recorded per row, and dropping them quietly lowered the engagement the admin
  grid shows.

Not applied: repointing a category hero can in principle land on a survivor in
another category. It needs the two duplicate rows to have been re-categorised
apart after the racing import, and the result is a cosmetic hero mismatch that
the admin category routes already guard on write. Not worth the extra branch
in a data migration.

* fix(external-media): invalidate the download zip when duplicates are removed (#1162)

External review of the stable twin. Applies to both branches.

The pre-built "download everything" archive still contained the duplicate rows
the dedupe had just deleted, so guests kept receiving them until something
else happened to invalidate it. Every ordinary photo-deletion path calls
downloadZipService.invalidate for exactly this reason.

The columns are cleared rather than the service being called: that service
carries debounce timers and a regeneration queue, which is not something a
migration should start. getZipInfo already treats a cleared record as a cache
miss and rebuilds on the next request, so this is the durable half of what
invalidate does. The stale object is left in storage for the same reason the
duplicates' thumbnails are — a migration is the wrong place to reach into a
backend that may be S3.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:28:48 +02:00
Paul Nothaft bb5d496495 chore(main): release 3.115.0-beta.0 (#1158)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-23 20:17:29 +00:00
Paul Nothaft c305ad4146 feat(faces): make "not the same person" survive a re-scan (#1132) (#1145)
A separation — an explicit dismissal, or the implicit one a Split records — was stored as a pair of event_people.id. Those ids do not survive re-derivation: recluster() deletes every person, and a full re-scan replaces a photo's faces outright, so face ids die too. The only thing that survives both is the embedding, so the decision is keyed on the two centroids the pair had when the photographer separated them. It binds while both sides still look like the clusters that were separated, and lapses once they have drifted past recognition.

The constraint is now honoured at assignment time as well as in consolidate(), which is what makes it hold across a re-scan rather than being reformed before any later pass could object.

Six review rounds shaped the matching itself: each candidate must resolve to the OPPOSITE side rather than merely matching something (a split leaves two similar halves, and the loose test fragmented the person the split was not even about); assignment judges both sides at the ordinary match threshold, since a single face — or a cluster of one part-way through a recluster — cannot resemble a settled centroid; separations carry their own model_version; and the projections are hoisted out of the innermost loop, which took a 2000-photo scan from ~15s of dot products to 0.23s.

Lifecycle closed three ways: purgePhotoFaces re-anchors each side onto the live cluster it still describes and drops rows that describe nothing left, deleteEventCascade and the permanent archive delete clear the table (which deliberately has no event FK), and a later manual merge drops the separations it reverses. All of it matched on vectors rather than ids, since a row that has outlived a recluster names people who no longer exist.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:10:28 +02:00
Paul Nothaft 2c81888eaf fix(gallery): a guest's own hidden feedback is hidden from them too (#1150) (#1153)
Everything in the system treats a hidden row as absent — getPhotoFeedback drops it even for the guest's own feedback, and updatePhotoFeedbackStats does not count it. The per-viewer is_liked heart and my_color_label badge read the row without looking at is_hidden, so a like the photographer had hidden still showed as liked on a photo whose like_count was zero.

Making those agree exposes why it had not been fixed: the duplicate check behind like/favorite toggling did not skip hidden rows either, so the now-empty heart, when clicked, found the hidden row and toggled it OFF — the click did nothing visible and the moderation was silently undone. Skipping hidden rows there makes the click create a fresh, visible row.

Review found four more surfaces still treating a hidden row as present: the per-guest caps (an at-cap guest with one hidden met their own click with limit_reached), /my-feedback (which drives the Liked/Favorited/Rated chips in guest identity mode), getEventFeedbackSummary (disagreeing with the photo counters in the same response), and unhide (leaving two visible rows for one guest). The rating-clear and single-value delete scopes are visible-only now, so a follow-up mutation no longer destroys the admin's hidden record, and the unhide collapse is skipped when there is no stable identity to scope by — that fallback was 'guest_identifier IS NULL', i.e. other visitors' rows.

Not taken: refusing to hide non-comment feedback, which the issue recommended. #839 and #1044 both ship hiding for reactions and colour labels with tests asserting a hidden one stops counting; only the admin UI's Hide button is comment-only.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:09:07 +02:00
Paul Nothaft 00b20b2d72 fix(gallery): guest filters respect show_feedback_to_guests, and marks survive a mid-write clear (#1147)
Two follow-ups from the review of #1137.

Filters were a second way to read hidden feedback. Every token on /photos is an OR of two halves: what THIS viewer marked, and what ANYONE marked. The response fields built from the second half — like_count, comment_count, color_label_count — are all gated on show_feedback_to_guests. The filter was not, so with the setting off a guest could still send ?filter=liked and get back exactly the photos other people liked, across all five tokens. Reachable by a direct API caller holding a gallery token; the frontend never sends filter to this endpoint.

The half it left standing was also the wrong half. It read guest_identifier from the guest_id QUERY PARAMETER, which never matched anything — the frontend invents that string in localStorage and never sends it when submitting feedback, while submissions store generateGuestIdentifier(req). So gating the aggregate would have emptied these filters rather than narrowing them to 'mine', and accepting a caller-supplied identifier was a way back through the gate. Resolved from the request now, hidden rows excluded to match what the viewer can see.

A mark whose row is cleared mid-write lost its value. #1137 fixed two calls both writing; this is one clearing while another sets. The clear empties the row, the row is deleted for being empty, and the setter's update matches nothing — the caller told 'no mark'. A zero-row update now reports itself and the caller re-reads, bounded at three passes, throwing rather than reporting a success that did not happen.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:07:50 +02:00
Paul Nothaft e4a8be8e7e fix(gallery): no Logout button on galleries that don't require a password (#1149) (#1152)
showLogout was hard-coded true, so a gallery with no password showed a Logout button. Logging out of it is meaningless — no credential to drop, nothing to return to — and it stranded the visitor: GalleryPage's auto-login is a one-shot latch, so clearing the session left the page on its skeleton until a manual reload. That is the 'turns blank' in the report.

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

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

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

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:06:47 +02:00
Paul Nothaft b581267031 fix(scripts): regenerate-thumbnails resolves external sources through ensureThumbnail (#1148) (#1151)
The CLI fallback carried the defect #1129 fixed in the admin route: it computed `storage/events/active/<photo.path>` and fs.access'd it, a location that does not exist for external or reference rows. Every one failed the check and was counted as an error, so on an external-media install the script was inert while reporting one error per photo.

Resolution now goes through ensureThumbnail, which already branches on source_origin and owns the per-photo ext<id>_ output name — sharing it is what stops the script and the route drifting apart again.

Also: videos skipped on every marker they can carry (fileWatcher writes type and mime_type but never media_type), responsive tiers backfilled alongside the canonical rendition, skip-vs-generate asked from isThumbnailValid rather than inferred from an unchanged path, tier failures counted rather than swallowed, and a nonzero exit when the backfill was incomplete.

The script is now importable with the CLI behind a require.main guard; it previously ran on require and called process.exit, so it could not be tested at all — which is why this survived #1129.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:06:11 +02:00
Paul Nothaft 66a9b5eba1 chore(main): release 3.114.0-beta.0 (#1146)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-23 09:20:30 +00:00
Luca e2844d1909 feat(gallery): colour labels for client proofing, and one global default per feedback type (#1044) (#1137)
Colour labels for client proofing, plus the photographer's own stars and colours in the admin grid.

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

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

Merged with admin privileges: bypass-size-gate is a required check that fails on size alone for review-bypass authors and never re-evaluates on review, which is its designed behaviour once a maintainer has approved.
2026-08-23 11:15:01 +02:00
Paul Nothaft 7b77bbf243 chore(main): release 3.113.0-beta.0 (#1144)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-22 19:46:22 +00:00
Paul Nothaft 3583c924da feat(faces): consolidate look-alike clusters after a scan, and suggest the rest (#1107)
consolidate() has existed since #1074 and described this exact symptom in its own
comment, but its only caller was recluster() — i.e. when an admin pressed
Re-group people. After a normal background scan the centroids converged and
nobody looked, so a gallery settled with 14 people that should have been 8.

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

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

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

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

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

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

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

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

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

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

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

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

Both reported, measured in the live DOM, by @BraynArts.
2026-08-22 21:38:17 +02:00
Paul Nothaft 97d92f8428 fix(thumbnails): regenerate external photos instead of dropping their tiers (#1129)
POST /admin/thumbnails/regenerate resolved every source as
storage/events/active/<photo.path> and fs.access'd it. External and reference
rows are not there, so every one failed and was counted as an error — and
because the tier deletion runs first, the endpoint dropped every ?w= tier and
rebuilt nothing, leaving the library worse than before it ran. The UI reported
success either way.

Now routed through ensureThumbnail, which resolves both source kinds, uses the
per-photo ext<id>_ output name, and writes thumbnail_path back itself.

Review rounds also removed both destructive deletes in generateThumbnail: the
pre-delete ran before sharp opened the source, so an unreadable source left the
previous rendition gone and the database pointing at it — across a bulk run,
the whole gallery. Neither delete was needed, since put stages to a temp file
and renames atomically and is the last statement in the try.

Videos are filtered out, and the superseded rendition is removed only when the
storage key actually moved, compared through the same canonicalisation the
backends apply so a legacy backslash path is not mistaken for a different
object.

Reported by @BraynArts, who also identified the fix.
2026-08-22 21:37:49 +02:00
Paul Nothaft f735d26422 fix(gallery): a missing thumbnail tier must not take the backend down (#1128)
The first load of a gallery whose ?w= tiers do not exist yet could exit the Node
process — not 500 one tile, kill the backend. Two defects stacked.

The reader: LocalFsStorage.get() returns a lazy fs.createReadStream, so an ENOENT
arrives after the await returned and outside the route's try/catch. An unhandled
'error' event is a process-level throw. pipeStreamToResponse attaches the handler
the routes were missing — 404 for a vanished source, connection destroyed if
bytes are already on the wire, file headers cleared so the JSON error is not
served as image/jpeg or cached as a broken tile for an hour. Applied to all nine
streaming responses in gallery.js.

The writer: ensureThumbnailAtWidth passed regenerate:true, whose first act is to
DELETE the target — on a path only reached when the tier is absent. A grid fires
one request per tile, so one request unlinked the file another had just published
and handed to a reader. Without the flag the write is an atomic rename.

Generation is now also deduped per tier key: 8 concurrent requests ran 5 Sharp
passes before, 1 after.

Reported with a full diagnosis by @BraynArts.
2026-08-22 21:37:15 +02:00
Paul Nothaft e243a88410 chore(main): release 3.112.0-beta.0 (#1139)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-22 16:41:36 +00:00
Luca 7223118b89 feat(deploy): make the all-in-one image installable without a shell (#1124)
The all-in-one image could not be installed from a GUI at all — the deployment it
exists for. validateEnv treats a missing JWT_SECRET as critical and exits, and the
documented run command supplies it with `openssl rand`, a shell command a Synology
Container Manager or QNAP Container Station form cannot run.

wait-for-db.sh now generates one on first start and persists it next to the database,
extending the existing /run/secrets hydration rather than adding a second mechanism.
Explicit env still wins, then /run/secrets, then the generated file. The write is
load-bearing: JWT_SECRET is exported only when the file actually persisted, because an
unpersisted secret would mint a new one every restart and sign every session out.

Creation writes to a private temp file and hard-links it into place — atomic, fails with
EEXIST when another container won, and the loser adopts the winner's value. Non-regular
paths are rejected before the link, since POSIX ln links INTO a directory rather than
failing, which would make a mistyped -v target unrecoverable.

Also repairs the onboarding paths a new install actually walks: the installer no longer
rotates the secrets of a running install on re-run, deprecates the dead scripts/install.sh
in place, corrects the CONTRIBUTING dev loop, and fixes the vite proxy target that had
been pointing at a stray local port since 0da45e69.

Reviewed over three rounds. Co-authored by @Luca-Timo.
2026-08-22 18:37:12 +02:00
Paul Nothaft 8f23118782 chore(main): release 3.111.1-beta.0 (#1138)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-22 16:29:22 +00:00
Paul Nothaft 24e11df299 fix(faces): dark-mode styling for the People surfaces (#1106) (#1126)
Every component added by #1074 was styled for light mode only. In the admin dark
theme the three toggle labels rendered invisible — including "Detect people in
this gallery", the switch that starts GDPR Art. 9 processing — and the
Manage-people modal rendered as a light panel over a dark page because its shell
was a hardcoded bg-white.

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

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

External review found one defect, fixed: the sheet's avatars ring against
--color-surface, not the page background.
2026-08-22 18:24:13 +02:00
Paul Nothaft 2a43d95ff8 chore(main): release 3.111.0-beta.0 (#1123)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-21 17:41:19 +00:00
Paul Nothaft 38c27d097c feat(faces): show a detected face in its source photo, outlined (#1120)
Phase 2 of #1096. Stacked on the phase-1 branch — it needs the Postgres fix
there, or the face list this reads comes back empty.

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

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

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

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

Three things review caught, all real:

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

Round 2 found three more, all real:

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

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

Verified end to end: picked the 5th of 13 faces as cover, and the row action
opened at 5 / 13 rather than 1 / 13. Frontend suite 178 passing, build clean,
no new type errors.
2026-08-21 19:36:59 +02:00
Paul Nothaft ffc6bc1530 chore(main): release 3.110.0-beta.0 (#1122)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
2026-08-21 17:34:11 +00:00
Paul Nothaft bbce3cd2a2 feat(faces): let the photographer choose which photo represents a person (#1119)
Phase 1 of #1096.

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

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

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

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

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

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

  column reference "event_id" is ambiguous

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

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

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

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

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

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

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

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

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

Two things this surfaced, both worth knowing separately:

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

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

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

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

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

Also closes what the tier cache leaked or served stale:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-21 19:26:21 +02:00
Paul Nothaft 00776234fd fix(security): let cors() own Access-Control-Allow-Origin on protected images (#1118)
Closes #1116.

secureImageMiddleware set its own Access-Control-Allow-Origin, overwriting the
one cors(corsOptions) had already computed. server.js:247 mounts cors() on all
of /api with credentials:true, so by the time the route handler ran the correct
header was already there — and the local assignment replaced it with a worse
answer in BOTH directions:

  unresolved -> '*'. Combined with the credentials:true that cors() sets, that
    is an invalid pair browsers reject outright. Unreachable on Docker until
    #1104 stopped compose injecting FRONTEND_URL; reachable on a fresh install
    from then until the wizard stores general_site_url.

  resolved   -> the frontend origin, even when the request legitimately came
    from the allowlisted ADMIN_URL. A split admin host got a header naming the
    wrong origin and the browser rejected a request cors() had allowed.

Deleting the line fixes both. cors() already validates the request Origin
against the allowlist, sets Vary: Origin, omits the header entirely for a
disallowed or absent Origin, and pairs correctly with credentials. Methods,
Headers and Max-Age stay here: they are route-specific and cors() does not
contradict them.

Observed against a running instance before and after:

  allowlisted Origin    ACAO: <that origin> + Vary: Origin + credentials:true
  disallowed Origin     no ACAO
  no Origin header      no ACAO

Six tests, mounted on a real Express app with server.js's middleware order.
Deliberately NOT a unit test against a response double: the first version of
this fix was a guarded assignment that looked correct in isolation and still
overwrote cors() whenever an origin resolved. A double cannot see middleware
composition, which is exactly how that slipped through.

Mutation-checked both ways — restoring the original `|| '*'` fails 5 of 6, and
restoring the guarded assignment fails 3 of 6 including the admin-origin case.
2026-08-21 19:25:56 +02:00
Paul Nothaft d241919604 fix(ui): stop iOS Safari zooming in on 14px form fields (#1113)
Closes #1105.

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

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

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

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

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

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

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

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

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

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

The viewport meta is deliberately left alone: `maximum-scale=1` would suppress
the zoom by disabling pinch-to-zoom for everyone.
2026-08-21 19:25:47 +02:00
Paul Nothaft 69edee36fc ci: give the backend job headroom over its observed tail (#1108)
The backend job normally finishes in about 3 minutes — the last eight
runs on main were 2.6 to 3.4 — but it is the only one that boots
Postgres and runs the full integration suite, so it is the only one
exposed to runner contention. The observed spread has reached 9.2
minutes against a 10-minute cap, and release PR #1088 was cancelled at
10.3 with every test in the log passing and jest still running.

That failure mode is expensive out of proportion to how often it
happens: a cancelled job is a red X on a branch that is actually green,
so it costs a diagnosis and a re-run each time, and it lands on release
PRs because those are the ones that run when everything else does.

The cap is a runaway guard rather than a performance budget, so 20 buys
real headroom over the worst run seen while still killing a genuinely
hung suite well inside the hour GitHub would otherwise allow.

frontend and ml keep 10: they finish in seconds and have never been
close.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-21 19:25:23 +02:00
Paul Nothaft ba221ba2a0 chore(main): release 3.109.0-beta.0 (#1115)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-21 10:58:27 +00:00
Luca 9431b9f094 feat(setup): configure the public address and SMTP in the wizard, not .env (#1104)
* feat(setup): configure the public address and SMTP in the wizard, not .env

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

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

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

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

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

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

Refs #705

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

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

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

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

Refs #705

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

Review feedback on #1104.

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

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

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

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

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

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

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

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

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

Three parts:

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
2026-08-21 12:54:14 +02:00
Paul Nothaft 7583b4f6b0 chore(main): release 3.108.1-beta.0 (#1103)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-20 12:22:34 +00:00
Paul Nothaft b3a7ab27ea fix(faces): face avatars were cropped against a cropped rendition (#1100)
* fix(faces): face avatars were cropped against a cropped rendition

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-20 14:17:25 +02:00
Paul Nothaft 56034af0f0 chore(main): release 3.108.0-beta.0 (#1102)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-20 12:14:09 +00:00
Paul Nothaft 011f6ae7ec feat(gallery): sized preview tiers so phones stop pulling 1920px (#1095) (#1099)
* feat(gallery): sized preview tiers so phones stop pulling 1920px (#1095)

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-20 14:08:07 +02:00
Paul Nothaft 0b886ed942 fix(faces): defer on unreachable storage, and commit the import path first (#1097)
* fix(faces): defer on unreachable storage, and commit the import path first

The two follow-ups left open by #1091, both consequences of external
photos becoming scannable at all.

**A dropped mount no longer burns the gallery.** ensurePreviewImage
returns null for "this JPEG is corrupt" and "the NFS share is gone"
alike, and faceProcessor marked both 'failed'. Nothing re-queues a
failure automatically and the queue only ever claims 'pending', so a
mount that blinked mid-scan cost the whole event a manual Re-scan —
on external libraries, where network storage drops far more often than
local disk, that is the common case rather than the corner one.

faceProcessor now probes the containing DIRECTORY before failing, and
throws TransientSourceError when it cannot be reached; faceQueue treats
that exactly like SidecarUnavailableError — release to pending, back
off, retry — with the warning rate-limited to one per five minutes,
since an outage hits every photo in the event.

The directory rather than the file is the whole point: a missing file
inside a healthy directory is a broken photo and should still fail, and
it still does. Anything that goes wrong deciding which case it is falls
through to 'failed', because guessing 'transient' on an unknown
condition would retry forever.

**The import commits its path before inserting rows.** enqueueEvent
accepts processing_status NULL (faceProcessor.js:243-246), which these
inserts leave unset, so an admin hitting the toggle or Re-scan during a
long import could queue partial rows while the event still resolved
against the old directory — and burn them to 'failed'.

Moving events.external_path ahead of the loop closes that, and fixes a
pre-existing bug on the same line: an import that died at photo 500 of
1000 used to leave those 500 rows pointing into the new tree while the
event still resolved against the old one, making every one of them
unreadable. Safe to do first because the path is already validated
above, and existing photos are unaffected — photo.source_origin takes
precedence over event.source_mode in both resolvers and is NOT NULL
defaulting to 'managed'.

The #1090 test that asserted a missing source fails needed its setup
corrected rather than its intent: it created no directory at all, which
is now (correctly) a dropped mount. It now creates one, so it tests the
case it always meant to — healthy storage, dead photo.

Verified both fixes discriminate: removing the probe fails the defer
test, and moving the event update back after the loop fails the
ordering test. Face suite 59 passed across 8 suites; the full backend
suite fails the same 10 pre-existing suites as unmodified main, no more.

* fix(faces): stop a dead mount stalling the whole queue

External review, and the first finding is one my own change created.

Deferring by returning the row to 'pending' was a trap: claimNextPhoto
orders by id ascending and the queue defaults to a single worker, so the
same unreachable row becomes the oldest pending one after every backoff
and the worker never reaches a higher id. One dead mount would have
stalled face scanning for the entire install — unrelated events, fresh
uploads, everything. Strictly worse than the permanent 'failed' this set
out to replace.

The row is now left parked in 'processing' with face_started_at intact.
It is not claimable, so the worker moves straight on; the janitor that
already exists returns it to 'pending' past STUCK_TIMEOUT_MS, which is
the retry. No new column and no new timer. The sidecar branch still
releases, because a down sidecar blocks every photo anyway — there is no
other work to get on with.

Second: probing existence was not enough. Unmounting an NFS or SMB share
usually leaves the mountpoint behind as an ordinary empty directory, so
fs.access succeeded on storage that was entirely gone and the photo was
failed anyway — the exact case this was written for. An empty directory
where the photo should live now counts as unreachable. The trade is
deliberate and documented: a directory an admin genuinely emptied is
retried rather than failed, which now costs one attempt per janitor
sweep and nothing else.

Third: a comment in adminExternalMedia claimed source_origin isolates
existing photos from the early external_path update. That is true of
managed rows and false of external ones — resolveExternalPath prefixes
every external row with event.external_path, so importing folder B into
an event referencing folder A rebases the A rows. Pre-existing rather
than introduced here (the update always did this, just later), but the
comment asserted otherwise, so it now says what actually happens and
names the underlying single-base-path limitation.

The deferral test initially passed against the blocking version too —
database state alone cannot tell the fix from the bug. It now inspects
the branch directly, the way the #596 contract tests do, and fails when
releaseToPending is put back or the two branches are merged.

* fix(faces): back off per event, and stop clobbering concurrent scans

Round two of external review.

Parking a row in 'processing' fixed the head-of-line block but not the
cost: every janitor sweep handed the whole dead gallery back, and the
worker walked all of it again — one stat per photo against storage that
may be hard-mounted and slow to time out — before reaching any healthy
event. Every one of those attempts also went through
generatePreviewImage first, which logs an error per photo, so a down
mount produced a recurring flood that the rate-limited warning did
nothing about.

So the backoff is now per EVENT and separate from the janitor:
TransientSourceError carries the event id, the queue records a cooldown,
and claimNextPhoto excludes those events while it lasts. The janitor
keeps doing its own job, which is rescuing rows a crashed worker
abandoned. Cooldown is in memory on purpose — a restart is usually what
follows fixing a mount, so it should retry at once.

Second: committing the event path before the loop means a toggle or
Re-scan firing mid-import can now genuinely queue and finish some of
those rows. The final bulk update was unconditional, so it dragged
'done' rows back to 'pending' for a duplicate sidecar scan and knocked
'processing' rows out from under the worker. It is now whereNull —
only rows nothing has touched are ours to queue.

Also corrected a comment of mine that had gone stale in the same file:
it still described the enqueue as happening after the event path was
written "below", which stopped being true when that update moved above
the loop.

* fix(faces): judge the mount, the path and the file separately

Round three of external review. The probe was too coarse in both
directions.

It read any ENOENT on the photo's own directory as a mount-wide outage,
so a deleted or renamed subfolder — individual/ gone while collages/ is
healthy — deferred the entire event and starved every sibling folder,
renewing the cooldown on each retry. It now judges the EVENT ROOT for
that verdict: root missing, or present-but-empty, is an outage; anything
below a populated root is a broken path and fails.

And it read a listable directory as proof the photo was at fault, so
EACCES on a reconnected share, EIO, or the classic NFS ESTALE handle
were burnt as permanent failures. Only ENOENT now means genuinely gone;
any other error opening the file defers.

The event-wide backoff was also too broad. A reference event can hold
managed uploads alongside imported external ones, and those live in
local storage that is fine — excluding the whole event id left them
unscanned for as long as external rows kept renewing the cooldown, which
during a real outage is indefinitely. The exclusion is now scoped to
external and reference rows.

One of my own tests had modelled the unmount wrongly: it emptied the
photo's subdirectory rather than the event root, which under the
corrected logic is a populated mount with a missing folder — a failure,
not an outage. It now empties the root, which is what an unmount
actually leaves behind.

Dropped the path require the first version of this probe needed; the
event-root form does not.

All three fixes mutation-checked: reverting each one fails the test
written for it. Face suite 69 passed across 9 suites; full backend suite
fails the same 10 pre-existing suites as main, no more.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-20 14:06:21 +02:00
Paul Nothaft b7f04f6992 chore(main): release 3.107.5-beta.0 (#1101)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-20 11:18:12 +00:00
Luca 2a84efef71 Merge pull request #1098 from Luca-Timo/docs/aio-readme-existing-tag
docs(readme): point the single-container install at a tag that exists
2026-08-20 14:13:12 +03:00
Luca e47c103c2a docs(readme): point the single-container install at a tag that exists
The all-in-one quickstart tells people to pull
`ghcr.io/picpeak/picpeak/aio:stable`, which has never been published, so
the documented one-liner fails for anyone who copies it:

    docker: Error response from daemon: failed to resolve reference
    "ghcr.io/picpeak/picpeak/aio:stable": not found

`merge-aio` does gate `:stable`/`:latest` on `refs/heads/stable` or a
non-prerelease `v*` tag, same as backend/frontend — but `Dockerfile.aio`
only landed on `main` in 0874a30a (#1068, 2026-08-18), and the current
`stable` head (3.46.1) does not contain it. So the aio image has only
ever built off `main`/beta refs, and its full tag list on GHCR and Docker
Hub is `main`, `beta`, and `3.10x.y-beta.0`. backend and frontend both
have `stable` and `latest`; aio is the only image that does not.

Point the quickstart at `:main`, which exists today, and note when
`:stable` will start working so this can flip back after the next stable
promotion.
2026-08-20 11:51:36 +02:00
Paul Nothaft 731df9dc6e chore(main): release 3.107.4-beta.0 (#1094)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-20 06:39:49 +00:00
Luca 375bc5303d Merge pull request #1093 from Luca-Timo/ci/aio-dockerhub-mirror
ci(docker): publish the all-in-one image to Docker Hub, and give aio + ml a Hub page
2026-08-20 09:31:56 +03:00
Luca 899c9b3407 docs(docker): Hub pages for aio + ml, and the image table in the README
picpeak/ml has an empty Hub overview and picpeak/aio has none at all,
while backend and frontend carry hand-written ones — so the two newest
images are the two with nothing on their registry page.

Adds .github/dockerhub/{aio,ml}.md as the source of those pages and a
dockerhub-descriptions job that pushes them on every main merge, so the
page cannot drift from the release it describes. backend/frontend stay
hand-maintained for now: capturing their current Hub text into files is
a prerequisite, not a side effect of this change.

README gains a registry table for all four images (both registries share
digests and tags), the org-move callout lists the full set, and the
feature list finally mentions People in this gallery, which shipped in
#1074 without a README line.
2026-08-20 08:24:19 +02:00
Luca 2df455784c ci(docker): mirror the all-in-one image to Docker Hub
The aio image (#1042) shipped GHCR-only with a TODO to wire the Docker
Hub mirror once the Hub repo existed. backend, frontend and the ml
sidecar all publish to docker.io/picpeak/*; aio was the only image a
Docker Hub user could not pull.

merge-aio now follows merge-backend/merge-ml verbatim: DOCKERHUB_ENABLED
computed from the repository slug (so forks stay GHCR-only), a gated
Docker Hub login, docker.io/picpeak/aio added to the metadata images
list, and a Docker Hub manifest inspect. Tag scheme is untouched — the
same beta/main/stable/latest/semver tags land in both registries.

The build summary drops the "Docker Hub mirror pending" note and lists
the aio (and ml) Hub images when the mirror is active.
2026-08-20 07:49:53 +02:00
Paul Nothaft 54b68fe6e8 chore(main): release 3.107.3-beta.0 (#1092)
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
2026-08-20 05:23:01 +00:00
Paul Nothaft 576924fa57 fix(faces): scan external/reference photos instead of skipping them (#1090) (#1091)
* fix(faces): scan external/reference photos instead of skipping them (#1090)

faceProcessor short-circuited every photo with source_origin 'external'
or 'reference' straight to 'skipped', before the sidecar was ever
contacted. On an external-media install that is the entire library — the
reporter's gallery sat at 0/3230 with every row skipped and no error, and
a rescan changed nothing.

The guard was correct when written: resolvePhotoStorageKey returns null
for anything outside managed storage, so ensurePreviewImage could not
build a preview and there was nothing to send. #1078 removed that
limitation one release earlier — ensurePreviewImage now reads externals
straight off the mount via resolvePhotoFilePath and writes the preview
into managed storage, so the key faceProcessor already fetches through
getStorage() is readable like any other. The guard outlived its reason.

Photos whose source is genuinely gone still return a null preview key
and land in the existing 'failed' branch, which is the honest outcome:
that is a broken photo, not an unsupported one. The blanket skip was
absorbing those too.

No migration or manual reset needed — enqueueEvent already re-queues
rows with face_status in (NULL, 'failed', 'skipped'), so previously
skipped photos get picked up on the next scan.

* fix(faces): queue external imports for scanning (#1090)

The other half of the same bug, found by external review — and my first
counter-argument against it was wrong.

Managed uploads are enqueued by photoProcessor, which writes face_status
'pending' once a photo is processed (photoProcessor.js:573, commented as
"the only correct place to enqueue"). External media never goes through
photoProcessor at all: adminExternalMedia inserts rows directly, leaving
face_status NULL.

faceQueue.claimNextPhoto only claims 'pending' (faceQueue.js:64), so an
import into an already-enabled event produced nothing until someone
pressed Re-scan. Lifting the skip guard alone made external photos
scannable but still not scanned — which looks like a complete fix right
up until you import a photo.

Resolved once per import rather than per file, since it is a per-event
setting and the loop can run to a thousand files, and guarded on both
the global flag and the per-event toggle exactly as photoProcessor
guards it, so installs without the feature still never write a
face_status. A failure to read the setting logs and imports anyway — the
photos are the point.

No video guard: walkDir only collects jpg/jpeg/png/webp, so nothing
faceProcessor would skip as video can arrive through this route.

* fix(faces): enqueue imports only after the event path is written

External review caught a race I introduced in the previous commit.

Marking rows 'pending' as they were inserted published claimable work
while events.external_path still held the old value — or none at all, on
a first import, since the route only writes it after the entire
thumbnail loop. The face worker polls continuously, so on any import
long enough to matter (the loop is ~100-300ms per photo, and the
reporter's library is 6500+) it would claim those rows, resolve them
against the wrong directory and mark them permanently 'failed' — a state
only an explicit Re-scan clears. That is strictly worse than the
unscanned photos this set out to fix.

Ids are now collected during the loop and marked pending in one pass
after the event path is written, chunked at 500 because SQLite caps a
statement at 999 bound parameters.

The test now drives the real route instead of re-implementing its logic,
and observes the mid-loop state from inside the per-photo thumbnail
call — the only hook that can see the window the race lived in. Verified
it discriminates: deleting the enqueue fails two tests, and moving it
back onto the insert fails the ordering test specifically.

* fix(faces): read the face setting after the import, not before

Third external-review round. The setting was captured before a loop that
runs for many minutes on a large library, so an admin who enabled
detection during an import left every photo imported after that moment
at NULL forever — the toggle endpoint only queues rows that already
existed when it fired.

Ids are now collected unconditionally and the setting is evaluated
immediately before the queue update, off a freshly read event row. The
guard is unchanged in substance: both the global flag and the per-event
toggle, so installs without the feature still never write a face_status.

Test flips the toggle from inside the mocked per-photo thumbnail call,
which is the same mid-loop hook the ordering test uses.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-20 07:18:45 +02:00
Paul Nothaft 198df02d2a test(ml): add an embedding-space fingerprint tool (#1084) (#1089)
* test(ml): add an embedding-space fingerprint tool (#1084)

requirements.txt is pinned exactly so rebuilds produce byte-identical
embeddings, but nothing verified that. The API tests stub FacePipeline, so
a decode/resize/kernel change could move every stored cluster without
failing anything.

This prints a hash per stage — decode, resize, cvtColor, YuNet detect,
FaceNet forward pass — so the old and new image can be diffed on the same
host before any base-image or dependency bump lands.

Used it to answer the open question in #1084: Debian/Python 3.12 and
Wolfi/Python 3.14 produce identical hashes at every stage, so a base swap
would not invalidate stored clusters. The input is generated rather than
a fixture, and the hashes are deliberately not compared across
architectures — OpenCV and onnxruntime dispatch different SIMD kernels on
x86 and aarch64, so this answers "did this change move the numbers", not
"is every platform identical".

* test(ml): fingerprint the production path, not a parallel one

External review found the first cut was largely theatre.

The documented command could not run: tools/ is in .dockerignore and the
Dockerfile copies only app/, so the script is never inside the image. It
has to be mounted — which is what I actually did when producing the
numbers, while documenting something else.

Three stages were fingerprinting the wrong thing:

- The detector recorded "none" plus a return status, because synthetic
  input has no face to find. It would have stayed green through any
  change to YuNet or its kernels. Now the ONNX graph is driven directly,
  so all twelve output heads always produce numbers, and the reported
  thresholds are the service's (0.6/0.3) rather than FaceDetectorYN's
  0.9 default.
- The embedding used a hand-rolled tensor, bypassing everything that
  actually places a face in the embedding space: umeyama + warpAffine,
  BGR->RGB, per-image standardization, layout, and the L2 normalization
  the backend's cosine similarity depends on. It now calls _align and
  _embed directly. Private, deliberately — reimplementing the maths here
  would drift from pipeline.py and fingerprint a path nothing runs.
- Decode exercised PNG, but the worker only ever receives the preview
  rendition, which imageProcessor.js writes as JPEG. Now a fixed JPEG,
  embedded as bytes so the input cannot depend on the encoder version
  being held still. Verified SOI/EOI-clean; the first attempt at this
  produced "Corrupt JPEG data: 22 extraneous bytes".

Sensitivity checked rather than assumed: a one-pixel landmark nudge
moves align_warp and embed and leaves decode and the detector heads
alone, which is exactly the dependency structure expected.

Debian/Python 3.12 vs Wolfi/Python 3.14 remain identical across all
sixteen stages, so the #1084 parity conclusion still holds under the
stronger check.

* test(ml): measure the image's own pipeline, and the detector OpenCV runs

Two more from external review, both of which let matching hashes mean
less than they claimed.

The documented bind mount put the checkout's app/ ahead of the image's
/app/app, so comparing two images built from different revisions would
have executed the same pipeline source twice and reported a match no
matter how the images differed. /app now wins whenever it exists, so the
tool measures the image under test however it is invoked, and the loaded
path is printed as _app_source so that is auditable rather than assumed.

The detector was fingerprinted through onnxruntime, but production runs
cv2.FaceDetectorYN — OpenCV's own preprocessing, DNN engine and
NMS/landmark decode, none of which ORT touches. An OpenCV upgrade could
therefore move real landmarks, and with them alignment and embeddings,
while every detector hash held still. It now runs the OpenCV path too,
with the score threshold at the floor so synthetic input still yields
candidates (594 here) instead of the empty result the production 0.6
gives on an image with no face. The ORT pass is kept alongside it to
separate a model change from an OpenCV change.

Parity across debian/3.12 and wolfi/3.14 still holds across all 19
stages, and a one-pixel landmark nudge still moves align_warp and embed
and nothing else.

* test(ml): cover the orchestration and progressive decode too

Round three of external review found two more ways the hashes could
match while production moved.

The isolated stages never fed the detector's output into alignment —
_align got fixed landmarks — so INPUT_LONG_EDGE resizing and the row ->
landmark scaling in _one_face were invisible. process() now runs end to
end on the fixture, with the pipeline's own detector threshold dropped
so a faceless frame still yields rows to carry through (26 faces here).

A first attempt at that still missed the resize: the embedded fixture is
48px, so `long_edge > INPUT_LONG_EDGE` never fired and changing 1920 to
960 moved nothing. It now runs a second pass with the threshold lowered
under the fixture, which executes the same downscale and inverse
landmark scaling without carrying a 1920px image in the source. Verified
sensitive: moving that bound 32 -> 24 changes both the face count and
the embedding.

The fixture was also a baseline JPEG, while generatePreview writes
progressive (imageProcessor.js:236/480/617) — a different path through
libjpeg. Swapped for a progressive fixture, SOF2 confirmed present and
SOF0 absent.

24 stages now. Debian/3.12 and Wolfi/3.14 remain identical across all of
them.

* test(ml): close three more false-negative paths in the fingerprint

Round four of external review. All three let hashes match while
production moved.

INPUT_LONG_EDGE was used but never printed. The fixture is too small to
trip the resize in either image, and the forced pass overrides the value
in both, so a production change from 1920 to 960 moved no hash at all.
It is now emitted alongside the other thresholds, where a reviewer sees
it in the diff.

The fixture was square, so a width/height swap in setInputSize or the
resize produced identical dimensions and identical hashes. It is now
64x48.

The forced-downscale pass hashed only an embedding, which is derived
from separately scaled landmarks — a regression in the inverse scaling
of row[0:4] would have shown up nowhere, because the normal pass runs at
scale 1. That bbox is now hashed too; a wrong one is what breaks avatar
crops and area calculations.

Changing the fixture to 64x48 also broke the forced pass: at the old
bound of 32 the downscaled frame is 32x24 and YuNet returns nothing, so
the stage pinned nothing. The NO-DETECTIONS-STAGE-VACUOUS marker added
last round caught it immediately rather than printing a reassuring hash
of an empty result. Bound moved to 48, which still triggers the resize
and still yields rows.

25 stages, no vacuous markers. Debian/3.12 and Wolfi/3.14 identical
across all of them.

* test(ml): hash every detection, not just the first

Round five of external review. Both end-to-end passes hashed only
candidate 0, so a change that moved candidates 1..n — or merely
reordered them — matched as long as the count and the first candidate
held. With the threshold at the floor those passes return 24 and 27
candidates, so that was most of the evidence being thrown away.

Both now stack every returned face, in order, via a shared _hash_all.
Stacking preserves order, so a reshuffle is caught too.

Verified against the exact case: reversing candidates 1..n while leaving
the count and candidate 0 untouched now moves process_embedding and
process_bbox. Before this it moved nothing.

* test(ml): hash every persisted field, and emit the model version

Round six of external review, plus the adjacent gaps it implied.

Two findings: MODEL_VERSION was never emitted, and _hash_all discarded
score. Both matter to the backend rather than to the numbers — a
model_version change makes faceClustering.js:190 refuse to compare new
faces against existing people, forcing a rescan, and det_score decides
via meetsQualityFloor (faceClustering.js:96-100) whether a face joins
clustering at all. Either could change while every hash held still.

Rather than fix only the two named, I checked what faceProcessor.js
actually stores per face (:157-167) and covered all of it: bbox, score,
yaw, pitch, blur, embedding. yaw/pitch/blur were heading for the same
finding next round. One hash per field, so a diff says which thing moved
rather than only that something did.

model_version is emitted as a compatibility key alongside the
thresholds, not hashed — it is a string, and its job is to be read.

Verified: scaling score alone by 0.999 now moves process_score and
nothing else. 33 stages, no vacuous markers, debian/3.12 and wolfi/3.14
still identical.

* test(ml): split verdict from diagnostic, and stop masking the threshold

Round seven of external review.

The ORT detector hashes were being read as part of the compatibility
verdict, but production never runs YuNet through onnxruntime. An ORT
change touching a YuNet operator would have moved them while real
behaviour was untouched, and the docstring said any difference means
re-scan — so the tool could have ordered a full-gallery rescan for
nothing. They are now diag_-prefixed, and the docstring states which
keys carry a verdict, which are diagnostic, and which are metadata a
reviewer has to read rather than diff.

setScoreThreshold(1e-6) also overwrote the detector's real threshold
before anything recorded it, and _thresholds.det_score only echoes
config. If FacePipeline ever stopped applying DET_SCORE_THRESHOLD —
falling back to OpenCV's 0.9 default — production would detect a
different face set while every hash matched. The constructed value is
now read first and emitted as _effective_det_score; simulating the
regression makes it read 0.9 instead of 0.6.

MAX_FACES is emitted for the same reason INPUT_LONG_EDGE is: the fixture
never reaches the pipeline.py:138 slice, so 64 -> 128 would move no hash
while real group photos persisted a different face set.

21 verdict keys, 12 diagnostic, no vacuous markers, debian/3.12 and
wolfi/3.14 still identical across both sets.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-19 22:43:58 +02:00
Paul Nothaft 3c5bedc2cf chore(main): release 3.107.2-beta.0 (#1088)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-19 15:10:22 +00:00
Paul Nothaft 37a15e3d49 fix(faces): restore the :beta image tag and surface sidecar health (#1087)
* fix(faces): restore the :beta image tag and surface sidecar health

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-19 16:49:47 +02:00
Paul Nothaft 74a8f9bf24 chore(security): shrink the ML image's CVE surface, override deepmerge-ts (#1083)
Three Trivy cleanups off the code-scanning tab.

ml/Dockerfile — install no runtime apt packages at all. Neither libgl1
nor libglib2.0-0 is needed: opencv-python-headless 4.14 bundles what it
needs and `ldd .../cv2/cv2*.so` resolves fully on a bare slim base. The
old comment claimed the headless wheel still links libGL, which was
true of much older wheels. libgl1 was dragging in 36 transitive
packages (mesa, LLVM, X11) for a service that never opens a display.

Measured with `trivy image` on locally built variants:

  before:            165 findings — 88 low / 49 med / 19 high / 6 crit
  without libgl1:    133 findings — 58 low / 48 med / 19 high / 5 crit
  without either:    123 findings — 57 low / 46 med / 13 high / 4 crit

42 findings gone, image 1.05GB -> 774MB. Not one of the 165 had an
upstream fix available, so not installing the packages is the only
lever there is.

docker-build.yml — set ignore-unfixed on all four Trivy steps. All 123
remaining ML findings are unfixed base-OS CVEs; Debian has them
resolved in sid and pending backport to trixie, and apt-get upgrade -y
behind CACHEBUST picks each one up automatically. Reporting them buries
anything actionable, and suppressing them is the precondition for ever
setting exit-code: 1.

backend — deepmerge-ts <8.0.0 has a stack-exhaustion advisory
(CVE-2026-40345, high) reached via mailparser -> html-to-text, which
pins ^7.1.5 so npm cannot get there alone. Not reachable in our code:
html-to-text only feeds deepmerge-ts its options object
(html-to-text.mjs:1468, :1442), never parsed email content. npm audit
goes 3 high -> 0.

The lockfile also picks up the version field release-please had left at
3.103.1-beta.0, plus some "peer": true metadata npm 11.6 recomputes.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-19 13:56:01 +02:00
Paul Nothaft 9f825be01e chore(main): release 3.107.1-beta.0 (#1081)
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
2026-08-19 08:23:14 +00:00
Paul Nothaft af7970b069 fix(preview): generate lightbox previews for external/reference photos (#1078) (#1079)
* fix(preview): generate lightbox previews for external/reference photos (#1078)

ensurePreviewImage() resolved its source only via resolvePhotoStorageKey(),
which returns null for external/reference photos by design — those live on a
media mount outside the managed storage tree. The null went straight into
withLocalCopy(), which throws ("LocalFsStorage: invalid relative path: null"),
so the preview route fell back to redirecting at the full-size original. A
gallery whose photos are all external got no benefit from the preview tier
(#492) at all: guests paid 5-12 MB on every lightbox open, with nothing
surfaced in the admin UI.

Add the external branch ensureThumbnail() has had since #423: resolve via
resolvePhotoFilePath() and feed the mount path to generatePreviewImage()
directly, with an ext<id>_ output basename so two events referencing the same
NAS filename can't clobber each other's preview.

Also close the adjacent hole that made the failure a throw rather than the
documented null: a row with no source_origin in a reference-mode event takes
its mode from the event, so resolvePhotoStorageKey returns null for it too.
Return null instead of handing that to withLocalCopy.

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

* fix(preview): select the columns the external branch needs on bulk regenerate

POST /api/admin/thumbnails/regenerate-previews selected only id, event_id,
path, media_type, mime_type and preview_path, so photo.source_origin was
undefined by the time ensurePreviewImage branched on it. Every external row in
a reference gallery took the managed path, resolvePhotoStorageKey returned null
for it, and the endpoint reported success while generating nothing.

Add source_origin, external_relpath and filename to the select, plus a
source-inspection test pinning the caller contract and a service-level test
showing a column-starved row is indistinguishable from a managed one.

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

* style(test): single-quote the source-inspection needles

Matches the repo eslint quotes rule (no avoidEscape) by dropping the nested
quotes from the search strings rather than escaping them.

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-19 10:17:44 +02:00
Paul Nothaft 98fe9c7699 chore(main): release 3.107.0-beta.0 (#1077)
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-18 20:41:12 +00:00
Paul Nothaft b69dd134d0 feat(faces): People in this gallery — face recognition via an optional ML sidecar (#1074) (#1075)
* feat(ml): optional face-detection sidecar, opt-in and inert by default (#1074)

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

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

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

Two things worth review attention:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Sweep against ground truth (5 identities):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

TWO BUGS THE BROWSER CAUGHT, both invisible to tsc:

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

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

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

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

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

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

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

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

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

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

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

37 face tests still green after the change.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

47 face tests green.

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-18 22:37:28 +02:00
Paul Nothaft 6ebdd13dde chore(main): release 3.106.0-beta.0 (#1076)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-18 20:21:44 +00:00
Luca 0874a30ac9 feat(docker): all-in-one image (#1042) — my version of #1067 (#1068)
* feat(docker): add all-in-one image — backend + frontend in one container (#1042)

One container, one Node process, SQLite by default: `docker run` with no
compose file, no nginx, no supervisor, no bundled Postgres/Redis.

- Dockerfile.aio (repo-root context): frontend build stage + backend deps
  stage + a runtime stage mirroring backend/Dockerfile's production stage,
  with the built SPA copied to /app/frontend/dist and SERVE_FRONTEND=true.
  DATABASE_CLIENT=sqlite3 and STORAGE_PATH=/app/storage are pinned
  explicitly — the storage fallback resolves to container-root /storage,
  which EACCESes after the su-exec drop.
- server.js: the SERVE_FRONTEND block now does what the nginx image did —
  renders ${BRAND_TITLE}/${BRAND_DESCRIPTION} into index.html once at boot,
  serves that rendered shell on /index.html and every SPA route, caches
  hashed /assets/* immutably while the shell revalidates, and gzips the
  bundle via compression() mounted after all /api routers. express.static
  now runs with index:false so `/` keeps flowing to handlePublicSiteRequest
  — its default index option was shadowing the landing page on native
  installs.
- wait-for-db.sh: skip the Postgres readiness wait when DATABASE_CLIENT is
  sqlite3. The engine resolver still runs, still logs, and still refuses
  the populated-both conflict (#1038).
- .dockerignore: **/node_modules, so the root-context build can't pick up
  host deps from backend/ or frontend/.
- docker-build.yml: build-aio / merge-aio follow the same per-arch build →
  digest-merge → per-version tag scheme as backend/frontend (GHCR only for
  now; the Docker Hub mirror is wired once the Hub repo exists), plus a
  smoke-aio job that boots the image on every PR and asserts /health, the
  SPA shell, the rendered brand title, immutable asset caching and the
  SQLite engine resolution.

Pointing DB_HOST/DB_USER/DB_PASSWORD + DATABASE_CLIENT=pg at an external
Postgres works exactly like the backend image.

* fix(ci): correct three smoke-aio assertions that would fail a green image (#1042)

Found by running the smoke job locally against a real build — the image
passed every behavioral check, but three assertions were wrong:

- `/` asserts 200, but handlePublicSiteRequest 302s to /admin/login while
  the public landing site is disabled, which is the state of the fresh
  install the smoke container always is. Assert the redirect target
  instead — that still proves express.static's index option is not
  shadowing the handler, which is the thing the check exists for.
- The placeholder-leak grep matched index.html's explanatory comment,
  which mentions BRAND_TITLE in prose and survives into the built shell.
  Match the literal ${BRAND_TITLE}/${BRAND_DESCRIPTION} tokens with -F,
  and cover the description token too.
- Add a gzip assertion, probing with GET: the compression middleware
  skips bodyless responses, so a HEAD probe reports no Content-Encoding
  even when compression is active.

Verified locally on linux/arm64: image builds clean, boots to healthy in
~8s on the SQLite default, and 25/25 checks pass (SPA shell, rendered
brand title, immutable+gzipped assets, no-store shell, SPA fallbacks,
npm removed, su-exec drop to nodejs, no errors in the boot log). The
DATABASE_CLIENT=pg override was exercised against a real Postgres too —
the readiness wait still runs and the engine resolves to postgres.

* fix(server): serve the SPA for every client route, not just /admin and /gallery (#1042)

nginx did `try_files $uri $uri/ /index.html`, so behind compose every
client-side route survived a direct hit or a refresh and the short
`['/admin', '/admin/*', '/gallery/*']` list was never exercised. Without
nginx that list is the whole contract, and everything outside it 404'd:

  /setup  /customer  /impressum  /datenschutz  /payment-check
  /quote/:token  /contract/:token  /invite/:token
  /transfer/:token  /transfer-upload/:token

/setup is the first URL a new install visits, so the all-in-one image was
unusable from a cold start.

The catch-all is registered after `app.use('/api', notFoundHandler)`, so
an unknown /api route still answers JSON instead of being handed the HTML
shell, and after the /s/:shortSlug resolver, so a typo'd short URL still
404s (#699). It is GET-only — a stray POST keeps 404ing rather than
getting a 200 page back. The handler is hoisted out of the
SERVE_FRONTEND block via `spaCatchAll` because that block runs before the
API 404 handler is registered.

Verified on the built image: all ten routes above now 200, /api/nope still
returns JSON 404, /s/nonexistent still returns 404, / still 302s to
/admin/login, and the smoke suite is 25/25. Both boundaries are now
asserted in the smoke-aio job.

* docs(readme): document the single-container install (#1042)

The README had no mention of the all-in-one image, so the only way to
discover it was reading the workflow file. Adds a Quick Start subsection
with the one-line `docker run` and the `docker exec … cat SETUP_TOKEN`
step, plus a row in the documentation table.

Deliberately does not sell it as the default: the note says the compose
stack is still the right choice for anything busier, gives the reason
(SQLite takes one writer at a time), and points at the `.picpeak`
restore as the way out, so nobody picks it and then finds themselves
stuck. Full details live at docs.picpeak.app/deployment/single-container
(PicPeak/docs#8).

* feat(docker): fold #1067's items into the all-in-one image (#1042)

Consolidating the two parallel AIO branches into this one. This PR's approach
is kept wherever the two differed on design — in particular the in-process
brand render, `index: false` (which fixes express.static shadowing
handlePublicSiteRequest, a bug #1067 had), the compression middleware, and the
smoke-aio job. What follows is what #1067 had that this branch did not.

Layout — the issue asks for a single mountable root, and this moves to one:

  /data/db       picpeak.db (+ -wal/-shm) and SETUP_TOKEN
  /data/storage  originals, thumbnails, archives
  /data/logs     application logs
  /data/backup   built-in backup output; /backup symlinks here

`-v picpeak:/data` and nothing else to remember. README and the smoke job's
database-path assertion follow the new layout.

Correctness items:

- sqlite CLI. DatabaseBackupService SPAWNS `sqlite3` for `.backup` and
  PRAGMA integrity_check; the npm module does not ship that binary.
  backend/Dockerfile omits it because compose always runs Postgres — this
  image defaults to SQLite, so every database backup failed with ENOENT.
- /backup wired in. Migrations 029 + 030 seed /backup/picpeak and
  /backup/database as the backup destinations; nothing created or mounted them,
  so backups had nowhere to write and anything written would die with the
  container. Symlinked into the volume, subdirectories created at startup
  (a bind mount hides the tree baked into the image), and adopted only when
  BACKUP_DIR is set so it never gates boot for compose deployments that do not
  mount it.
- logger.js honours LOG_DIR. It hard-coded <backend>/logs, so logs could not
  leave the container. Unset keeps the old path for every existing install.
- wait-for-db.sh derives its writable roots from STORAGE_PATH / DATA_DIR /
  LOG_DIR instead of hard-coded /app paths, and mkdir -p's them before chown —
  a bind-mounted /data hides the image's tree, and chown against a missing path
  reports "the filesystem rejects chown", which is both wrong and a dead end.
- .dockerignore excludes backend/-prefixed runtime data. Docker reads only the
  root file, so the unprefixed data/*.db, logs/* and storage/* rules missed
  backend/data, backend/logs and backend/storage entirely; a checkout used to
  run PicPeak would bake its database, photos, logs and SETUP_TOKEN into a
  published layer.
- HEALTHCHECK follows $PORT rather than a hard-coded 3000.
- --max-http-header-size=32768 matches nginx's large_client_header_buffers
  4 32k; Node's 16 KiB default would reject a guest carrying several
  per-gallery JWT cookies.

docs/single-container.md is added as the in-repo reference the README links to.

The smoke job gains four assertions for the above: the one-volume layout and
writable backup destinations, the sqlite3 CLI, logs landing on the volume, and
the image carrying no runtime data from the build context.

Verified on a built image — named volume, bind mount and PORT=8080 all healthy;
every existing smoke assertion still passes, including / -> 302 /admin/login,
the rendered BRAND_TITLE, immutable assets, gzip and /s/<unknown> -> 404.

Co-authored-by: Luca-Timo <102960244+Luca-Timo@users.noreply.github.com>

* fix(docker): restore the SPA-fallback exclusions and close the build-context leak (#1042)

Both found by external review of the consolidated branch.

- The SPA catch-all had no backend-owned exclusions. This was a regression I
  introduced while merging: #1067 carried a BACKEND_OWNED prefix list, and
  taking this branch's server.js wholesale (correctly — its index:false and
  in-process brand render are the better design) dropped it. /photos,
  /thumbnails, /uploads and /fonts are static mounts whose middleware calls
  next() on a miss, so the catch-all was answering 200 text/html under image
  and font URLs instead of 404. nginx gave each of those its own location
  block, so try_files never applied to them.

- backend/data is now excluded wholesale rather than by suffix. The suffix list
  (*.db, *.db-wal, *.db-shm, SETUP_TOKEN) let real secrets through: a used
  checkout carries ADMIN_CREDENTIALS.txt next to the database, plus -journal
  files and any DATABASE_PATH not ending in .db. Since Dockerfile.aio builds
  from the repository root and COPYs backend/ wholesale, any of those would be
  baked into a published layer. The directory holds only runtime state and is
  already gitignored in full.

smoke-aio gains an assertion that the backend static routes still 404, so the
exclusion cannot be dropped again silently.

Verified on a built image: /photos, /thumbnails, /fonts and /uploads misses all
404; /setup, /impressum, /gallery/x, /admin/login still 200; / still 302s to
/admin/login; /api/nope still answers JSON; /s/<unknown> still 404s; and the
image carries no *.db, ADMIN_CREDENTIALS.txt, logs or storage from the context.

* fix(aio): three failures that only surface outside a dev laptop (#1042)

Backups aborted on SQLite. getTableChecksums() built its digest with
`CAST(t.* AS TEXT)`, which is Postgres row-to-text syntax; SQLite parses
`*` there as a syntax error, so every backup threw before reaching the
.backup call. Since the all-in-one image ships SQLite by default, that is
every AIO install. Enumerate the columns via columnInfo() and sum their
lengths instead.

The shared /data mount root was never adopted. wait-for-db.sh chowned the
children it creates but not the mount point itself, so a host directory
arriving as 0700 with a foreign owner stayed untraversable by UID 1001
after the su-exec drop. Docker Desktop's permissive bind mounts hide this
completely, which is why local testing passed; a NAS share does not.
DATA_ROOT is now adopted first.

Maintenance mode locked the admin out of the box. The middleware runs at
server.js:493, long before the static block at 891, and exempted the auth
endpoints but not the page that calls them. With the backend serving the
frontend, /admin/login and /assets/* returned 503 JSON, so an admin who
enabled maintenance mode could never load the UI to turn it off. nginx
serves those paths in the compose stack, which is why it never surfaced
there. Guest and API surfaces stay gated.

Verified on a built image: checksums compute across all 95 tables; a bind
mount created 0700/4000:4000 boots healthy and ends up 1001:1001; with
general_maintenance_mode=true, /admin/login, /admin and /assets/* return
200 while /gallery/* and /api/gallery/* return 503 — and 503 across all
three once the exemption is removed again.

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

* fix(aio): stop leaking .env into the image, fix the broken checksum test (#1042)

The Jest suite was red: mocking db.raw is no longer enough now that the
SQLite checksum branch asks the query builder for its column list, so
db(table) came back undefined and getTableChecksums failed on every PR.
The production code is right; the fixture needed to know about the call.

backend/.env was landing in the published layer. The root ignore file's
`.env`, `.env.*` and `data/*.db` rules read as unanchored but Docker
matches them from the context root, so they catch ./.env and never
backend/.env — and `COPY backend/ .` then puts a real JWT_SECRET at
/app/.env. Matched at any depth instead, the way **/node_modules in the
same file already is. Confirmed by building from a checkout carrying a
planted secret: before, `cat /app/.env` printed it back.

Business documents wrote outside the volume. quoteService, invoice
sending/reminders and contract signatures build paths from
process.cwd()/storage and never read STORAGE_PATH; compose hides it by
setting STORAGE_PATH=/app/storage with WORKDIR /app so the two are the
same directory. Here they are not, and /app is root-owned, so a quote or
invoice PDF failed to write as UID 1001 — and would not survive the
container if it had. Symlinked /app/storage into the volume, matching
the /backup symlink beside it. Teaching those services STORAGE_PATH is
the real fix and wants its own change.

Two smaller ones: the mount root is now chowned shallow rather than
recursively, since every child below it is already walked recursively
and a NAS-sized photo library should not be traversed twice on each
restart; and /assets/ joins the backend-owned prefixes, so a stale
hashed chunk requested by a tab left open across an upgrade gets a 404
instead of index.html served with 200 under a .js URL.

Verified on a built image: planted backend/.env and backend/probe.db are
absent; /app/storage resolves to /data/storage and a business-doc write
as UID 1001 appears on the host; a 0700 bind mount owned by 4000:4000
boots healthy; a missing /assets chunk 404s while the real bundle still
serves 200 as application/javascript. The databaseBackup suite is green
again, and the branch adds no failing suite that origin/main does not
already fail on the same machine.

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

* test(aio): teach the leak assertion about the storage symlink (#1042)

The previous check listed /app/storage/events and treated a hit as a
leak. That was true while /app/storage was either absent or a copied
directory; now it is a symlink into the volume, so the check followed it
and found the empty tree the image itself creates — a false positive on
its own design.

Check the shape instead: /app/storage must be a symlink pointing at
/data/storage, and the volume's photo tree must contain no files on a
fresh install. A real directory there now fails loudly, which is the
condition the assertion was always trying to catch. Also extended the
path list to /app/.env and loose database files, matching the
.dockerignore rules added alongside.

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

* fix(aio): show the maintenance screen instead of raw JSON to guests (#1042)

The previous commit exempted the admin shell so an admin could still
reach the switch they had just flipped. Guests had the same problem for
the same reason: with no nginx in front, /gallery/<slug> reaches this
middleware long before the static block, so a visitor during maintenance
got a 503 JSON body where every other deployment shows the branded
maintenance screen the frontend already ships.

Replaced the two path-specific exemptions with the rule they were both
special cases of: a GET that is not an API call and not a backend-owned
content mount is the SPA shell, and the shell is inert HTML — it boots,
reads /api/public/settings (already exempt) and renders MaintenanceMode
on its own. Everything that carries real data stays gated: /api/*,
/photos/, /thumbnails/, /fonts/, and any non-GET.

Compose is untouched by construction, since nginx answers those paths
and they never arrive here.

Verified on a built image with the flag on: /gallery/x, /customer/x,
/admin and /admin/login return 200 text/html while /api/gallery/x/verify,
/photos/x.jpg and /thumbnails/x.jpg return 503 and a POST to a public API
still returns 503; with the flag off the same paths go back to 404. Added
a middleware test over that exemption matrix — over-exemption is the real
risk in this change, so it asserts the gated half too. It fails on five
cases without the fix.

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

* fix(aio): stop the shell exemption from un-gating /og and the public CMS (#1042)

The previous commit exempted "any GET that is not an API call". That
negative rule reads as safe and is not: /og/gallery/<slug> and its
/cover render the event name and the hero thumbnail, /s/<code> renders
short-link previews, and `/` is handed to the public CMS. All four are
proxy_passed to the backend by nginx, so they were gated before this PR
in every deployment — the rule un-gated them, and for compose too, not
just the new image. A site switched to maintenance would have kept
publishing gallery metadata.

Replaced the guess with the split nginx already defines: exempt what the
frontend container answers itself, gate what it proxies. That is the
same rule the all-in-one image needs by definition, since its whole job
is to be both halves of that stack, and it now matches compose in both
directions rather than only in the direction the last commit tested.

Verified on a built image with the flag on: /admin/login,
/gallery/<slug> and /customer/* return 200, while /, /og/gallery/x,
/og/gallery/x/cover, /s/abc, /robots.txt, /api/* and /photos/* return
503; with the flag off all of them behave normally again. The middleware
test grew the gated cases — it now covers 21, most of them asserting
what must NOT be exempt.

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

* fix(aio): give the image a FRONTEND_URL default so share links are absolute (#1042)

getFrontendBaseUrl() reads FRONTEND_URL, falls back to the
general_site_url setting, and otherwise returns an empty string — which
makes share_url come back as a bare "/gallery/<slug>/<token>". Compose
defaults the variable to http://localhost:3000, but the documented
one-liner for this image passes only JWT_SECRET, so every fresh
single-container install handed out relative links in API responses, QR
codes and emails.

Defaulted to the same value compose uses; -e FRONTEND_URL=https://...
overrides it, as does the site URL field in Settings.

Found by pointing tests/e2e/local at a running AIO container:
auth/06-api-tokens asserts share_url matches /^https?:\/\//, and it was
the one spec that failed for a product reason rather than a harness one.
It passes now, and the suite is 19/20 against the image — the remaining
failure is smoke/02-auth-flow, whose seed helper shells out to a
hard-coded `docker exec picpeak-backend`, so it cannot arrange its
precondition against any other container.

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

* feat(aio): mark the image so face recognition stays off (#1042, #1074)

Face recognition needs a separate ML container this image does not contain,
and enabling it here would add a second image-processing pipeline competing
with Sharp for the CPU and memory of a container sized for one photographer
plus guests browsing. The failure mode would not be a clear error — just a
slow install that looks broken.

The backend gate for this lands in #1075 and keys on PICPEAK_SINGLE_CONTAINER.
Without this line the guard never triggers on an actual all-in-one build, so
the two changes have to arrive together: whichever merges second completes
the pair. Verified against this file's exact value — isFeatureEnabled()
returns false with it set.

An explicit marker rather than inferring from SERVE_FRONTEND or the SQLite
path, because legitimate multi-container deployments do both of those and
should keep the feature.

Also adds it to the Limits section of docs/single-container.md, next to the
SQLite and Redis constraints, since that is where someone will look before
choosing this image.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: the-luap <paul-nothaft@hotmail.de>
2026-08-18 22:15:49 +02:00
Paul Nothaft f22999aba6 fix(storage): write business documents under STORAGE_PATH, not the cwd (#1070)
* fix(storage): write business documents under STORAGE_PATH, not the cwd

persistDocPdf, the invoice sending and reminder writers and both contract
signature writers built their target from
`path.join(process.cwd(), 'storage', 'business-docs', ...)` and never
consulted STORAGE_PATH. docker-compose.yml and
docker-compose.production.yml both pin STORAGE_PATH=/app/storage and the
image's WORKDIR is /app, so on a stock deployment the two expressions
name the same directory and nothing looked wrong.

Point STORAGE_PATH anywhere else and quotes, invoices, Mahnungen and
contract PDFs land outside the configured storage root: missed by the
backup walker, invisible to the storage accounting, and gone when the
container is replaced. It also fails outright where the working
directory is not writable by the runtime user.

Routed all six writers through getStoragePath(), the resolver the rest
of the app already uses. Two read-side sites of the same class came
along: the custom PDF font lookup now checks the storage root before the
legacy cwd path (a font under STORAGE_PATH/fonts was simply never found,
and the document silently fell back to the built-in face), and the
dev-test scratch directory follows the same root.

Left alone deliberately: resolveLogoFile and adminBusinessProfile
already try both roots, so their cwd reference is a legacy fallback
rather than a miss.

No migration needed — the persisted path is stored absolute, so rows
written before this keep resolving to where those files actually are.

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

* fix(storage): allow the configured contract root, and move signature images too

Two holes in the previous commit, both found by review.

Contract downloads would have broken. assertContractPdfPath() guards the
admin unsigned/signed PDF routes and GET /api/public/contracts/:token/pdf,
and it listed only <cwd>/storage/business-docs/contract. Moving the
writers to STORAGE_PATH without moving that root meant every newly
generated contract was refused with PATH_OUTSIDE_STORAGE — a worse
failure than the bug being fixed, and only on the installs the fix was
for. The configured root is now allowed alongside the cwd one, which
stays for contracts written before the move; their absolute paths are in
the database and still resolve. Note the sibling root on the next line
already honoured STORAGE_PATH, so the helper was half-migrated already.

persistSignatureImage() still wrote customer and admin signature PNGs
under process.cwd(). It was missed because its path.join is spread over
seven lines while the others are single-line — and the regression test
compared against the single-line literal, so it reported green over a
live bug. The test now collapses whitespace before matching, which is
the only reason a formatting difference ever hid this. A sweep of the
whole of src/ with the same normalisation confirms the remaining
process.cwd()/storage references are all deliberate
`STORAGE_PATH || cwd` fallbacks, not misses.

Added a case that drives assertContractPdfPath against real files on
disk — the guard realpaths both the file and its roots, so a test using
imaginary paths proves nothing. It fails without the fix.

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

* fix(storage): resolve the contract guard's root through the shared resolver

The guard still built its own `STORAGE_PATH || <cwd>/storage`. That
matches getStoragePath() only while STORAGE_PATH is set — with it unset
the shared resolver falls back module-relative to <repo>/storage while
this fell back to <cwd>/storage, and the backend is normally started
from backend/, so the two name different directories. Writers and guard
then disagreed about where contracts live and the download routes
refused them, which is the same failure the previous commit fixed for
the configured case, reappearing in the fallback case.

One resolver on both sides now, which is the point of the whole change.
Docblock updated to describe the three roots as they actually are.

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

* fix(storage): make the fallback test safe, and align the backup diagnostics

The test added in the previous commit was dangerous. To exercise the
STORAGE_PATH-unset case it deleted process.env.STORAGE_PATH and then, in
cleanup, recursively removed `<resolved root>/business-docs` — which
with the variable unset resolves to the developer's real, gitignored
<repo>/storage. Running `npm test` in a working checkout would have
destroyed local business documents. This checkout has 65 MB there,
including a populated business-docs tree.

Rewritten to mock the shared resolver instead. That is both safe (every
path stays in the tmpdir) and a sharper assertion: if the guard consumes
getStoragePath() the mock moves its root, and if it went back to rolling
its own expression the mock would have no effect and the test fails —
which is exactly the regression being pinned.

backupCoverageService and backupIntegrityService kept their own
`STORAGE_PATH || cwd` roots. The backup walker itself already falls back
module-relative, so with the variable unset the two diagnostics
inspected a directory neither the walker nor the writers use and would
report the business-docs tree as missing while it was in fact being
backed up. Both now use the shared resolver.

No regression: the same jest invocation over contract/quote/invoice/pdf/
backup suites gives an identical 11 failed, 24 passed before and after —
those failures are a locally missing cron-parser dependency and
reproduce on an unmodified tree.

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-18 22:14:41 +02:00
Paul Nothaft 44adabca9e test(e2e): read the admin JWT from the cookie, not the login body (#1071)
Three specs acquire an admin token with `const body = await res.json();
return body.token`. The admin login has not returned a token in its body
for some time — establishAdminSession() sets the JWT as the httpOnly
`admin_token` cookie and responds with `res.json({ user })` — so the
token was undefined and every one of them failed at the first assertion,
before exercising anything they were written to cover.

Server-side the cookie and an Authorization: Bearer header are
interchangeable (see middleware/gallery.js, which reads the cookie first
and accepts an admin-typed Bearer second), so the fix is to read the
value back out of the context cookie jar and keep threading it as a
Bearer. Every downstream call in these specs stays exactly as it was.

Measured against a real stack, running only these three files:

  before   0 passed, 6 failed   — all six at the token assertion
  after    3 passed, 3 failed

The three that still fail no longer fail on auth: they get deep into the
flow and then miss UI that has since changed (a settings label, a
locator that no longer resolves). That is a separate and much larger
staleness problem across this directory — a full run is 12 passed
against roughly two dozen failures of that kind — and it is not
addressed here.

Worth knowing: no CI workflow runs tests/e2e at all, which is why this
rotted silently while `npm run test:e2e` stayed documented in CLAUDE.md.
Wiring it up is the obvious follow-up, but it has to wait until the
suite is actually green, or it would just pin main red.

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

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-18 22:14:00 +02:00
Paul Nothaft d62e21c1ba chore(main): release 3.105.1-beta.0 (#1066)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-16 19:41:03 +00:00
Paul Nothaft 52db982661 fix(gallery): make per-event banner overrides actually work, both banners (#440, #932) (#1064)
* fix(gallery): make per-event banner overrides actually work, both banners (#440, #932)

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

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

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

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

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

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

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

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

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

Test covers the stale-markdown source explicitly.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 21:37:17 +02:00
Paul Nothaft 180f19d70d chore(main): release 3.105.0-beta.0 (#1065)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-16 19:36:29 +00:00
Paul Nothaft b48fa62eea feat(gallery): info banner above the photo grid (#932) (#1063)
* feat(gallery): info banner above the photo grid (#932)

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 21:33:13 +02:00
Paul Nothaft ab6ca6f82f chore(main): release 3.104.1-beta.0 (#1061)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-16 17:34:09 +00:00
Paul Nothaft 3a11e6ebb5 fix(pdf): RFC 6266-encode Content-Disposition on quote/invoice PDFs (#1024) (#1055)
* fix(pdf): RFC 6266-encode Content-Disposition on quote/invoice PDFs (#1024)

The six quote/invoice PDF endpoints interpolated buildPdfFilename()'s result
straight into `inline; filename="${filename}"`. That result deliberately
preserves non-ASCII (it doubles as the PDF's internal Title metadata), and
HTTP header values are latin1 — so a customer label reaching the header
directly failed in one of two ways:

  - U+0080-U+00FF (ä ö ü ß — every German umlaut): no throw. The raw byte
    goes out and the client reads back a mangled name. Silent corruption.
  - above U+00FF (Polish ł, Czech ř, Turkish ş, €, Cyrillic, CJK, emoji):
    Node's setHeader rejects it with ERR_INVALID_CHAR. The throw lands
    after the PDF buffer is already rendered, so the request 500s.

Note this corrects the issue's diagnosis: it reported umlauts as the 500
case, but umlauts are inside latin1 and mangle rather than throw. Both
symptoms share this root cause and both are fixed here.

Route through buildContentDisposition() (utils/filenameSanitizer, already
used by secureImages.js), which emits an ASCII fallback plus the RFC 5987
`filename*=UTF-8''…` form, so the unicode name survives in browsers and the
header stays legal. Applied to all six sites: adminQuotes (persisted +
preview), adminInvoices (persisted + preview), customer (quote + invoice).

Also correct buildPdfFilename's docstring, which advertised the preserved
non-ASCII as suitable for Content-Disposition — the exact misreading that
produced these call sites.

* test(pdf): pin the ASCII fallback for fully non-Latin customer names (#1024)

A name written entirely in another script leaves the legacy filename= token
with just the document number (Q-2026-0042_.pdf) — filename* carries the real
name. That's the intended trade, but it's the token a client without RFC 5987
support actually saves, so assert it stays legal, non-empty and carries the
document number rather than leaving it unpinned.

* fix(pdf): don't split surrogate pairs when truncating the filename (#1024)

Codex review caught this. sanitiseSegment caps each segment at 80 UTF-16 code
units, so a cap landing inside an astral character (emoji, rarer CJK) left a
dangling high surrogate. encodeURIComponent throws URIError: URI malformed on
a lone surrogate, so buildContentDisposition — the helper this PR routes the
six PDF endpoints through — 500'd for e.g. company_name = 'a'.repeat(79)+'🎉',
well inside the 120-char validator limit. Same 500 the PR set out to remove,
reached a different way.

Drop the orphaned surrogate instead of widening the cap, so the byte budget
the limit exists to protect is unchanged. Tests cover both boundary cases and
assert the cap semantics; they fail against the previous slice().

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 19:31:19 +02:00
Paul Nothaft 959c18a864 chore(main): release 3.104.0-beta.0 (#1057)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-16 17:04:35 +00:00
Luca 8809564aad feat(backup): open sqlite → pg .picpeak restore as the supported upgrade direction (#1041) (#1043)
Reshaped onto main after #1039 landed the coercion engine
(typedColumnsFor / epochToIso / coerceForTargetEngine) — this PR is now
only the policy delta on top of it:

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

Co-authored-by: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
2026-08-16 19:01:36 +02:00
Paul Nothaft 18b1e0f66e ci(tests): run the gated real-Postgres .picpeak cases in the backend job (#1056)
The .picpeak restore suites gate their Postgres cases behind
PICPEAK_PG_TEST_URL and describe.skip themselves out when it is unset. That
variable was set in no workflow, so those cases have never run in CI — the
suites reported green while silently skipping the half that needs a real
database: sequence resync, operator/role preservation across a cross-instance
restore, and whether a coerced row lands with the right STORED VALUES rather
than merely not throwing.

Add a postgres:15-alpine service to the backend job (same shape schema-drift
already uses) and point the variable at it. Everything else in the suite still
runs on SQLite; this only un-gates the cases that were skipping.

Verified against a real Postgres 15 before wiring: picpeakRestorePg 4/4 and
picpeakCrossEngine 11/11 (8 of which were previously skipped across both).

Matters now because #1043 opens sqlite -> pg restore to the upload UI, so the
coercion layer's correctness stops being a CLI-only concern.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 18:57:44 +02:00
Paul Nothaft 77cb65f5a2 chore(main): release 3.103.1-beta.0 (#1053)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-16 13:17:54 +00:00
peipeimo 3600231d5f fix(storage): add S3 client timeouts so a dropped connection can't wedge uploads (#1049)
* fix(storage): add S3 client timeouts so a dropped connection can't wedge uploads

* fix(storage): use socketTimeout, not requestTimeout, for the dead-connection guard

* fix(storage): make S3 timeouts generous — short connectionTimeout breaks pooled reads

---------

Co-authored-by: Peifu Mo <peipeimo@Peifus-MacBook-Pro.local>
2026-08-16 15:14:48 +02:00
Paul Nothaft 2728479d2a chore(main): release 3.103.0-beta.0 (#1052)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-16 13:02:55 +00:00
Luca b118695474 feat(permissions): granular permission gating + role editor & presets (#747, phase 1 of #743) (#1045)
* feat(permissions): granular permission gating + role editor & presets

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Round-2 review:
- The protected-key 403 fired on key PRESENCE. The General tab re-posts
  general_site_url on every save, so a settings.edit-only role (the office
  manager this PR enables) got 403'd on every General save even when the URL
  was unchanged. Restore change-detection: compare the incoming value against
  the stored one and 403 only on an actual change; unchanged protected keys are
  dropped so the rest of the save proceeds. Only /general is affected.
- Commit the self-amplification guard test (was run locally, never staged):
  adminRolesGuards.test.js — non-super can't grant perms it lacks, can't edit
  its own role, can't escalate another role; super_admin bypasses;
  team_photographer name reserved.
- Add adminSettingsProtectedKeys.test.js pinning the change-detection: an
  unchanged general_site_url saves, an actual change 403s, super_admin changes it.
2026-08-16 14:59:52 +02:00
Paul Nothaft 9b976386f9 chore(main): release 3.102.2-beta.0 (#1046)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-13 16:57:48 +00:00
Paul Nothaft 6de30e5bf1 fix(docker): default NODE_ENV=production so non-compose deploys don't fall back to SQLite (#1038) (#1039)
* fix(docker): default NODE_ENV=production so non-compose deploys don't fall back to SQLite (#1038)

knexfile.js selects its config block by NODE_ENV and the `development` block
defaults to sqlite3. The image never set NODE_ENV, so every deployment that
doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD.

It stayed invisible because wait-for-db.sh is shell: it reads DB_HOST directly,
connects to Postgres, creates the database and logs "PostgreSQL is up" in the
same container where the Node process then writes to a SQLite file. Migrations
go through src/database/db.js → the same knexfile, so they also ran against
SQLite, leaving the provisioned Postgres database empty.

Setting the default alone would be unsafe: an affected install would flip to
Postgres on its next image pull and come up against an EMPTY database, which
reads as total data loss. So this adds a guard that runs before migrations
touch anything:

  - logs the resolved engine + target at boot (nothing did before, which is
    why this went unnoticed for so long)
  - refuses to start when pointed at a virgin Postgres while a populated
    SQLite file exists, naming the file and the .picpeak export path for
    moving the data, with PICPEAK_ALLOW_EMPTY_PG=true as the escape hatch
  - warns but boots when Postgres settings are present yet SQLite is in use

Compose files already set NODE_ENV explicitly, so compose users are unaffected.

The engine-selection tests resolve knexfile in a child process with a clean
cwd — dotenv.config() would otherwise let a developer's backend/.env decide
the answer instead of the knexfile defaults under test. Fake credentials in
the describeEngine tests are built at runtime rather than written inline, so
secret scanners don't flag a literal after `password:`.

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

* fix(db): stay on SQLite instead of blocking, and add a migration path (#1038)

Reworks the guard from the previous commit after walking through what an
existing install actually experiences on its next image pull.

Blocking was the wrong trade. An operator who had unknowingly been running on
SQLite (because the image left NODE_ENV unset) would have pulled the fix and
got a CrashLoopBackOff: data safe, galleries offline, for something they did
not do. Now the boot RESOLVES the engine before migrations run and stays on
whichever one holds the data:

  - Postgres configured but holding no galleries, while a populated SQLite file
    exists → keep serving from SQLite, print what happened and how to migrate.
    Nothing moves until the operator decides.
  - once Postgres holds the data, the next restart switches over on its own.
  - an explicit DATABASE_CLIENT is always honoured.

The check is keyed on Postgres holding DATA, not on it having tables: a stray
`run-migrations` against the empty database creates every table, which would
otherwise blind the check and strand the operator on an empty install.

wait-for-db.sh resolves the engine and exports DATABASE_CLIENT before the
migration step, so the runner and the server always agree. Manual migration
runs (no entrypoint, no exported client) now refuse rather than build a schema
in the wrong database.

Adds scripts/migrate-sqlite-to-postgres.js for moving the data across. It
reuses the .picpeak export/import services rather than hand-rolling a
cross-engine copy — they already handle FK suspension, JSON columns and
Postgres sequence resync. Two things had to be added for the SQLite → Postgres
direction, both opt-in and CLI-only so the upload/restore UI is untouched:

  - `allowEngineSwitch` relaxes the importer's same-engine guard
  - cross-engine row coercion: SQLite has no real date or boolean types, so
    its rows carry epoch numbers where Postgres wants a timestamp and 0/1
    where it wants a boolean. Postgres rejects both outright
    ("date/time field value out of range: 1786548038763"). Coercion is driven
    by the TARGET schema, never guessed from the value.

Verified end to end against a real PostgreSQL 15: a seeded SQLite install
migrated across with booleans, timestamps and foreign keys intact, and the
serial sequences correctly advanced (the next INSERT got id 2, not a
primary-key collision). Photo files on disk are never touched and the SQLite
file is left in place as a rollback.

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

* fix(db): close four review findings on the SQLite fallback + migration (#1038)

External review (codex) found four issues, all confirmed against the code and
fixed here. Two of them could have cost data.

1. The engine resolver was reachable only through wait-for-db.sh. A Kubernetes
   manifest that sets `command`/`args`, or a plain `docker run … node
   server.js`, bypasses the entrypoint — exactly the deployment styles this fix
   targets. With NODE_ENV now baked into the image, such an install would have
   resolved to Postgres and come up against an empty database while its SQLite
   data sat there unseen. server.js now resolves the engine itself, before
   anything requires knexfile, via the same script the entrypoint uses.
   Verified by running `node server.js` directly against an install with
   stranded SQLite data: it logs the banner and serves SQLite.

2. Cross-engine loads double-encoded JSON. SQLite has no json type, so its json
   columns are TEXT holding JSON; the export dumps that as a string and
   serialiseJsonColumns stringified it again, storing `true` as the scalar
   string "true". app_settings.setting_value is json on every install, so this
   reshaped every migrated setting. The text is decoded before serialisation
   now — verified against a real Postgres: json_typeof(setting_value) is
   `boolean`, matching a native install exactly.

3. The migration could silently miss concurrent writes. If the backend keeps
   serving, rows written after the export never reach Postgres and vanish from
   view once the engine switches. The script now fingerprints the SQLite tables
   whose loss would be noticed, checks for drift BEFORE loading Postgres (so a
   detected race leaves the target untouched) and again after, and refuses with
   the exact rows that moved. It also says plainly to stop the backend first.

4. The child phases shared stdout with winston. Outside production, and
   whenever LOG_TO_CONSOLE=true, createPicpeak's own log line was concatenated
   with the archive path and the migration failed on a bogus filename. Payloads
   travel through a result file now; verified with LOG_TO_CONSOLE=true.

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

* fix(db): close review round 2 — six more data-safety findings (#1038)

1. The engine choice is now PINNED once the data is in Postgres. Previously the
   boot decided from "does Postgres hold galleries", so an operator who later
   deleted every gallery would be sent back to the stale pre-migration SQLite
   file while their settings, admins and CRM data stayed in Postgres. The
   migration writes a marker next to the database file (and retires the file
   itself by renaming it); the marker wins over any probe.

2. The migration refused to overwrite Postgres only when it held GALLERIES. A
   target with admins, customers, invoices or projects but no galleries was
   wiped without --force. Both the source and target checks now look for user
   data across the tables that are empty on a fresh install.

3. Same bug in the other direction: an install with no galleries but real
   admins/settings/customers was refused a migration it was entitled to.

4. Drift detection covered four tables and only count/max(id), so an in-place
   UPDATE (event edit, password change) or a write to any other table passed
   unnoticed. It now fingerprints every table the export carries, including
   max(updated_at). It still is not a substitute for stopping the backend, and
   the script says so rather than implying a guarantee.

5. probeSqliteData() treated an unreadable or corrupt file as "no data", which
   would have switched the install to an empty Postgres — the very failure this
   module exists to prevent. It fails closed now and stays on SQLite so the real
   error surfaces.

6. The "you are leaving SQLite data behind" warning was unreachable: setting
   DATABASE_CLIENT skipped the probes, so the branch that produces it never had
   the inputs. Postgres and SQLite are both probed whenever Postgres is the
   engine in play.

Also: the final verification compares row counts for EVERY table rather than
just galleries, and flags only a shortfall — the import legitimately adds an
app_settings row (setSessionsValidAfter) that made the strict equality fail on
a first real run.

Verified against a real PostgreSQL 15 end to end, including: the marker keeps
an install on Postgres after every gallery is deleted; removing the marker and
restoring the file rolls back to SQLite as documented.

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

* fix(db): close review round 3 — occupancy, bootstrap admin, secrets in /tmp (#1038)

1. Both engine probes judged occupancy by GALLERIES alone. An install whose
   galleries were all deleted, but which still has admins, customers or
   accounting records, was treated as empty: on the SQLite side that meant
   booting the empty Postgres and appearing to lose everything; on the Postgres
   side it meant diverting a live install to a stale SQLite file. Both now look
   across the tables that are empty on a fresh install, matching the migration
   script.

2. The migration ran migrate-schema BEFORE checking the target, and migration
   001 seeds a bootstrap admin when ADMIN_PASSWORD is set (common on legacy
   installs). The occupancy check then saw that admin and refused, pushing the
   operator towards --force against a genuinely empty database. The target is
   read first now.

3. probeSqliteData()'s warning went through the app logger, which writes to
   STDOUT when LOG_TO_CONSOLE=true — and the resolver's stdout is the protocol
   channel wait-for-db.sh captures, so DATABASE_CLIENT could have been set to a
   JSON log line. Diagnostics take an injected sink (stderr in the resolver),
   and the shell now validates the value it captured instead of trusting it.

4. The .picpeak archive holds password hashes, SMTP credentials and API keys in
   plaintext, and was only removed on the fully-successful path — any drift or
   import failure left it in /tmp. Every exit path removes it now.

5. A database-only migration still hauled every business-doc and upload through
   /tmp and back into the same volume. createPicpeak takes includeFiles:false
   for this path; rows move, files stay where they already are.

Verified against a real PostgreSQL 15: a gallery-less install with only an admin
account now stays on SQLite and migrates successfully with ADMIN_PASSWORD set;
the resolver emits exactly one token on stdout with LOG_TO_CONSOLE=true and a
corrupt database; a drift failure leaves Postgres untouched and no archive
behind.

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

* fix(db): pin the boot to SQLite while a migration is unfinished (#1038)

Review round 4. A migration that dies after touching Postgres leaves rows
behind — schema creation alone seeds a bootstrap admin when ADMIN_PASSWORD is
set, and a drift or row-count failure can leave a partial load. Since the
occupancy probes were widened in round 3, those rows read as "Postgres is
occupied", so the next restart would switch engines and hide the SQLite data
that is still the database of record.

The script now writes a pin file next to the database BEFORE its first Postgres
write and clears it only on success (after the success marker exists, so no
restart in between can pick the wrong engine). While the pin is present the
resolver stays on SQLite and explains why.

Verified against a real PostgreSQL 15 by reproducing the exact scenario: a
migration failed mid-run with ADMIN_PASSWORD set, leaving one bootstrap admin
in Postgres. With the pin the next boot resolves to sqlite3; with the pin
removed it resolves to pg — the failure this closes. The subsequent successful
re-run clears the pin and the boot moves to Postgres.

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

* fix(db): close review round 5 — occupancy, path drift, retry, host default (#1038)

1. A seeded bootstrap admin counted as "Postgres is occupied". core/001_init.js
   inserts one whenever ADMIN_PASSWORD is set, so a Postgres that was
   initialised once and never used would have beaten a SQLite file full of real
   galleries — the exact failure the guard exists to prevent, reintroduced by
   widening the probe in round 3. The two sides are deliberately asymmetric now:
   the SQLite probe counts any user data (err towards keeping data visible),
   the Postgres probe ignores rows that schema creation seeds (err towards
   requiring proof of real use).

2. The guard resolved DATABASE_PATH with its own logic while knexfile trimmed
   whitespace and collapsed the legacy duplicated-backend form. A path either
   engine normalised differently meant probing a file nobody uses, concluding
   there was no SQLite data, and booting an empty Postgres. The resolution now
   lives in one module both require.

3. Re-running after a partial migration — the documented recovery — was refused
   unless the operator passed the destructive-sounding --force, because the
   half-written rows read as target data. An unfinished run of this same script
   is now recognised as a safe retry.

4. wait-for-db.sh verified readiness against its own default host (`postgres`)
   while knexfile's production block defaults to `db`. With NODE_ENV now baked
   in, a bare `docker run` without DB_HOST would have passed the readiness check
   against one host and then dialled another. The entrypoint exports the exact
   connection it verified. Compose sets DB_HOST explicitly and is unaffected.

Verified: a Postgres holding only a seeded admin now loses to real SQLite data;
a DATABASE_PATH with surrounding whitespace resolves to the identical file in
both knexfile and the guard.

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

* fix(db): close review round 6 — explicit-client bypasses, retry scope, cleanup (#1038)

1. An explicit DATABASE_CLIENT bypassed the unfinished-migration pin, because
   decideBootEngine honoured it first. docker-compose sets DATABASE_CLIENT=pg,
   so a failed migration would have restarted on a half-written Postgres on
   exactly the deployments that pin it. Worse in the other direction: with
   DATABASE_CLIENT=sqlite3, a SUCCESSFUL migration renames the source file, so
   the next start created a NEW, empty SQLite database and served that. The pin
   now outranks explicit pg (clearing the marker is the override), explicit
   sqlite3 is left alone since it already points at the data, and the migration
   refuses up front when the deployment pins anything other than pg.

2. The retry allowance was bound to the SQLite file, not to the target. An
   operator who repointed DB_HOST/DB_NAME between attempts could have replaced
   an unrelated populated database without --force. The pin records the target
   and the allowance only applies when it matches.

3. The printed rollback did not roll back: with data on both sides and no
   marker, the resolver still selects Postgres. It now spells out all three
   steps, including DATABASE_CLIENT=sqlite3.

4. A failure inside createPicpeak left a partial archive — plaintext hashes and
   credentials — in the caller-supplied temp dir, which that service
   deliberately does not clean. The export phase removes it on error.

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

* fix(db): close review round 7 — pin bypass on direct start, real admins (#1038)

1. server.js only ran the engine resolver when DATABASE_CLIENT was unset, so a
   deployment that both bypasses the entrypoint (Kubernetes `command:`) AND
   pins DATABASE_CLIENT=pg never consulted the migration pin — the round-6 fix
   was unreachable on exactly that path, and a failed migration would have
   served a half-populated Postgres. The resolver now also runs whenever a pin
   file exists.

2. Round 5 excluded admin_users from Postgres occupancy to stop a seeded
   bootstrap admin counting as real data. That over-corrected: an install that
   has completed first-run setup but has no galleries yet has exactly one
   user-created row — an admin — so Postgres looked empty and, with a stale
   SQLite file present, the boot would switch away and the admin's credentials
   and configuration would disappear.

   core/001_init.js seeds must_change_password=true; setupService writes false
   once a human completes setup. The FLAG, not the table, distinguishes them,
   and a legacy NULL counts as a real admin.

Verified against a real PostgreSQL 15: a Postgres holding only the seeded row
loses to real SQLite data, the same Postgres wins once setup is completed, and
a server started directly with DATABASE_CLIENT=pg and a pin present comes up on
SQLite with the warning.

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

* fix(db): close review round 8 — reset admins, CLI config, JSON nulls (#1038)

1. must_change_password is mutable: resetAdminPassword() re-raises it on REAL
   accounts (userManagementService.js:474). Round 7's discriminator therefore
   read a gallery-less Postgres whose only admin had been reset as an untouched
   bootstrap seed — and with a stale SQLite file present, the boot would have
   switched away and hidden those live credentials. The rule is layered now:
   more than one admin, any admin that has logged in, or must_change_password
   false all count as use. Only core/001_init.js's exact leftovers — one admin,
   never logged in, still flagged — read as a seed.

2. The CLI read process.env directly but never loaded the configuration the
   child phases get through knexfile, so running it directly (or via
   `docker exec`, which does not inherit wait-for-db.sh's exports) failed the
   pre-flight checks even with valid settings in backend/.env or
   /run/secrets/db_password. Both sources are loaded up front now.

3. The migration's target check counted a seeded bootstrap admin as user data
   while probePgData classified the identical row as empty, so migrating into a
   previously-initialised-but-unused Postgres demanded --force. Same rule on
   both sides.

4. Cross-engine JSON handling is simpler and no longer lossy. SQLite keeps json
   columns as TEXT holding valid JSON and Postgres accepts JSON text directly,
   so the correct action is to pass them through untouched. Round 1 parsed then
   re-serialised them to undo a double-stringify; that round-tripped the JSON
   literal `null` into SQL NULL, changing data and breaking NOT NULL json
   columns. Not serialising at all fixes both.

Verified against a real PostgreSQL 15: a migrated install now carries
json_typeof = null for a JSON null, object for a nested object, and boolean for
a boolean — matching a native install exactly.

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

* fix(db): close review round 9 — probe error classes, marker ordering (#1038)

1. probePgData() answered every failure with "Postgres has data". That is right
   for an unreachable server — the app cannot run on it either way, and
   diverting a healthy pg install to a stale SQLite file over a transient blip
   would be worse — but wrong for a server that answers and then fails the
   query, which is what a half-built or damaged schema looks like. That is not
   evidence of data, and reporting it as such booted the empty Postgres and hid
   a populated SQLite file: the exact failure this guard exists to prevent.

   Reachability is now established with SELECT 1 first, so the two cases get
   opposite answers: unreachable → leave the configured engine alone;
   reachable-but-uninspectable → unproven, and the SQLite side wins if it
   actually holds data.

2. The success marker was written after the SQLite file was renamed away. A
   failure in between — a full disk — left the source retired with no marker:
   the next attempt reported "No SQLite database", the in-progress pin stayed,
   and the operator never saw the rollback path. The marker is written first
   and updated with the retired filename once the rename succeeds, so a failure
   at any point leaves everything recoverable.

Verified against a real PostgreSQL 15: a reachable database whose admin_users
table lacks the probed column now resolves to sqlite3 rather than hiding the
data, while an unreachable host still resolves to pg.

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

* fix(db): don't fail the migration on empty SQLite-only tables (#1038)

Review round 10. The final verification flagged every source table missing from
Postgres, regardless of whether it held rows — and SQLite-only tables do exist:
initializeDatabase() creates an `events_new` scratch table and, when its legacy
column copy throws, the catch swallows the error and leaves the empty table
behind (db.js:236). The importer correctly skips tables Postgres does not have,
so verification then reported a mismatch AFTER the data had already landed,
exited 1, and left the install pinned to SQLite with no way to finish.

An absent target table only matters if the source actually had rows. Empty ones
are now listed and skipped.

Reproduced both ways against a real PostgreSQL 15 with an events_new table
present: without the fix the run ends in "ROW COUNTS DO NOT MATCH" and leaves
the in-progress pin; with it, the table is reported as skipped, the migration
completes and the pin is released.

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

* fix(db): a completed migration overrides an implicit SQLite config (#1038)

Review round 11. The migration allowed the one configuration it should have
worried about most: DATABASE_CLIENT unset AND NODE_ENV not "production", which
resolves to the development block — i.e. sqlite3. That is precisely the state
the affected installs are in, since it is why they ended up on SQLite at all,
so an operator can easily run the migration before fixing it. The script then
renames the source database away, and the next start resolved to the implicit
sqlite3, created a NEW empty database and served it — after reporting success.

The success marker now overrides an IMPLICITLY resolved sqlite3 when Postgres
settings are present, because the marker is durable proof of where the data
actually went. An explicit DATABASE_CLIENT=sqlite3 still wins: that is the
documented rollback.

The script says something rather than refusing — refusing would block exactly
the population this exists for.

Reproduced with NODE_ENV and DATABASE_CLIENT both empty, against a real
PostgreSQL 15: the migration completes, the source is renamed away, and the
next boot resolves to pg with the data intact. Before this it resolved to
sqlite3 and would have served an empty database.

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

* refactor(db): drop the dead reachability flag in probePgData (#1038)

github-code-quality flagged `if (reachable)` as always true, and it is right:
the unreachable branch returns, so everything below it runs only when the probe
connected. The variable and the conditional were leftovers from a first draft
that used a single catch for both failure classes.

No behaviour change — the two error paths still return opposite answers.

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

* fix(db): refuse to choose when both databases hold data (#1038)

Review round 12.

1. An install that ran on PostgreSQL, lost NODE_ENV/DATABASE_CLIENT, and kept
   working on SQLite has REAL data on both sides: old rows in Postgres, newer
   ones in SQLite. The stranded-data rule only protected SQLite when Postgres
   was empty, so pulling this fix would have booted Postgres and hidden every
   gallery created since the switch — the exact failure this PR exists to
   prevent, in a variant I had not considered.

   A completed migration leaves a marker saying which side is current. Without
   one, two populated databases are a conflict: the boot stops and prints both
   targets, the two DATABASE_CLIENT values that resolve it, and the migration
   command that merges them. This is the only deliberate refusal in the change —
   guessing here would hide data AND split subsequent writes across two
   databases.

2. probePgData was handed knexConfig.connection even when knexfile had resolved
   to SQLite (a completed migration whose environment still says sqlite3), so
   node-postgres dialled its own localhost defaults instead of DB_HOST/DB_NAME —
   false "unreachable" diagnostics and a needless delay on every boot. The probe
   target is now built from the environment when the config is not pg.

The conflict is honoured by all three entry points: the resolver exits 3 with an
empty stdout, wait-for-db.sh stops the container, and server.js refuses to start.

Two existing tests asserted that Postgres wins when both sides hold data. They
encoded the pre-conflict assumption and described a state that cannot occur
after a real migration (which always leaves a marker); both now pass the marker.

Found while testing: the resolver's logger shim had no .error, so the conflict
path threw, was swallowed by the fallback, and silently chose Postgres — the
precise outcome this refuses to make. The shim is complete now.

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

* fix(db): symmetric bootstrap rule, one resolved Postgres target (#1038)

Review round 13. Both findings are consequences of earlier rounds.

1. The conflict rule added in round 12 counted an untouched SQLite bootstrap
   admin as data. core/001_init.js seeds one whenever ADMIN_PASSWORD is set —
   including into the accidental SQLite database — so a healthy Postgres install
   that had ever started once without NODE_ENV would have had a seeded-only
   SQLite file beside it, been declared a both-populated conflict, and REFUSED
   TO BOOT. The bootstrap discrimination is applied on both sides now; a
   setup-completed or logged-in admin still counts as real use on either.

2. The CLI's child phases inherited whichever knexfile block NODE_ENV selected.
   The development block defaults Postgres to localhost/postgres/photo_sharing,
   production to db/picpeak/picpeak — and this script is explicitly meant to run
   with NODE_ENV unset. With DB_USER/DB_NAME left to defaults it would therefore
   have migrated into `photo_sharing`, after which following the script's own
   advice to set NODE_ENV=production pointed the app at an empty `picpeak`.

   The target is resolved once, with production defaults, and passed explicitly
   to every phase — so the block knexfile happens to pick can no longer decide
   which database the data lands in. The pin and success marker record that same
   resolved identity.

Verified against a real PostgreSQL 15: a live Postgres beside a seeded-only
SQLite file now boots pg rather than refusing, flipping that admin to
setup-completed restores the conflict, and a migration records
localhost:7102/picpeak_r13b as its target rather than a defaulted guess.

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

* fix(db): one Postgres identity everywhere; protect the credentials file (#1038)

Review round 14. Three of the six findings were the same defect as round 13's,
surfacing through paths that fix did not cover: the connection used to PROBE or
MIGRATE could differ from the one the application then OPENS, because
knexfile's development block points Postgres at localhost/postgres/photo_sharing
while production uses db/picpeak/picpeak.

1. server.js exported only DATABASE_CLIENT=pg after the resolver decided, so
   knexfile filled in host/user/database from whichever block NODE_ENV selected.
   With SQLite already retired by a migration, that meant opening an empty
   database. The whole connection is pinned now.

2. Two defaults existed for DB_HOST: wait-for-db.sh resolves and exports
   `postgres`, knexfile's production block says `db`. Since the entrypoint
   exports its value, `postgres` is what a running container actually uses — so
   a `docker exec` migration, which inherits neither, has to agree with that,
   not with the default that is only reached when the entrypoint did not run.

3. The migration's Postgres phases inherited an unset NODE_ENV and therefore the
   development block, which ignores DB_SSL entirely — a managed Postgres
   requiring TLS could never be migrated into. The phases run with production
   semantics now.

4. core/001_init.js writes data/ADMIN_CREDENTIALS.txt, and that data directory
   belongs to the SOURCE install. Bootstrapping the Postgres schema replaced the
   operator's real credentials file with ones for a temporary admin the import
   immediately discards. The file is preserved across the phase, including when
   it fails.

5. The boot line described knexConfig, so an install redirected to Postgres by a
   migration marker still logged "Database engine: sqlite (...)", contradicting
   the warning printed one line earlier.

6. On a both-populated conflict resolveBootEngine returns client:null, and both
   migration runners told the operator their data was in "null" and to set
   DATABASE_CLIENT=null. They now present the two real choices.

Verified against a real PostgreSQL 15: a migrated install started directly with
NODE_ENV unset now logs `postgres (localhost:7102/picpeak_r14)` and opens it,
where before it would have gone to the development block's photo_sharing.

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

* refactor(db): resolve the PostgreSQL target in exactly one place (#1038)

Rounds 13 and 14 both traced back to the same thing, each time through a caller
the previous fix had not covered: three different defaults existed for the same
connection.

  knexfile development : localhost / postgres / photo_sharing
  knexfile production  : db        / picpeak  / picpeak
  wait-for-db.sh       : postgres  / picpeak  / picpeak   (and it EXPORTS them)

So a process that probed or migrated against one could hand over to a process
that opened another. Patching each caller was not converging — the guard, then
the CLI's child phases, then server.js — so this deletes the divergence instead.

`src/utils/pgConnection.js` now owns the resolution and knexfile's development
and production blocks both derive from it, as does the engine guard. Same shape
as the earlier sqlitePath.js extraction, for the same reason.

The database NAME is what made this dangerous: a wrong host or user fails
loudly at connect time, while a wrong name connects fine and presents an empty
installation.

BEHAVIOUR CHANGE: with DATABASE_CLIENT=pg and no DB_* variables, a
non-production environment now resolves to postgres/picpeak/picpeak instead of
localhost/postgres/photo_sharing. Deployments are unaffected — compose sets
these explicitly and wait-for-db.sh exports them — but a local machine running
Postgres bare now needs DB_HOST=localhost DB_USER=postgres DB_NAME=photo_sharing
(or DATABASE_CLIENT=sqlite3, which is what backend/.env already uses). The
failure mode of getting this wrong is a refused connection, not a silently empty
database.

Side effect worth having: DB_SSL is now honoured whatever NODE_ENV says, so the
managed-Postgres case is fixed at the root rather than by forcing production
semantics onto the migration's child phases.

The test block keeps its own photo_sharing_test default — isolation is the point
there.

Verified: every block plus the guard resolve identically from the same
environment; explicit DB_* still wins; production's pool tuning is preserved;
and a full SQLite → PostgreSQL migration with NODE_ENV unset lands in the right
database with JSON shapes intact.

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

* fix(db): two more components that guessed the database instead of asking (#1038)

Both found while sweeping for copies of the connection defaults. Checked in
detail first — one of my suspicions about them was wrong.

scripts/set-admin-password.js hand-rolled its own knex config while all four
sibling scripts (reset-admin-password, create-admin, show-admin-credentials,
reset-admin-mfa) use the application's connection. Two consequences:

  - it read DB_CLIENT, a variable nothing else in this codebase sets, so it
    defaulted to Postgres and could not work on a SQLite install at all;
  - it defaulted to database `picpeak_dev`, a name no other component uses.

It now uses `require('../src/database/db')` like its siblings, so it follows
whatever engine the install actually runs on. Timestamps are written as ISO
strings because it reaches SQLite now, where raw Date objects are the documented
landmine.

NOT changed: the script's "all existing sessions have been invalidated" notice
is accurate — auth.js compares token iat against password_changed_at — and it
deliberately leaves must_change_password alone, which is right for an operator
choosing a password rather than being issued one.

routes/adminSystem.js re-derived three things the live connection already knows,
and each could disagree with it:

  - the engine, from DATABASE_CLIENT || 'sqlite3' — so a Postgres install
    without an explicit DATABASE_CLIENT took the SQLite branch;
  - the Postgres database, from DB_NAME || 'picpeak';
  - the SQLite file, from a hardcoded ../../data/photo_sharing.db that ignored
    DATABASE_PATH entirely.

All three now come from db.client.config, with pg_database_size(current_database()).

Verified: set-admin-password works on SQLite (new hash verifies, old rejected)
and still on PostgreSQL; and on a SQLite install with a custom DATABASE_PATH the
size logic reports the real database (1,748,992 bytes) where the old code
reported a different file entirely (1,851,392) — or 0 where that path does not
exist.

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

* fix(db): bind the migration marker to its target; fix a phantom table (#1038)

Review round 15.

1. The marker records `host:port/database`, but only its EXISTENCE was checked.
   Repoint DB_NAME or DB_HOST at a different, empty PostgreSQL after migrating
   and the marker would vouch for that one too — booting it, presenting an empty
   installation, and suppressing the SQLite fallback while the real data sits in
   the recorded target and the renamed rollback copy. The marker is compared
   against the current connection now, and a mismatch stops the boot with both
   targets named and the two ways out.

2. `incoming_invoices` is not a table — supplier documents live in
   `inbound_documents` (core migration 124). Both occupancy lists skip tables
   that do not exist, so those records were silently not protecting anything:
   an install whose only remaining data was inbound documents could be switched
   away from, or overwritten without --force. Verified every other name in the
   lists against the live schema at the same time.

Verified: a marker naming picpeak_original with picpeak_mk configured refuses
with exit 3 and prints both; making them agree boots pg.

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:50:56 +02:00
Paul Nothaft 89dc9623c1 fix(feedback): persist guest feedback settings, unshadow the guest route (#1030) (#1031)
Enabling Guest Feedback on an event could silently do nothing.

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

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

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

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

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

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:50:52 +02:00
Paul Nothaft 34ee31141b fix(gallery): coerce SQLite 0/1 booleans in the guest surface (#1028) (#1034)
SQLite stores booleans as 0/1, Postgres as true/false. The guest gallery
compared strictly against `true`/`false`, so every flag read backwards on
SQLite installs:

    allow_downloads:    0 !== false → true   (header Download button shown
                                              with downloads disabled)
    allow_user_uploads: 1 === true  → false  (upload button hidden with
                                              uploads enabled)

Worse, all five download guards used `allow_downloads === false`, which never
fires against a stored 0 — so on SQLite "Allow photo downloads = off" was
inert end to end: single photo, download-all, download-selected, download-jobs
and the job-status poll all kept serving, as did the secure-images download
route. Per-category blocking (#640) was ignored for the same reason, the
protection toggles (right-click, devtools, canvas, watermark) reported false
while enabled, overlay_protection was stuck on, and show_feedback_to_guests
leaked feedback with the setting off.

The /info endpoint was already correct — it checks 0/'0' explicitly. The two
payloads had simply drifted. Everything now goes through parseBooleanInput
(utils/parsers.js), which normalises both engines and takes a per-column
default so legacy NULL rows keep their documented behaviour.

Tests run on the SQLite harness, so they assert the real engine values. Every
one of them fails on the unfixed code — the payload assertions return the
inverted value, and the guard assertions never get their 403 (the request
proceeds to serve instead).

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

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:50:48 +02:00
Paul Nothaft 671c4dbd56 fix(events): make event_date/expires_at nullable on SQLite (#1029) (#1035)
Clearing a gallery's expiration failed on every SQLite install with

    SQLITE_CONSTRAINT: NOT NULL constraint failed: events.expires_at

surfacing in the admin UI as "Failed to update event".

Migration 061 added the event_require_event_date / event_require_expiration
settings and dropped the NOT NULL on both columns — but only for Postgres. It
skipped SQLite on the premise that "SQLite doesn't enforce NOT NULL as
strictly", which is untrue, so "never expires" was never reachable there. The
#426 work that allows clearing the expiration on edit therefore never worked
on SQLite either.

Migration 174 finishes 061 for SQLite. Knex implements .alter() on SQLite by
recreating the table; migration 073 already does that on `events`, so the path
is well-trodden. Postgres is skipped — it was handled in 061 and .alter() there
would needlessly rewrite a column that is already correct.

The test asserts against the real engine (the Jest harness runs SQLite) and
reproduces the reporter's exact error without the migration.

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

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:50:42 +02:00
Paul Nothaft e2832725ea chore(main): release 3.102.1-beta.0 (#1026)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-11 08:41:17 +00:00
Luca 27dedb13f3 docs: flip README links to docs.picpeak.app + delete docs/_to-migrate (#1000 phase 3) (#1023)
Phase 3 (final) of #1000. The deep content now lives on the docs site (PicPeak/docs#7), making docs.picpeak.app the single source of truth and removing the in-repo copies.

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

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

Closes #1000.
2026-08-11 10:38:35 +02:00
Paul Nothaft 054c342c33 chore(main): release 3.102.0-beta.0 (#1025)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-11 07:49:42 +00:00
Paul Nothaft 8e3573788b feat(downloads): per-gallery download resolutions (#858) (#1022)
Clients who need smaller files no longer make the photographer re-export. Two capabilities, both off by default.

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

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

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

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

Closes #858.
2026-08-11 09:46:46 +02:00
Paul Nothaft 02deac9f10 chore(main): release 3.101.5-beta.0 (#1021)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-10 11:36:23 +00:00
Paul Nothaft 75bfad2b6a fix(slideshow): stop "no crop" fit letterboxing a pre-cropped frame (#1015) (#1018)
The slideshow resolved its image as preview_url || hero_url || url. preview_url is only emitted when lightbox_preview_enabled is on (default false), so a default install fell through to hero_url — the 1920x1080 fit:'cover' centre crop built for gallery header banners. object-fit: contain then letterboxed an already-cropped 16:9 frame, so portrait photos lost their top and bottom and 'Black Bars (No crop)' looked inert.

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

Fixes #1015.
2026-08-10 13:32:14 +02:00
Paul Nothaft 08e0098597 chore(main): release 3.101.4-beta.0 (#1016)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-10 09:03:45 +00:00
Paul Nothaft e3830cd921 fix(deps): bump nanoid and js-yaml out of two HIGH advisories (#1013)
Both are production dependencies of the backend image (npm ci --omit=dev):

- nanoid 3.3.16 -> 3.3.18 (CVE-2026-67213, infinite loop in customAlphabet), transitive via postcss
- js-yaml 4.3.0 -> 4.3.1 (GHSA-5p4m-2wfm-xmqj, quadratic CPU in !!omap resolution), direct dependency

Lockfile-only; the existing ^ ranges already permitted both fixes. Clears the two open Trivy code-scanning alerts on main.
2026-08-10 10:59:53 +02:00
Paul Nothaft 03671f3b91 test(e2e): anchor the admin login button locator so SSO doesn't break it (#1012)
With OIDC enabled the login page also renders a 'Sign in with <provider>' button whose accessible name matches the unanchored /Sign In/ locator, so Playwright strict mode failed every test that logs in — 7 of 13 in the local smoke suite, which is also the pre-push gate. CI never hit it because its databases seed without OIDC config.

Anchors the regex to the full accessible name in all six call sites.
2026-08-10 09:53:18 +02:00
Paul Nothaft 5503e6ca5e chore(main): release 3.101.3-beta.0 (#1011)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-10 07:18:08 +00:00
Paul Nothaft a607cea110 fix(auth): issuer-tag the oversize SSO logout marker (#798) (#1010)
Phase 3 validated a stored ID token hint against the currently configured issuer, but the oversize path never got that check: an ID token above the 3.9KB cookie limit was stored as the bare string 'sso', which collapsed to an undefined hint at logout and skipped validation entirely. Changing the issuer while such a session was live bounced the user to the new IdP on logout.

Stores sso.<base64url(issuer)> instead and moves all marker interpretation into buildEndSessionUrl: raw ID token -> iss/aud-validated hint, issuer-tagged marker -> round-trip without a hint, anything else -> no round-trip. Every branch fails closed.

Refs #798.
2026-08-10 09:14:56 +02:00
Paul Nothaft fbe1d07228 chore(main): release 3.101.2-beta.0 (#1009)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-10 06:30:01 +00:00
Paul Nothaft 1bf19a7caf fix(branding): route the gallery footer through <PoweredBy /> (#1008)
Closes #1003.

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

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

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

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

Four cases cover the variant — span not paragraph, separator present, separator
hidden with the attribution when white-labelled, hidden while loading. Each was
checked against the pre-fix shape: rendering a <p> or moving the separator out
breaks one.
2026-08-10 08:27:28 +02:00
Paul Nothaft 4ec93107a9 chore(main): release 3.101.1-beta.0 (#1007)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-10 06:19:11 +00:00
Luca ddebd50d3f docs: slim README to a lean router, stage deep content for docs-site migration (#1001)
Phase 1 of the README slim / docs-migration plan in #1000.

README goes from 577 to ~191 lines: hero, one Quick Start, a Documentation
index, comparison table, tech stack and a table of contents. The deep inline
prose moves into a temporary docs/_to-migrate/ staging folder (webhooks,
storage backends, first-run setup, system requirements, roadmap) so README
links keep resolving until the docs-site pages are live.

Existing docs/*.md referenced by app code are deliberately left in place —
crm-disclaimers.md (frontend TSX, i18n, a backend route and migration),
fonts.md (server.js), accounting-inbound-invoices.md (Dockerfile) and
migration-to-org.md (UpdateNotification.tsx, MigrationBanner.tsx). Moving them
is a separate, code-touching change.

Verified before merge: merges cleanly against main with no conflicts; all 14
in-repo links resolve in the merged tree; no docs file is deleted or renamed;
and the registry-move notice from #995 survives the rewrite in condensed form,
keeping 'still responds but its tags are frozen at 2026-05-27' plus the
migration-to-org.md link. The fuller symptom explanation remains in that doc,
which the README links to.

Follow-up per #1000: port docs/_to-migrate/* into docs.picpeak.app, then flip
the README links and delete the staging folder.

Co-authored-by: Luca-Timo <Luca-Timo@users.noreply.github.com>
2026-08-10 08:16:13 +02:00
Paul Nothaft 1c242d401f test(transfers): pin the PicTransfer ownership guards (#1006)
Closes #1005.

The two ownership guards added during the #998 review were correct on merge but
untested. They are the only thing between a scoped admin and every other
admin's ORIGINAL files, since a transfer serves those over an unauthenticated
token URL.

14 cases: filterOwnedPhotoIds (own / foreign / ownerless-legacy / mixed /
non-existent / super_admin), addFiles gating on the same rule, listTransfers
scoping plus the absence of token/upload_token/download_url/upload_url from the
list payload, and getTransferOwner.

Each was checked against the pre-fix behaviour rather than only passing against
current code — reverting each guard in turn fails exactly the cases covering it:
ownership filter 3, list scoping 1, payload strip 1, guard registered late 1.

requireTransferOwnership is module-local, so its two contracts are asserted at
the source following the #596 pattern: that router.use('/:id', ...) precedes
every /:id route — ordering is the whole mechanism, and a late registration
would guard nothing while still looking present — and that missing and foreign
ids both answer 404, so the endpoint is not an existence oracle.

Tests only; no production code touched.
2026-08-10 08:10:54 +02:00
Paul Nothaft 80599a5e47 chore(main): release 3.101.0-beta.0 (#1004)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-09 11:43:36 +00:00
Luca 2e495d7c48 feat(transfers): add PicTransfer — cross-event file transfers (#998)
Closes #997.

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

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

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

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

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

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

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

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

Co-authored-by: Luca-Timo <Luca-Timo@users.noreply.github.com>
2026-08-09 13:40:03 +02:00
Paul Nothaft e2d8ec86bd chore(main): release 3.100.2-beta.0 (#1002)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-09 09:19:07 +00:00
Lutthy 3bb4f1a1a8 fix(branding): hide "Powered by PicPeak" on every page, not only the gallery (#999)
branding_hide_powered_by only hid the attribution on the main gallery footer. It
stayed visible on the gallery password screen, client access page, Premium
layout, admin and customer login, accept-invite and CMS pages — AdminLoginPage
rendered it unconditionally with no guard at all, so the setting genuinely did
not apply there.

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

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

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

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

Co-authored-by: lbossuyt <lbossuyt@users.noreply.github.com>
2026-08-09 11:16:18 +02:00
Paul Nothaft 1f224f4ead chore(main): release 3.100.1-beta.0 (#996)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-04 15:33:44 +00:00
Paul Nothaft b9e42591f5 docs: the retired registry path freezes, it does not stop serving (#995)
Closes #985.

README and migration-to-org.md both claimed the old path 'is no longer served'.
It is served — ghcr.io/the-luap/picpeak/backend:latest returns a complete image,
created 2026-05-27, label version: main. The registry responds normally; it just
never receives anything new.

That inaccuracy is what generates reports like #982. Told the path is not
served, an operator runs docker compose pull, watches it succeed, runs docker
rmi and pulls again, watches that succeed too, and concludes the problem lies
somewhere other than their image path. Nothing reports an error anywhere; the
only symptom is an update notice that never resolves.

Say what actually happens — the path freezes rather than failing — and add a
self-diagnosis via docker image inspect on both paths, with the 2026-05-27 date
and the 'main' version label as the tells. MigrationBanner's wording is left
alone: 'no longer being updated' was accurate.

This is the delivery mechanism for #985. There is no in-app channel:
MigrationBanner shipped a month after the freeze, the #993 update-check notice
cannot fire on installs running their own frozen backend, and the changelog
modal that renders release notes shipped two days after the freeze. What reaches
these operators is GitHub, and the GHCR page for the retired package — which
renders this README through the images' own org.opencontainers.image.source
label, so the fix propagates to the dead path's own page automatically.
2026-08-04 17:30:18 +02:00
Paul Nothaft 9dc2b2166e chore(main): release 3.100.0-beta.0 (#994)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-04 14:52:13 +00:00
Luca f00661511c feat(gallery): admin preview skips the password on protected galleries (#981)
Closes #868.

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

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

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

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

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

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

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

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

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

'0.0.0' is excluded from the predicate: that is getCurrentVersion's fallback for
an unreadable package.json, i.e. a broken install, not a pre-rename one.
2026-08-04 16:36:50 +02:00
Paul Nothaft 0c8ad6bbed fix(security): vet the destination project when linking a deal (#991)
linkDealToProject re-points a deal's quotes, contracts and events into
`projectId`. Its lineage guard vets the SOURCE events and its comment assumed
the route had vetted the destination — true only for attachDocumentToProject.
quoteService.create/update and contract crud.create/update take `projectId`
straight from the request body behind quotes.manage / contracts.manage, which
are permissions, not ownership; adminQuotes.js and adminContracts.js carry no
ownership guard at all.

The lineage guard did not cover it: it is skipped when the deal has produced no
event yet, which is the state of a newly created quote, and an unassigned
destination ADOPTS the deal's customer rather than rejecting it.

A scoped admin could therefore write into another admin's project, and on an
OWNERLESS project (created_by IS NULL — legacy rows migration 167 could not
attribute) escalate to a read: once the quote converts to an event it becomes
the project's only linked event, which is the condition ownedProjectsSubquery's
second branch grants ownership on.

Vetted at the service choke point all four callers share, ahead of both the
null-deal early return (callers write project_id before calling, and deal_uuid
is nullable) and the customer check (whose 422 vs 404 was an enumeration
oracle). 404 PROJECT_NOT_FOUND throughout. super_admin unaffected.
2026-08-04 16:36:12 +02:00
Paul Nothaft 083b3d86b0 chore(main): release 3.99.2-beta.0 (#989)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-04 12:40:03 +00:00
Paul Nothaft 6c03feaef5 fix(deps): bump ip-address, brace-expansion and postcss for open CVEs (#987)
Closes Trivy code-scanning alerts #414-#418 on the backend image.

  brace-expansion  5.0.8  -> 5.0.9   CVE-2026-69152 (high)   DoS via unbounded
                                     intermediate arrays
  ip-address       10.2.0 -> 10.4.0  CVE-2026-69192 (high), CVE-2026-54272 and
                                     CVE-2026-69198 (medium) — SSRF and
                                     trust-boundary bypasses. Needs 10.3.1+ to
                                     clear all three.
  postcss          8.5.18 -> 8.5.23  CVE-2026-69153 (medium) information
                                     disclosure via crafted sourceMappingURL

ip-address and brace-expansion were already in overrides but pinned below the
new fixed versions; the floors just needed raising. postcss reaches the image
through sanitize-html — the direct pin is not an import, it forces the
transitive copy to dedupe to a known version, so it moves with the bump.

Only the backend image is affected: the frontend production stage is
nginx:1.30-alpine and ships no node_modules.

Each lockfile now holds exactly one entry per package, all at or above the
fixed version, and the image installs via npm ci --omit=dev so the lockfile is
authoritative.
2026-08-04 14:35:57 +02:00
Paul Nothaft 0ef836df19 chore(main): release 3.99.1-beta.0 (#986)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-04 12:21:08 +00:00
Paul Nothaft 4b53b64277 fix(accounting): gate cross-add counters on the permission their endpoint checks (#984)
Closes #983.

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

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

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

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

No seeded role is affected: migration 123 grants accounting.view and
accounting.manage together, and customers.edit projects forward from
customers.create, which migration 090 always grants alongside customers.view.
2026-08-04 14:17:34 +02:00
Paul Nothaft 83d514315e chore(main): release 3.99.0-beta.0 (#980)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-03 20:06:19 +00:00
Luca 165cebdb5c feat(accounting): re-bill proof attachment, CRM panel & hours↔re-bills cross-add (#979)
Closes #866.

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

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

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

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

Migration 169 is additive, hasColumn-guarded and idempotent.

Review (two rounds) closed two concerns:

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

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

Known follow-up: two cross-add counter queries are gated on a permission their
endpoint does not check (HoursSection.tsx:174, CustomerCrmPanels.tsx:270) —
degrades safely, one line each.
2026-08-03 22:03:31 +02:00
Paul Nothaft d66425c8ee chore(main): release 3.98.6-beta.0 (#978)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-03 12:52:55 +00:00
Paul Nothaft 67592fc569 fix(projects): stop the cockpit offering email controls the API rejects (#976)
Closes #969.

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

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

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

Regression from the GHSA-93x4 fix in #960/#966, which added the ownership middleware.
2026-08-03 14:48:50 +02:00
Paul Nothaft 6699855c93 fix(auth): fail closed when the adminAuth roles join errors (#974)
Closes #968.

The roles-join fallback in adminAuth fabricated role_name='super_admin' on ANY database error, so a transient fault (connection reset, deadlock, statement timeout, pool exhaustion) silently granted super_admin for its duration. roleName is the sole discriminator for every ownership check, so this inverted the authorization model rather than failing the request.

Gate the fallback on isMissingRolesSchema(), moved to utils/dbErrors.js and shared with apiTokenAuth. The predicate was also tightened: knex prefixes the failing SQL to err.message and that SQL always names `roles`, so the old /roles/i gate was vacuous and a generic /does not exist/ could accept unrelated faults. Now trusts SQLSTATE 42P01/42703 on Postgres and exact driver phrasing on SQLite.
2026-08-03 14:48:20 +02:00
Paul Nothaft 569ae39acb chore(main): release 3.98.5-beta.0 (#973)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-02 19:27:37 +00:00
Paul Nothaft 7c0c0a5b7f fix(security): enforce project ownership on project + project-email routes (GHSA-wrg5, GHSA-93x4) (#960)
* fix(security): enforce project ownership (GHSA-wrg5, GHSA-93x4)

Project routes authorized on generic events.view / events.edit with NO
ownership check, so an editor-like admin could enumerate, read, update and
aggregate projects belonging to other admins' events. The project email
endpoints keyed on an email_queue id alone — any admin with events.view /
email.send could preview, resend, cancel or retry ANY queued mail by walking
ids.

The earlier 'needs a migration, deferred' assessment was wrong in one
direction and right in another: ownership IS derivable transitively via
events.project_id -> events.created_by, but only for projects that already
have a linked event. A brand-new EMPTY project has no derivable owner, which
is exactly where the create -> attach flow starts. So migration 167 adds
projects.created_by (backfilled from the single linked event owner, skipping
ambiguous multi-owner projects) and createProject finally persists the adminId
it was already being passed.

- ownedProjectIds(): union of the stored owner and the transitive path, so
  pre-167 rows and new empty projects both resolve. Reads created_by
  defensively so an instance that hasn't run 167 falls back to the transitive
  rule instead of throwing.
- requireProjectOwnership on detail/update/attach-event/attach-quote/
  attach-contract/overview; list filtered by an id allowlist (empty array
  means 'owns nothing' and must return no rows, hence null-vs-[] care).
- POST /:id/events also validates the INCOMING eventId — owning the project
  is not enough, or an editor could pull a foreign event in and read its
  rolled-up documents via /:id/overview.
- Queued-email routes scoped via email_queue.event_id. CRM document mail has
  event_id NULL and no ownable parent here, so a scoped caller is denied
  rather than guessed into access. 404 (not 403) so it isn't an id oracle.

Note: adminEmail.js:315/332 let any email.view/edit admin archive or delete
any email_queue row — the same class, pre-existing and outside these two
advisories. Left untouched and reported rather than silently widened.

* fix(security): codex round 2 — make the stored project owner authoritative (GHSA-wrg5)

The first predicate union'd 'any linked event I can see' with the stored
owner, which opened two holes:

- A project owned by admin B containing ONE legacy ownerless event became
  readable by every admin — and /:id/overview aggregates B's other events,
  invoices and emails, so a single legacy event exposed the whole project.
- Migration 167 deliberately leaves multi-owner (ambiguous) projects NULL
  rather than guessing an owner. A NULL owner was then treated as
  'everyone's', so exactly those mixed projects became globally accessible.

Now: the stored created_by wins outright, and a project without a usable
stored owner only derives access when EVERY linked event is accessible (and at
least one exists). A created_by pointing at a hard-deleted admin degrades to
'no usable owner' so the project falls back to its events instead of being
locked away — no ON DELETE SET NULL migration needed. A project with neither a
usable owner nor linked events stays super_admin-only: failing closed beats
failing open, and a super_admin can reassign it.

Also returns a knex SUBQUERY rather than a materialised id list, so a large
project count can't hit the driver's bind-parameter limit.

* fix(security): codex round 3 — enforce deal-lineage ownership on project attach (GHSA-wrg5)

requireProjectOwnership vets only the DESTINATION project, while attaching a
quote or contract cascades through linkDealToProject — which re-points every
event the deal produced into that project. An editor could therefore create an
empty project of their own, attach another admin's quote, and pull that admin's
events (plus the invoices, emails and gallery that roll up with them) into a
project they own and can read via /:id/overview. The single-customer guard did
not stand in the way: an unassigned project ADOPTS the deal's customer rather
than rejecting it.

linkDealToProject now refuses to move lineage events the actor cannot own, and
assignDocument cascades BEFORE stamping the document so a refused attach leaves
nothing half-applied (the old order committed the foreign document into the
caller's project and only then declined the cascade). The quote/contract
create+update paths, which reach the same cascade with an arbitrary project_id,
thread their adminId through as well; isSuperAdmin() resolves the role for them
and fails closed when it cannot.

Events are the only ownership signal a deal carries — quotes and contracts have
no created_by in this schema — so a lineage that produced no event still cannot
be attributed. That is a property of the CRM model, noted in the code.

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

* docs(security): drop the stale ownership JSDoc left by the rebase (GHSA-wrg5)

Rebasing onto main (which had gained scopeEventsQuery from #957) replayed the
round-1 doc block above round-2's replacement, leaving a comment that describes
the ORIGINAL union rule — "a project is the caller's when … it has at least one
linked event they own" — directly above the code that deliberately no longer
does that. That union is the hole round 2 closed; a comment asserting it is
worse than none.

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:24:38 +02:00
Paul Nothaft 3fc6463873 chore(main): release 3.98.4-beta.0 (#971)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-02 19:22:22 +00:00
Paul Nothaft 164129b8f5 fix(security): escape brand tokens, block tracker redirects, trim logo diagnostic (GHSA-j347, mw76, 29vm) (#961)
* fix(security): escape brand tokens, block tracker redirects, trim logo diagnostic (GHSA-j347, mw76, 29vm)

GHSA-j347 — buildCachedPayload sanitizes the operator's HTML and THEN runs
applyBrandTokens over the result with a plain String.replace, so any markup in
a token value reached the public origin unfiltered. The default templates
interpolate tokens into text AND into quoted attributes
(<img src="{{brand_logo_url}}" alt="{{company_name}} logo">,
href="mailto:{{support_email}}"), so a value could close the attribute and
inject. Token values are now HTML-escaped on substitution, mirroring
galleryOgService's escapeHtml. sanitizeBrandUrl's case-sensitive literal
'javascript:' check (which 'JavaScript:' walked straight past) is replaced by
an http/https scheme allowlist; relative logo paths are unaffected.

Writer is settings.edit (super_admin only) and the CSP blocks inline script,
so this is defence-in-depth — but sanitize-then-substitute is a real ordering
bug regardless.

GHSA-mw76 — the SSRF decline STANDS: self-hosted operators legitimately point
analytics at private addresses, so connection-time IP blocking would break real
deployments. Fixed only the narrow leak: undici strips
Authorization/Cookie/Proxy-Authorization/Host across a cross-origin redirect,
but umamiAdapter sends a CUSTOM x-umami-api-key header, which would be replayed
verbatim to the redirect target. Both adapters now use redirect: 'error'.

GHSA-29vm — the logo diagnostic echoed absolute storage roots, process.cwd()
and absolute candidate paths. It now reports candidates relative to
<STORAGE>/<CWD_STORAGE>, which answers the same 'which candidate existed'
question. It also still advertised the raw-absolute candidate that GHSA-c7x5
removed from resolveLogoFile, so it was misreporting what the resolver tries —
aligned with the real candidate list.

publicSiteService.test.js expectation updated: an '&' in a company name is now
emitted as '&amp;'. Renders identically; the raw payload string differs.

* fix(security): codex round 2 — stop the remaining logo-path disclosure, mirror the resolver (GHSA-29vm)

- sources[].value was still echoed verbatim. branding_logo_path is stored
  ABSOLUTE by multer, so relativising only resolvedTo and the candidate paths
  left the filesystem layout going out anyway. It is now relativised too.
- Round 1 dropped the raw-absolute candidate on the grounds that GHSA-c7x5
  removed it from resolveLogoFile — but the c7x5 follow-up RE-ADDED it (kept,
  subject to the containment filter, so a legitimate multer path still
  resolves). The diagnostic therefore reported every candidate as missing for
  a contained absolute logo while resolvedTo named the file. It now mirrors the
  resolver, containment filter included.

One deliberate cosmetic divergence, commented in place: for an absolute value
the resolver also tries path.join(root, value-minus-leading-slash), which can
never exist and would re-embed the absolute path this endpoint must stop
echoing. Omitted; every candidate that can actually match is still shown.

* fix(security): codex round 3 — mirror the resolver for root-relative logo paths (GHSA-29vm)

The logo diagnostic skipped the `<STORAGE>/<value>` candidates whenever
path.isAbsolute(value) was true. That test cannot distinguish a multer disk
path from a root-relative URL such as `/custom/logo.png`, and for the URL form
resolveLogoFile.generateCandidates() does try `<STORAGE>/custom/logo.png` and
can resolve it — so the endpoint reported "no source candidate exists" about a
logo that renders fine, and collapsed the configured value to its basename.

The stripped joins are now built unconditionally, exactly as the resolver does.
Disclosure stays closed: every candidate still passes the containment filter and
redact() rewrites survivors to `<STORAGE>/…`, never an absolute host path.

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

* fix(security): gate the logo stripped-joins on containment, not isAbsolute (GHSA-29vm)

The previous commit dropped the isAbsolute() gate entirely and regressed
logoDiagnostic's own disclosure assertion: for a genuine multer disk path,
path.join(root, value-minus-leading-slash) yields
`<STORAGE>/tmp/…/storage/custom/logo.png`, and redact() only rewrites the
LEADING root — so the inner absolute path went straight back into the payload.

The right discriminator is not "is this absolute" (which cannot separate a disk
path from a root-relative URL) but "does the value already resolve inside a
storage root". If it does, it is a real disk path, the raw candidate already
covers it, and the stripped join is the double-prefixed junk that can never
exist. If it does not — the `/custom/logo.png` URL form — the stripped join is
exactly what resolveLogoFile resolves, and is shown.

Covered by a new case asserting both halves: the candidate appears for the URL
form, and the payload still contains neither the storage root nor cwd.

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:17:17 +02:00
Paul Nothaft e2ce95ee48 fix(security): enforce event ownership on the v1 API surface (GHSA-9697) (#957)
* fix(security): enforce event ownership on the v1 API surface (GHSA-9697)

Migration 081 documents the intent — 'the token's effective permissions are
the intersection of the user's role permissions and the token's own scope
flags' — but it was never implemented.

- apiTokenAuth selected only id/username/email/role_id, so req.admin.roleName
  was undefined. Every ownership helper keys on roleName, so the v1 surface
  could not tell a super_admin from a demoted viewer. Now joins roles and
  emits the same req.admin shape adminAuth does, including the
  roles-table-missing upgrade fallback.
- No v1 route applied any ownership predicate: GET /events listed every event
  on the instance, and GET /events/:id/share-link returned ANY event's
  share_token — the gallery access credential, same class as GHSA-rh8r.
  List is now scoped via a new scopeEventsQuery helper; the three :id routes
  (detail, photo upload, share-link) use the existing requireEventOwnership.

Not a breaking change: tokens are minted by super_admins, who bypass
ownership. It closes the case where a token's owner is later demoted —
userManagementService never touches api_tokens, so the token outlived the
demotion with full read of every gallery's share token.

events.category.test.js stubbed apiTokenAuth without roleName; giving the
stub super_admin keeps requireEventOwnership from issuing a DB query and
desyncing that suite's sequenced dbMock.

* fix(security): codex round 2 — intersect v1 token scopes with role permissions (GHSA-9697)

Ownership scoping alone left half the documented control missing. Migration
081 defines a token's effective permissions as the INTERSECTION of the owner's
role permissions and the token's scope flags; requireApiScope only ever checked
the scope half. A token minted while its owner was super_admin therefore kept
write access after the owner was demoted to viewer — userManagementService
never touches api_tokens, so the token outlives the demotion, and ownership
scoping does not help because the demoted owner still owns their events.

Adds requirePermission to all six v1 routes (events.create on create,
events.view on the reads, photos.upload on upload). It keys on req.admin.id,
which apiTokenAuth already populates.

The two existing v1 suites mock the database, so a real permission lookup
500s — they now mock the permissions middleware as pass-through, matching how
they already mock apiTokenAuth. Those suites cover route logic; the
intersection is pinned by the new v1TokenPermissions suite.

* fix(security): codex round 3 — fail closed on the roles-join fallback (GHSA-9697)

The round-2 fix loaded the token owner's role so the v1 ownership checks could
tell a super_admin from a demoted viewer, and mirrored adminAuth's
roles-table-missing fallback. That fallback assigns role_name = 'super_admin',
and the catch around it was unconditional — so ANY failure of the joined query
(connection reset, deadlock, statement timeout) elevated the token owner to
super_admin as long as the simpler fallback query then succeeded. A restricted
owner could ride that into listing, reading and share-tokening every event on
the instance, which is the exact hole GHSA-9697 closes.

The fallback is now reached only for an error that genuinely names a missing
roles table/column (PG 42P01/42703 or the SQLite/MySQL wording); anything else
propagates to the 500 handler.

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:17:14 +02:00
Paul Nothaft 1b4e5fee3e fix(security): bound inbound-mail resources, redact secrets from logs (GHSA-2qf9, pgmp, r794) (#959)
* fix(security): bound inbound-mail resources, redact secrets from logs (GHSA-2qf9, pgmp, r794)

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:17:11 +02:00
Paul Nothaft da855cfef9 fix(security): scope dashboard stats/analytics/activity to the caller's events (GHSA-c2jj, gqx7, jhcf) (#958)
* fix(security): scope dashboard endpoints to the caller's events (GHSA-c2jj, gqx7, jhcf)

/dashboard/stats, /analytics and /activity are gated only by analytics.view,
which the editor role holds — but the events LIST restricts editors to their
own rows (adminEvents/crud.js: roleName === 'editor' -> created_by =
admin.id). So an editor saw instance-wide totals, and via /analytics
topGalleries other admins' gallery NAMES and SLUGS (the public gallery URL
component), for events invisible to them everywhere else.

- stats: all 10 aggregates scoped (events by id, photos/access_logs by
  event_id).
- analytics: all 8 series/aggregates scoped, including topGalleries. The
  external tracker device breakdown reports instance-wide data with no event
  filter, so a scoped caller falls through to the access_logs heuristic
  instead, which IS scoped.
- activity: feed scoped. activity_logs.event_id is nullable and the join is a
  leftJoin, so system-level rows (logins, settings changes) are deliberately
  excluded for a scoped caller — those are precisely the cross-admin actions
  the advisory is about.

Scoping keys on 'editor' to mirror the events list exactly, so the admin
role's dashboard is unchanged. filterOwnedEventIds uses the broader
'!== super_admin' rule; the two conventions disagree in this codebase and
matching the list is the no-regression choice.

* fix(security): codex round 2 — fix activity misattribution, scope via subquery (GHSA-jhcf, c2jj, gqx7)

- expenseService passed adminId as logActivity's THIRD positional parameter,
  which is eventId — so admin ids were being written into
  activity_logs.event_id. The /activity scoping filter trusts that column, and
  admin/event id sequences overlap, so a foreign admin's expense metadata could
  surface under an editor's event. All 11 calls now pass null for eventId and
  the admin as the actor, which is what they meant.
- Dashboard scoping now uses a SUBQUERY instead of pluck()+whereIn. An editor
  owning more events than the driver's bind-parameter limit (~999 SQLite,
  65535 Postgres) would have turned all three endpoints into 500s once each id
  became a placeholder; below the limit it still re-sent the full list for each
  of the ~10 aggregates per request.

Note: two billInboundNow() calls also end in ', adminId)' but have an unrelated
signature — verified untouched.

* fix(security): codex round 3 — correct legacy accounting activity rows (GHSA-jhcf)

expenseService called logActivity(type, metadata, adminId), but logActivity's
third positional parameter is eventId. Every expense / incoming-invoice entry
therefore stored the ACTING ADMIN'S ID in activity_logs.event_id.

Round 2 scoped the activity feed with
`WHERE activity_logs.event_id IN (SELECT id FROM events WHERE created_by = me)`,
which does nothing about the rows already on disk. Admin ids and event ids are
small integers from the same range, so on any upgraded instance an editor who
owns the event whose id happens to equal another admin's id is served that
admin's accounting activity, verbatim metadata included — GHSA-jhcf, still
live. Migration 168 re-attributes those rows (event_id holds exactly the actor
id that was lost) and then clears event_id so the scope predicate can no longer
match them. All ten activity types are emitted by expenseService and nothing
else, so no row with a genuine event_id is touched.

Also: the round-2 rewrite passed `{ type: 'admin', id: adminId }`
unconditionally, which stored actor_type='admin' with a null id for the
automated mailbox intake (emailIntakeService calls recordInboundDocument with
no adminId). adminActor() restores 'system' attribution for those.

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:17:08 +02:00
Paul Nothaft 0d4c30884e fix(security): backup/restore hardening — public-dir DB dump, restore path allowlist, gunzip bound, manifest keying (#956)
* fix(security): stop caller-chosen database backup destination (GHSA-jw8m)

POST /api/admin/database-backup/backup forwarded req.body straight into
databaseBackupService.backup(), which merges options over config:
  const { destinationPath = '/backup/database', ... } = { ...config, ...options }

destinationPath is not a persistable setting — the /config allowlist only
accepts database_backup_* keys — so the request body was its only source.
The built-in `admin` role holds backup.create but neither settings.edit nor
backup.restore, so it could aim a full DB dump (admin bcrypt hashes, gallery
password hashes, encrypted SMTP creds) at the PUBLIC /uploads static mount
(server.js mounts it with no auth middleware) and then fetch it
unauthenticated. Filed low; it is a privilege escalation to unauthenticated
disclosure.

Forward only the real knobs, and only when present so absent keys can't
override config defaults via spread.

* fix(security): backup/restore hardening — restore path allowlist, gunzip bound, manifest checksum keying (GHSA-fw4c, h652, hgp8)

- adminRestore /validate + /start: constrain caller-supplied source and
  manifestPath to the operator-configured backup roots — the SAME set the
  restore wizard discovers from — so disaster recovery from a rescued mount
  still works, with RESTORE_ALLOWED_ROOTS as an escape hatch (GHSA-fw4c).
- restoreService.decompressFile: bound the EXPANDED size and abort the
  pipeline when exceeded; default 50 GB, RESTORE_MAX_DECOMPRESSED_BYTES
  overrides (GHSA-h652).
- backupManifest: BACKUP_MANIFEST_KEY upgrades new manifests to a keyed
  HMAC (GHSA-hgp8). Deliberately opt-in and verify-if-present — the key
  cannot live in the database because the database is inside the backup, so
  a mandatory HMAC would lock operators out of the exact disaster-recovery
  case this exists for.

Also fixes a pre-existing bug found while testing hgp8: the checksum passed
Object.keys().sort() as JSON.stringify's second argument, which is an array
REPLACER (a property allowlist applied at every depth), not a key sorter. All
nested keys — path, size, per-file checksum — were dropped before hashing, so
the file list sat outside the integrity check entirely and a manifest path
could be rewritten to ../../etc/passwd without disturbing the digest. Now
hashes a recursively-canonicalized copy, with the legacy serialization
accepted on validation so existing backups stay restorable.

* fix(security): codex round 2 — unbreak the restore wizard, share checksum verification, guard downgrades

- adminRestore: `source` is usually a SOURCE TYPE ('local'|'s3'|'upload'),
  not a path — restoreService branches on those literals. The containment
  check treated it as a path, so path.resolve('local') fell outside the
  backup roots and BOTH /validate and /start returned 400, blocking every
  normal restore. Type tokens are now excluded from the path check.
- backupManifest: extracted verifyManifestChecksum() as the single source of
  truth for the legacy/keyed fallbacks. restoreService.performPreRestoreValidation
  recomputed the digest itself with the default canonical+keyed settings,
  which rejected EVERY backup written before this batch. It now delegates.
- backupManifest: guard the algorithm downgrade — with a key configured, an
  attacker able to rewrite the backup store could strip checksum_algorithm,
  edit the manifest and recompute a plain SHA-256 that verified. Opt-in via
  BACKUP_MANIFEST_REQUIRE_KEYED so pre-key backups keep restoring by default.

* fix(security): codex round 3 — close two manifest-verification fail-opens (GHSA-hgp8)

verifyManifestChecksum returned valid for a manifest with no
verification.total_checksum at all, and restoreService only called it when
that field was present. Deleting the field was therefore a complete bypass of
the keying work: no digest check, no downgrade guard, no
BACKUP_MANIFEST_REQUIRE_KEYED. Every manifest this codebase writes stamps the
field, so an absent one now fails validation, and the call site invokes the
verifier unconditionally.

Second fail-open: the strict-mode rejection of an unkeyed manifest was gated on
`&& key`, so with BACKUP_MANIFEST_REQUIRE_KEYED=true and no BACKUP_MANIFEST_KEY
configured a plain SHA-256 manifest sailed through. Strict mode is a statement
about the operator's manifests, not about the host — it is exactly the fresh
disaster-recovery box that lacks the secret. The rejection no longer depends on
a key being present.

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:17:05 +02:00
Paul Nothaft acd6b453d1 chore(main): release 3.98.3-beta.0 (#954)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-02 20:25:32 +02:00
Paul Nothaft 1c8f7d58a8 fix(security): redact gallery share tokens from analytics tracking (GHSA-7m6c) (#952)
* fix(security): redact gallery share tokens from analytics page-view tracking (GHSA-7m6c)

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:38:33 +02:00
Paul Nothaft c2ce12c039 fix(security): authz/ownership gaps (token binding, auth revocation, feedback/customer ownership, token logging) (#950)
* fix(security): close authz/ownership gaps (secure-download binding, photo-auth+logout revocation, feedback/customer ownership, token logging)

* fix(security): codex round-1 — complete admin-token invalidation + preserve foreign assignments

- photoAuth: mirror adminAuth's active-admin lookup + iat<password_changed_at
  check in the admin branch, so a deactivated admin or a pre-password-change
  token can no longer fetch every photo (GHSA-x55x was only revoke+cutoff).
- adminAuth logout: revoke req.token (the token adminAuth authenticated with,
  cookie OR header) instead of header-only, and clear the auth cookie — a
  cookie-based logout previously left the JWT live (GHSA-cjqh).
- adminCustomers PUT /:id/events: preserve the customer's existing
  assignments to events the caller does NOT own, so a restricted admin can't
  revoke another admin's customer-event links via full-list replacement.

* fix(security): codex round-2 — don't 403 legit restricted-admin assignment edits

The Manage-galleries dialog submits the full initial assignment list, so a
restricted admin editing a customer that already has a foreign assignment hit
the denied.length 403 before the preservation logic ran. Reject only
NEWLY-supplied foreign/nonexistent ids; retain foreign ids the customer is
already assigned to (they can't be added or removed by a non-owner).

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:38:24 +02:00
Paul Nothaft 8f91c2ca99 fix(security): neutralize spreadsheet formulas in all CSV/export cell-writers (CSV injection cluster) (#948)
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:37:56 +02:00
Paul Nothaft 9050affd8d fix(security): unauth share_token leak (HIGH) + restore path-traversal, logo file-read, branding path keys (#946)
* fix(security): stop unauth share_token leak + block restore path-traversal, logo-path file read, branding path keys

* test: update resolveLogoFile for the c7x5 containment (reject outside-storage absolute paths, keep inside)

* fix(security): codex round-1 — escape LIKE wildcards in share-link resolve, keep in-storage absolute logos, guard restore verification

- shareLinkService: escape %/_ in the link_partial LIKE fallback so an
  anonymous /resolve/____… wildcard can't match an arbitrary share_link and
  leak its bearer token (reopened GHSA-rh8r). Explicit ESCAPE for SQLite.
- resolveLogoFile: re-add the raw absolute candidate but keep it subject to
  the storage-root containment filter (GHSA-c7x5) so legit in-storage
  absolute logos resolve while /etc/passwd stays rejected.
- restoreService: apply the same pathEscapes guard in post-restore
  verification so a skipped traversal entry isn't fs.access'd/hashed.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:37:48 +02:00
Paul Nothaft 8cbb37310b chore(main): release 3.98.2-beta.0 (#945)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-01 17:37:57 +02:00
Paul Nothaft 82d68711cf fix(security): close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs) (#943)
* fix(security): close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs)

* fix(security): block archive columns in event mass-assignment per review

* fix(security): comprehensive event mass-assignment denylist + deal-cascade cross-domain permission gate (codex r2)

* fix(security): case-insensitive complete event denylist + project_id + empty-update no-op (codex r3)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 17:36:27 +02:00
Paul Nothaft b7005692b3 fix(security): resolve DNS before vetting external hostnames (SSRF cluster) (#941)
* fix(security): resolve DNS before vetting external hostnames (SSRF cluster)

* fix(security): harden SSRF fix per review (rsync backup path, S3 config-save, webhook transient-DNS retry)

* fix(security): S3 endpoint validation on any endpoint update + no-connect on unresolved webhook host (codex r2)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 17:36:21 +02:00
Paul Nothaft 8a87c9274b fix(security): block guest access to hidden/client-only photos across bulk + secure routes (#939)
* fix(security): block guest access to hidden/client-only photos across bulk + secure routes

* fix(security): harden hidden-photo fix per review (stale ZIP cache, legacy token mint, SQLite bool, client rebuild)

* fix(security): invalidate ZIP cache on photo visibility/category change (codex r2)

* fix(security): recheck photo visibility at signed/secure serve time (TOCTOU) + invalidate ZIP on client visibility change (codex r3)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 17:36:15 +02:00
Paul Nothaft fe615c82e4 fix(security): bump sanitize-html to 2.17.5 (CVE-2026-53606) (#937)
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 17:36:09 +02:00
Paul Nothaft cf37ad5389 chore(main): release 3.98.1-beta.0 (#935)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-01 10:32:31 +00:00
Paul Nothaft defeae9634 fix(uploads): prevent cross-photo contamination from filename collisions and non-atomic writes (#931) (#933)
* fix(uploads): prevent cross-photo contamination from filename collisions and non-atomic writes (#931)

* test: pin the suffixed photo filename format in the NFD pipeline suite (#931)

* test: make the suffix-uniqueness check deterministic-in-practice (#931)

* fix(uploads): widen the anti-collision suffix to 48 bits (#931)

* fix(uploads): hide staging files from list() + share one watermark limiter process-wide (#931)

* fix(uploads): reclaim orphaned staging files + revalidate watermark settings in queued jobs (#931)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 12:29:12 +02:00
Paul Nothaft 2581f4af70 chore(main): release 3.98.0-beta.0 (#930)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-31 09:56:00 +02:00
Paul Nothaft 3bcded78a4 feat(gallery): multi-select feedback filters + sort direction controls (#889) (#929)
* feat(gallery): multi-select feedback filters + sort direction controls (#889)

* fix(gallery): keep mobile sidebar open while combining feedback filters (#889)

* fix(gallery): generic sort icon when direction is uncontrolled (#889)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-31 08:57:08 +02:00
Paul Nothaft 08ff9f20e7 feat(gallery): per-event toggle to hide the logo on the password page (#894) (#928)
* feat(gallery): per-event toggle to hide the logo on the password page (#894)

* fix(admin): harden login_logo_visible coercion for SQLite + string booleans (#894)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-31 08:56:48 +02:00
Paul Nothaft 926a4a540d feat(gallery): mouse-wheel zoom at cursor in the lightbox (#885) (#927)
* feat(gallery): mouse-wheel zoom at cursor in the lightbox (#885)

* fix(gallery): chain rapid wheel events synchronously + handle page-mode deltas (#885)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-31 08:56:29 +02:00
Paul Nothaft 39d397c086 chore(main): release 3.97.6-beta.0 (#926)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-30 12:28:18 +00:00
Paul Nothaft 03087c798c fix(security): close GHSA-g94x (cross-gallery photo read) + GHSA-pv6w (admin DB export) (#924)
* fix(security): close two access-control advisories (GHSA-g94x, GHSA-pv6w)

GHSA-g94x-8vv8-3c9f (HIGH) — the secure-image VIEW route
(/secure-images/:slug/secure/:photoId/:token) validated only the token
signature and took the gallery/photo from the URL, so a token minted on
any PUBLIC gallery read every other gallery's photos with no password
(its download sibling has verifyGalleryAccess; the view route can't —
it serves via <img src> with no header). Bind the token to its scope
instead: the URL photoId must equal the token's minted photoId (photos
belong to exactly one gallery, and minting is gallery-scoped), and the
gallery embedded in the token's sessionId must equal the URL gallery.

GHSA-pv6w-rj34-wj9v (MEDIUM) — GET /admin/backup/picpeak/export dumps
every table unredacted (bcrypt hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3
secrets) and was gated only by backup.create, which the built-in admin
role holds. Gate it behind super_admin, matching the restore side
(backup.restore, already admin-denied) and the masked config APIs.

Regression tests pin both: cross-gallery token reads 403 (photo and
gallery checks), backup export 403 for admin / passes for super_admin.

* test: stub requireSuperAdmin in the backup masking mock

adminBackup now calls requireSuperAdmin() at load (GHSA-pv6w export
gate), and backupSecretMasking mocks the permissions module — add the
new function to the mock so the module loads.

* fix(security): review follow-ups on the export gate (GHSA-pv6w)

- test: place the mocked export in its own mkdtemp dir. The route
  recursively deletes path.dirname(filePath) after download, so a stub
  in bare os.tmpdir() made the super_admin test wipe the whole temp
  root — other jest workers' DB files included (latent CI flake).
- ui: hide PicpeakExportCard from non-super_admins. The role keeps
  settings.view + backup.create, so after the gate its Download button
  always 403'd with a generic toast; gate the card on role super_admin
  to match the endpoint.

* fix(security): keep the token-mismatch audit values within varchar(20) (GHSA-g94x review)

image_access_logs.access_type is varchar(20) (migration 038), but
'token_gallery_mismatch' is 22 chars — on Postgres the audit write
threw value-too-long and logImageAccess swallowed it, so the security
event went unrecorded (the 403 still fired; log is best-effort). Shorten
to 'photo_mismatch' / 'gallery_mismatch' (14/16).

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 14:24:28 +02:00
Paul Nothaft 342dde3589 chore(main): release 3.97.5-beta.0 (#922)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-30 10:19:02 +00:00
Paul Nothaft 252475fce2 fix(admin): code-review follow-ups on #910/#916 (MIME resolver + expiry reactivity) (#921)
* fix(admin): own-property lookup in the extension MIME map (#908 review round)

A client-controlled filename ending in .constructor / .__proto__ /
.toString made EXTENSION_TO_MIME[ext] return an inherited Object.prototype
member (truthy), and the downstream extMime.startsWith threw —
a permanent 500 on the admin view for that photo instead of the JPEG /
mp4 fallback. hasOwnProperty-gated now; test pins both a .constructor
image and a .__proto__ video.

* fix(admin): drop already-expired events from the dashboard card (#909 review round)

The expiring-soon card ran Math.max(1, ceil(delta)), so an event that
expired while the dashboard sat open (its query isn't polled) showed
'1 day left' indefinitely from the stale cached row. Expired rows are
now filtered out before render; the delta is therefore always positive
and the clamp is gone.

* fix(admin): honor safe stored image MIME for auto-imported formats (#908 review round 2)

My previous round made the image side map-only to dodge the migration
039 image/jpeg backfill and image/svg+xml — but that regressed the S3
auto-importer (STORAGE_AUTO_IMPORT), which stores correct types for
avif/bmp/tiff/heic whose extensions aren't in EXTENSION_TO_MIME. Those
now served as image/jpeg (JPEG-labelled non-JPEG bytes).

Precedence is now mapped-extension (still corrects the 039 backfill on
PNGs) -> stored MIME IF in a safe raster allowlist (avif/bmp/tiff/heic
+ the mapped ones) -> image/jpeg. Allowlist, not a regex: image/svg+xml
stays excluded (scriptable inline). Tests pin avif preserved and svg
degraded to jpeg.

* fix(admin): refresh expiry status live at the boundary (#909 review round 2)

Two review findings on the admin expiry surfaces:

- The dashboard 'expiring soon' card, list badges, and detail banner are
  all computed inline from Date.now() at render, so a page left open
  across an event's expiry kept showing 'active'/'1 day left' until an
  unrelated render — which for editor/viewer roles (no health poll)
  never happens.
- My round-1 client-side filter on the dashboard desynced the visible
  list from the cached total/stat ('no events expiring' beside 'view
  all N').

Both are fixed by new useExpiryRefresh: it fires once at the soonest
future expiry (setTimeout, overflow-guarded). The dashboard refetches
its expiring + stats queries — the backend already excludes expired
events, so rows/total/stats come back consistent (filter removed). The
list and detail pages bump a tick so the inline badges recompute. Hooks
are placed above the loading early-returns (rules-of-hooks is disabled
in eslint, so this was a latent crash otherwise).

* fix(admin): allow any header-safe raster MIME, deny svg/xml (#908 review round 3)

The round-2 hand-listed Set kept missing formats the S3 auto-importer
stores (apng/ico/jxl beyond avif/bmp/tiff). Replace it with a regex:
honor image/<token> EXCEPT the scriptable svg / *+xml family. Covers
every current and future raster type in one rule while still blocking
inline-scriptable svg and header injection. Tests pin apng + x-icon
preserved, svg still degraded to jpeg.

* fix(admin): expiry-refresh precision + filtered refetch (#909 review round 3)

Three refinements to round-2's live-expiry work:

- useExpiryRefresh now re-arms past setTimeout's ~24.8-day overflow
  limit (capped wake-up that re-evaluates) instead of dropping the timer,
  so a page mounted for weeks still updates.
- The dashboard requests the expiring list ordered by expires_at asc, so
  the five shown rows ARE the soonest to expire — the timer schedules
  against the true next boundary even when >5 events are expiring
  (getEvents gains optional sortBy/sortOrder; backend already whitelists
  expires_at).
- EventsListPage refetches instead of only re-rendering at the boundary:
  under the 'expiring' filter the backend drops expired rows, so a plain
  tick would leave a stale 'Expired' row + total. refetch keeps rows and
  totals correct under every filter.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:14:41 +02:00
Paul Nothaft fbc18a386b ci: batch stable releases into one daily version (#919)
* ci: batch stable releases into one daily version

The stable release PR was auto-merged the instant it went green, so a
day with N bugfixes produced N patch releases (3.45.8 AND 3.45.9 on
2026-07-29 alone) — N upgrade notifications for stable users and N
full Docker build cycles.

Fixes now accumulate in release-please's rolling release PR and are
cut as ONE version per day by release-stable-daily.yml (18:00 UTC).
Approval/merge mechanics are unchanged from the inline step (#719):
approve as github-actions[bot], auto-merge as the PAT so the merge
triggers the tag-cutting run.

- Urgent fix? workflow_dispatch the daily job or merge the release PR
  by hand — the schedule is a default, not a gate.
- Beta is untouched: instant beta releases are load-bearing for
  same-day reporter verification.
- schedule only fires from the default branch; the stable copy of the
  new workflow is inert and exists to keep branches in sync.

* ci: harden the daily stable-release cut (review round)

- P1: the daily job runs on a schedule, so a fork PR can spoof the head
  branch name 'release-please--branches--stable' — gh --head matches the
  name only. Pin --base stable AND require isCrossRepository == false so
  a fork PR can never be approved+auto-merged with the release PAT.
- P2: this scheduled job is now the ONLY automatic stable cut, so the
  auto-merge-enable step no longer swallows failures (|| true); it fails
  loudly and verifies autoMergeRequest is actually set. A silently
  expired PAT would otherwise stop releases while the workflow stays
  green. Approve stays tolerant (re-approval can return non-zero).

* ci: accept an immediately-merged release PR as success (review round 2)

gh pr merge --auto merges immediately when required checks are already
green — the normal 18:00 case, since fixes land hours earlier and CI
passes. The autoMergeRequest verify then saw null on a MERGED PR and
failed the job on the happy path. Now: MERGED = success, pending
auto-merge = success, still-open-with-no-auto-merge = real failure.

* ci: read release-PR state + auto-merge in one snapshot (review round 3)

Two separate gh pr view calls raced: a pending auto-merge completing
between them made the first read OPEN and the second read null on the
now-merged PR, failing the job on a successful release. Fetch state and
autoMergeRequest together.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:14:21 +02:00
Paul Nothaft 55b344b531 chore(main): release 3.97.4-beta.0 (#918)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-29 20:26:14 +00:00
Paul Nothaft 487f55f2d9 fix(admin): stop marking events expired up to 24h early (#909) (#916)
differenceInDays truncates to whole days, so an event expiring in a few
hours returned 0 and three admin surfaces treated it as gone:

- EventsListPage: status chip said 'Expired' (days <= 0) while the
  public gallery — which compares real timestamps — correctly showed
  'expires in X hours'. This is the reporter's exact symptom.
- EventDetailsPage: same isExpired math on the detail view.
- AdminDashboard: the expiring-soon card showed '0 days left' on the
  final day.

Expired is now gated on the actual timestamp (expires_at <= now), and
the countdown chips use ceiling days so the last day reads '1 day
left' instead of flipping to Expired/0.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-29 22:23:55 +02:00
Paul Nothaft aca3c8e4bc fix(admin): expose view/download counters in the admin photos list (#895 follow-up) (#914)
* fix(admin): expose view/download counters in the admin photos list (#895 follow-up)

st-ivan's re-test after #904: statistics panel and event summary now
agree, but the per-image Engagement column still shows 0. Root cause:
the admin photos LIST endpoint maps rows to an explicit response object
that includes like/comment/rating/favorite counts but never included
view_count or download_count — the grid reads photo.view_count ?? 0,
so the column showed 0 regardless of what the DB counted. This mapper,
not stale data, is also why per-image downloads always displayed 0 in
the original report.

Suite extended with a list-endpoint assertion (beacon + download, then
the admin list reflects 1/1 and untouched photos 0/0). The skip test now
neutralizes the route's background pre-zip build, whose async ENOENT
against the intentionally missing file could land mid-suite.

* test: widen the fire-and-forget settle window (#895 follow-up)

The 100ms settle was marginal on loaded CI runners — the counter
increments are deliberately fire-and-forget, and the 909 PRs flaked on
exactly these assertions. 400ms keeps the suite fast while giving slow
runners room.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-29 22:23:38 +02:00
Paul Nothaft 888150ba2d chore(main): release 3.97.3-beta.0 (#913)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-29 19:48:17 +00:00
Paul Nothaft 67c56c5b61 fix(admin): serve videos with their real MIME type in the admin photo view (#908) (#910)
* fix(admin): serve videos with their real MIME type in the admin photo view (#908)

The admin view route built Content-Type from the filename extension —
image/<ext> — which is invalid for videos (image/mp4). The admin player
fetches this URL into a blob that inherits the type, and browsers
refuse to play a <video> blob labeled image/*: blank/grey preview,
while download (which already uses photo.mime_type) worked fine.

Stored mime_type now wins; videos without one fall back to video/mp4,
images to the extension, and extensionless files to image/jpeg instead
of the equally invalid bare 'image/'.

Also unrefs chunkedUploadService's module-level hourly cleanup interval:
it kept Jest from exiting for any suite requiring adminPhotos (it's why
adminPhotos.reference sits on the CI ignore list). Production behavior
unchanged — the HTTP listener keeps the process alive.

New adminPhotoContentType suite pins all four MIME cases.

* fix(admin): harden admin photo Content-Type resolution (#908 review round)

External review findings, all verified:

- The header is now ALWAYS image/* or video/*. photos.mime_type is
  never echoed verbatim unless it is a video/ type — the chunked-upload
  path stores the client-sent MIME unvalidated, so a stored text/html
  served inline under the app origin was a same-origin XSS hazard.
- MIME-less videos map from the extension via the shared
  EXTENSION_TO_MIME (.mov → video/quicktime, .webm → video/webm)
  instead of a blanket video/mp4 that would mislabel them.
- Images ignore the stored MIME entirely: migration 039 backfilled
  image/jpeg onto every legacy row (PNGs included), so trusting it
  would regress previously-correct extension-derived types. Extension
  wins, normalized (jpg → image/jpeg).

Suite extended to 8 MIME cases including the XSS guard and the
039-backfill immunity.

* fix(admin): validate stored video MIME as a full header-safe token (#908 review round 2)

A prefix check let malformed client-stored values through:
'video/mp4\r\nX: y' makes res.setHeader throw ERR_INVALID_CHAR — a
permanent 500 for that photo — and a bare 'video/' is an invalid type.
Strict /^video\/[\w.+-]+$/ now gates the stored value; anything else
falls back to the extension map. Two new tests pin both shapes.

* fix(admin): map-only image Content-Type — no raw extension interpolation (#908 review round 3)

image/${ext} could synthesize image/svg+xml (scriptable when served
inline) or header-invalid values from client-controlled chunked-upload
filenames. The shared EXTENSION_TO_MIME map is now the allowlist on the
image side too; unmapped extensions serve as image/jpeg — browsers
sniff image bytes in img/blob contexts, so a mislabel is harmless where
an injected type is not.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-29 21:32:33 +02:00
Paul Nothaft 1ee7fe7336 chore(main): release 3.97.2-beta.0 (#906)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-29 16:02:10 +00:00
Paul Nothaft 78116e2e8b fix(analytics): make per-photo view/download counters actually count (#895) (#904)
* fix(analytics): make per-photo view/download counters actually count (#895)

Three stacked defects behind 'per-image stats stay 0':

- photos.view_count had NO writer anywhere — the admin IMAGES table and
  photo viewer display it, so it was permanently 0. It now increments
  when the full-size photo or its preview tier is served, excluding the
  slideshow kiosk (migration 138 design) and follow-up video Range
  requests (seeks are not views). Fire-and-forget so analytics can
  never fail the byte-serving path.
- Zip downloads (download-all, presigned download-all,
  download-selected) never incremented per-photo download_count — only
  single-photo downloads did, so zip-heavy galleries showed 0 forever.
  The zip routes now bump exactly the photos that went into the archive
  (the prebuilt-zip path mirrors the archive builders' category filter).
- Every admin surface used a different definition of 'downloads', which
  is the reporter's 46 vs 45 vs 0: event details counted only
  action='download' (no zips at all), the dashboard counted
  download+download_all but silently EXCLUDED download_selected and
  download_all_presigned. All queries now share one action set:
  download, download_all, download_all_presigned, download_selected.

New photoEngagementCounters suite pins all of it (7 tests).

* fix(analytics): count views via an explicit lightbox beacon (#895 review round)

External review flagged that request-level view counting is wrong in
both directions: the lightbox preloads prev/next neighbours (3 fetches
per open) while a preloaded neighbour promoted by a swipe is never
re-fetched (#505 keeps the DOM node), and enhanced/maximum galleries
never hit /photo at all (bytes come from /api/secure-images).

- Views now count via POST /:slug/photo/:photoId/view, fired by the
  lightbox exactly when a photo becomes the visible slide; the
  serving-route increments are removed. Covers protected galleries and
  the preview tier uniformly; slideshow kiosk stays excluded.
- bumpEventDownloadCounts mirrors downloadZipService._build (ALL event
  photos) — the category filter mismatched the prebuilt zip's actual
  contents. (That the builder ignores per-category allow_downloads is a
  separate pre-existing issue.)
- Zip loops count only successfully appended entries, with a pre-append
  storage stat: a lazy stream's async error bypassed the per-photo
  catch and hung the whole response — pre-existing bug, now fixed.

Suite extended to 9 tests (beacon semantics, serve-does-not-count,
skipped-entry exclusion).

* fix(analytics): fire the view beacon from the premium lightbox too (#895 review round 2)

gallery-premium events use yet-another-react-lightbox inside
GalleryPremiumLayout instead of PhotoLightbox, so the layout never
counted views. yarl's on.view fires on open and on every slide change —
identical semantics to the PhotoLightbox beacon.

Also documents the accepted prebuilt-zip approximation: _build can skip
entries whose watermark step fails and still publish the archive;
counting those exactly would need a persisted zip manifest.

* perf(analytics): skip the per-entry zip preflight on S3 (#895 review round 3)

The pre-append source check exists for LocalFs's lazy createReadStream
(async error would kill the whole zip response). S3's get() awaits
GetObject and rejects inside the loop's try/catch on a missing key, so
a HEAD per entry was a redundant serial round trip — 500 extra HEADs
on a 500-photo zip.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-29 17:58:54 +02:00
Paul Nothaft 5171105938 chore(main): release 3.97.1-beta.0 (#901)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-29 10:40:56 +00:00
Paul Nothaft d9ad982373 fix(tests): raise migration-boot hook timeout pins to the 120s default (#900)
The 3.97.0-beta.0 release PR (#899) failed its backend Tests job on
slideshowPublic.test.js: bootCrmDb's full migration chain crossed the
suite's explicit 30s beforeAll timeout argument on a slow runner. #860
raised the config default and the jest.setTimeout pins to 120s, but
hook-ARGUMENT pins override the config default and were left behind —
same time-bomb, different syntax.

Every beforeAll that boots the migration chain and pinned 30s/60s is
raised to 120000 (16 suites). Untouched on purpose: the three suites
whose pinned hooks don't run migrations (webhookDelivery,
imageProcessor.storage, storageBackend) and publicQuotes' 30s pin on
the rate-limit lockout test — neither grows with the migration chain.

No test logic changed.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-29 12:38:26 +02:00
Paul Nothaft 564e816bec chore(main): release 3.97.0-beta.0 (#899)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-29 09:46:14 +00:00
Paul Nothaft 6a048d08bd feat(feedback): let guests remove their star rating (#884) (#893)
* feat(feedback): let guests remove their star rating (#884)

Clicking your current rating again clears it. rating: 0 is the wire
contract: the validator now accepts 0, and the service deletes the
guest's rating row (instead of storing a 0 that would drag the photo
average down) and recalculates photo stats. The lightbox stars send 0
on a same-star click; PhotoRating already did, but the backend rejected
it with a 400 until now.

* fix(feedback): harden the rating-clear path (#884 review round)

External review follow-ups: numerically normalize the clear sentinel so
a numeric-string "0" can't slip into the update/insert paths (validator
now also toInt()s), delete the full guest-scoped rating set on clear so
racy duplicate rows can't survive in the average (same defense as the
reaction path), and refresh the visible average/count after the
identity-modal submit path like the direct paths do.

* fix(feedback): round-2 review fixes for rating clear (#884)

- Clear sentinel matches only an explicit 0 / "0" — malformed input
  (undefined, NaN, garbage strings) can no longer delete a rating.
- Lightbox survives the photo list shrinking while open (clearing your
  rating under the Rated filter drops the photo on refetch): index is
  re-anchored and the lightbox closes when the list empties, instead of
  crashing on an out-of-range index.
- Story layout gets the same same-star-to-clear behavior, keyed off the
  session-local my-rating map, and an explicit 0 no longer falls back to
  displaying the photo average.

* fix(feedback): refresh guest-scoped caches after rating changes (#884 review round 3)

- GalleryView's onFeedbackChange now also invalidates ['my-feedback',
  slug]: in guest identity mode the Rated/Liked filter membership and
  chip counts come from that query (#538), so a cleared rating never
  left the Rated filter until the 30s staleTime lapsed.
- PhotoRating invalidates gallery-photos + my-feedback on success: the
  parent refetch fires optimistically in onMutate and could capture
  pre-mutation state, with nothing refreshing after the server accepted.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-29 11:34:23 +02:00
Paul Nothaft 435c558704 chore(main): release 3.96.1-beta.0 (#898)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-29 09:30:02 +00:00
Paul Nothaft ec66cd2684 fix(gallery): keep the lightbox toolbar from masking the photo (#888) (#892)
The bottom info/action bar was a translucent gradient overlaying the
image, hiding the lower edge of the photo. The bar is now opaque and the
image area stops above it (measured via ResizeObserver, since the bar
height varies with flex-wrap, the optional filename line and safe-area
padding), so the photo is always fully visible.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-29 11:27:29 +02:00
Paul Nothaft 9f7c644e0a chore(main): release 3.96.0-beta.0 (#897)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-29 09:25:35 +00:00
Paul Nothaft 97f68899a2 feat(gallery): quick return from zoomed to fit-to-screen in the lightbox (#886) (#891)
Adds a fit-to-screen button next to the zoom controls (enabled while
zoomed) and double-click-to-reset on the image itself. Both snap the
photo back to 100% and re-centre it.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-29 11:23:04 +02:00
Paul Nothaft 0f8b68c05c chore(main): release 3.95.5-beta.0 (#896)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-29 08:55:34 +00:00
Paul Nothaft 34c2992521 fix(gallery): don't close the lightbox when clicking beside the photo (#883) (#890)
Clicking the black bars around the image (a missed arrow click) closed
the lightbox and dropped the guest back into the grid. The lightbox now
only closes via the X button or Escape, matching what gallery guests
expect while paging through photos.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-29 10:52:54 +02:00
Paul Nothaft d41cd9746d chore(main): release 3.95.4-beta.0 (#887)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-27 22:12:46 +00:00
peipeimo 33f1bc42a9 fix: sync gallery feedback filters after lightbox like/rating in simple mode (#882)
In simple identity mode, likes and ratings submitted from the lightbox
never called onFeedbackChange, so the gallery's photo list (whose
like_count drives the Likes/Rated feedback filter chips) stayed stale
until a full page reload. Liked photos were missing from the Likes
filter; unliked photos stayed stuck in it.

The guest-identity-mode paths and the grid PhotoCard paths already call
onFeedbackChange after submitting - the simple-mode lightbox paths were
the only ones missing it. Add the call to the three missing paths:
submitLike (simple branch), submitRating (simple branch), and the
FeedbackIdentityModal onSubmit handler.

Verified locally (Docker build of main): like a photo in the lightbox
after navigating with Next/Prev, open the Likes filter - the photo now
appears immediately with no reload, and filter contents match the admin
feedback API exactly.
2026-07-28 00:10:27 +02:00
Paul Nothaft 06a9991a22 chore(main): release 3.95.3-beta.0 (#880)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-27 07:57:33 +00:00
Paul Nothaft 08be2b84f1 fix(security): close 5 Trivy alerts — postcss/tar bumps + drop npm from the runtime image (#878)
* fix(security): close the 5 open Trivy alerts — dep bumps + drop npm from the runtime image

Backend deps:
- postcss 8.5.10 -> 8.5.18 (CVE-2026-45623, GHSA-r28c-9q8g-f849; the pin
  exists to force sanitize-html's transitive copy onto a fixed version)
- tar pin/override >=7.5.16 -> >=7.5.21, resolves 7.5.22
  (GHSA-r292-9mhp-454m)

Runtime image:
- Remove the npm CLI from the final stage instead of upgrading it: npm's
  bundled node_modules ship tar 7.5.19 and brace-expansion 5.0.7 (no npm
  release bundles the fixed versions — checked 11.18.0 and 12.0.1), and
  npm never runs in production. wait-for-db.sh now invokes the migration
  runners via node directly. This ends the recurring npm-bundled-CVE
  alert class; the previous 'npm install -g npm@11' line was itself a
  patch for the last batch.

* fix(restore): run post-restore migrations via node — the image ships no npm

restoreService still shelled out to 'npm run migrate:safe' after a
restore; with npm removed from the runtime image that would ENOENT into
the non-fatal catch, silently leaving a restored older backup on a
schema behind the running code until the next container restart. Invoke
migrations/run-migrations-safe.js through node directly, matching
wait-for-db.sh. The PR #596 source-contract test now pins the new
invocation.
2026-07-27 09:54:33 +02:00
Paul Nothaft ea8bd9b3e9 chore(main): release 3.95.2-beta.0 (#876)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-27 07:10:19 +00:00
Paul Nothaft a2e723413e fix(backup): make backup settings actually apply (#871) (#874)
* fix(backup): make backup settings actually apply (#871)

- Wire the What-to-Backup toggles into the walker: honor
  backup_include_thumbnails / backup_include_photos (opt-out,
  default ON) and accept the UI's backup_include_archives spelling
  for the archived gate (the engine expected _archived, so the
  Archives checkbox silently never worked).
- Fix the 167.6 TB dashboard size: file_size_bytes is a bigint that
  node-postgres returns as a string, and the S3 path concatenated it
  onto the byte counter; coerce to Number at the source.
- Compute the real next scheduled run (cron-parser) and return it as
  nextBackup; the UI read a field the API never sent and rendered a
  hardcoded 'Not scheduled'. A named schedule label now beats the
  stray default cron the UI always sent, which silently turned
  weekly schedules into daily 03:00 runs.
- Never back up filesystem noise (.nfs* silly-renames, .DS_Store,
  Thumbs.db) and honor backup_exclude_patterns in the walker
  (previously rsync-only).
- Remove the compression/encryption toggles from the configuration
  UI: no backend implementation exists, and collecting an encryption
  passphrase while uploading plaintext is a false promise.

Closes #871

* fix(backup): close the review gaps in the settings wiring

- The UI's backup_include_archives now beats the migration-seeded
  backup_include_archived: every install has the singular key seeded
  true, so the alias-only-when-absent lookup made unchecking Archives
  a no-op.
- rsync destinations now receive the de-selected What-to-Backup paths
  and the noise filters as anchored --exclude args; previously rsync
  synced the whole storage root and the walker's selection only shaped
  the manifest, which then misreported what was actually transferred.
- Escape regex metacharacters in the walker's glob matcher: '.nfs*'
  compiled to /^.nfs.*$/ whose leading dot matched any character, so
  files like anfs-photo.jpg were silently dropped from backups.
- The Backup Coverage report now uses the same gate as the walker
  (new 'skipped-by-setting' status) instead of re-implementing it
  without the opt-out toggles and the archives alias.

* fix(backup): make the coverage diagnostics agree with the walker

- The coverage table shows the alias-aware flag value the gate actually
  used, instead of the seeded backup_include_archived shadowed by the
  UI's plural key (true next to a 'Gated off' badge).
- skipped-by-setting paths are now counted in the coverage summary
  (backend, TS contract, summary card, EN/DE locales) so the totals
  reconcile again when Photos or Thumbnails is unchecked.
- The form's thumbnail default now matches the backend's never-saved
  fallback (include): the checkbox no longer shows 'off' while
  thumbnails are being backed up, and saving an unrelated setting no
  longer flips the backup scope.

* fix(backup): keep custom crons, exclude disabled rows from rsync, normalize flag display

- Saving a named schedule no longer wipes the stored custom cron: the
  backend already prefers the label, so the cron field stays inert for
  named schedules and is preserved for switching back to Custom. A
  custom schedule now validates the 5-field expression before saving
  (the backend silently fell back to daily 02:00 on a blank value).
- resolveExcludedBackupPaths now also returns rows disabled via
  include_in_default, so rsync excludes them; the enabled-only loader
  hid them and rsync transferred their contents anyway.
- The coverage table normalizes flag values like the walker does —
  Boolean('false') displayed true beside a gated-off badge.
2026-07-27 09:06:38 +02:00
Paul Nothaft 0c65edd99a chore(main): release 3.95.1-beta.0 (#872)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-26 18:41:13 +00:00
Paul Nothaft 38b8d476d1 fix(security): bump backend deps to close all 14 open Trivy code-scanning alerts (#869)
* fix(security): bump backend deps to close all open Trivy code-scanning alerts

- axios 1.16.0 -> 1.18.1 (GHSA-gcfj-64vw-6mp9 high + 10 medium advisories)
- sharp 0.34.3 -> 0.35.3 (GHSA-f88m-g3jw-g9cj, inherited libvips CVEs)
- mailparser 3.9.9 -> 3.9.14 (pulls linkify-it 5.0.2, CVE-2026-59887)
- brace-expansion override >=5.0.6 -> >=5.0.7 (CVE-2026-13149)
- body-parser 1.20.4 -> 1.20.6 via lockfile refresh (CVE-2026-12590)

* fix(images): migrate removed sharp failOnError option and enforce Node >=20.9

sharp 0.35 drops the deprecated failOnError constructor option, so
recoverably corrupt images would start failing upload validation and
thumbnail generation; use the failOn: 'none' equivalent instead.

sharp 0.35 also requires Node >=20.9: declare it in engines and make
picpeak-setup.sh compare the full version instead of only the major,
so native installs on Node 20.3-20.8 upgrade instead of breaking.

* fix(setup): align the Node floor with the whole dependency tree and gate native updates

html-to-text@10 needs Node >=20.19 and the glob/minimatch family excludes
Node 21, so declare engines as ^20.19.0 || >=22 and enforce the same range
in picpeak-setup.sh. Also run install_nodejs at the start of
update_native_installation so existing native installs on an old Node get
upgraded before the service is stopped, instead of restarting broken.

* fix(setup): make the update-path Node gate actually work

--update dispatches before detect_os, so install_nodejs saw an empty
PACKAGE_MANAGER, matched no install branch, and reported success on the
old runtime. Detect the OS on demand and re-verify the installed version
afterwards, failing loudly (before the service is stopped) when the
runtime still misses the engines range, e.g. a Node 21 that package
managers refuse to downgrade.
2026-07-26 20:38:05 +02:00
Paul Nothaft 6e2e0a1a63 chore(main): release 3.95.0-beta.0 (#867)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-25 13:10:23 +02:00
Paul Nothaft 219d07b04a feat(auth): OIDC logout-to-IdP — phase 3 (#798) (#865)
* feat(auth): OIDC logout-to-IdP — phase 3 (#798)

RP-initiated logout behind a new oidc_logout_from_idp setting: logging
out of PicPeak also ends the IdP session. The SSO callback stores the
raw ID token in an HttpOnly cookie (also the marker that the session
came in via SSO — local-password sessions never bounce to the IdP);
/logout builds the end_session URL from discovery metadata with
id_token_hint + post_logout_redirect_uri + client_id and returns it as
ssoLogoutUrl for the frontend to navigate to. Any failure (no
end_session_endpoint, IdP unreachable, feature off) degrades to the
plain local logout.

Settings surface exposes the toggle plus the computed post-logout
redirect URI to register at the IdP. Session timeouts deliberately stay
local-only.

6 integration tests over the mock IdP; live-verified against
Keycloak 26 (logout ends the Keycloak session, no confirmation prompt).

* fix(auth): harden the SSO logout marker cookie (#798 phase 3)

Codex review round 1:
- Derive the oidc_id_token cookie options from the shared cookie policy
  (COOKIE_SAMESITE / COOKIE_DOMAIN / secure resolution) — hardcoded Lax
  meant split-origin deployments running on SameSite=None never sent the
  marker to the cross-site /logout XHR, silently disabling logout-to-IdP.
- Oversized ID tokens (>3.9KB) now store a bare 'sso' marker instead of
  no cookie, so the claimed client_id-only end-session fallback actually
  happens; /logout only passes the value as id_token_hint when it is a
  real JWT.
- establishAdminSession clears any stale marker on every fresh login —
  sessions can die without /logout (deactivation, expiry, restore), and
  a surviving marker would bounce a later local-password session to the
  IdP. The SSO callback re-sets the marker for its own session.

Tests: oversized-token marker + hint-less end-session URL, stale-marker
cleared on local login; helper updated for the clear+set cookie pair.

* fix(auth): validate the logout hint against the current OIDC config (#798 phase 3)

Codex review round 2: an ID token stored at login can outlive an
issuer/client config change; sending it to the newly configured IdP as
id_token_hint strands the user on the IdP's error page (providers
validate iss/aud on the hint). buildEndSessionUrl now decodes the hint
(no verification — routing only): different issuer → skip the round-trip
entirely (the session belongs to another IdP); same issuer but changed
client → keep the round-trip, drop the unusable hint. Two tests pin both
paths.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-24 11:42:09 +02:00
Paul Nothaft c5dc790e28 chore(main): release 3.94.2-beta.0 (#864)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-23 19:59:53 +00:00
Paul Nothaft 323dcae917 fix(gallery): block password form in Instagram in-app browser and unmask login errors (#863)
* fix(gallery): block password form in Instagram in-app browser (#654)

Field reports show gallery password login still failing inside
Instagram's IAB after the #656 input-attribute/trim defenses. Three
changes:

- Replace the advisory amber banner with a red blocking state: the
  password form is hidden in the Instagram IAB and replaced with
  platform-specific "open in external browser" instructions plus a
  copy-link button (clipboard API with execCommand fallback). A
  "try anyway" link restores the form as an escape hatch.
- Stop masking non-password failures as "incorrect password": a request
  that never got a response (offline, webview killed it) now reports a
  connection error, and a reCAPTCHA 400 reports a verification failure —
  both previously fell through to the wrong-password message and sent
  guests chasing the wrong cause.
- Strip invisible Unicode (zero-width chars, word joiner, BOM, soft
  hyphen) from the submitted password in addition to trimming — these
  ride along when the password is copy-pasted out of a chat app and fail
  byte-exact bcrypt compare server-side.

* fix(gallery): retry login with typed password + honor execCommand result (#654)

Codex review round 1:
- Stored passwords can legitimately contain the invisible code points the
  sanitizer strips (e.g. ZWJ emoji sequences) — creation paths don't
  normalize. On a 401 where the sanitized form differs from the typed
  (trimmed) input, retry once with the typed value. Skipped when a
  reCAPTCHA token is in play (single-use).
- document.execCommand('copy') signals failure via its return value, not
  by throwing — only show "Link copied" when it returns true.

* fix(gallery): move invisible-char password fallback server-side (#654)

Codex review round 2: the client-side retry either burned the single-use
reCAPTCHA token (making exotic-but-valid passwords impossible to enter
with reCAPTCHA on) or burned failed-attempt lockout quota on every
rescued login. Doing the fallback as a second bcrypt compare inside the
same gallery/verify request eliminates both: exact bytes are compared
first (stored passwords containing e.g. ZWJ emoji keep working), the
sanitized form only on mismatch, and trackFailedAttempt only fires when
both fail. Frontend goes back to plain trim-on-submit; the client-side
sanitizer util and retry are removed. 7 integration tests pin the
contract.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-23 21:45:07 +02:00
Paul Nothaft 9ac23e5fe1 chore(main): release 3.94.1-beta.0 (#861)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-22 19:30:46 +00:00
Paul Nothaft 40eb03f0d8 fix(tests): raise jest timeouts to survive the growing migration chain (#860)
The 3.94.0-beta.0 release PR (#859) failed its backend job on
workflowEngine.test.js: bootCrmDb() runs every core migration in
beforeAll, and with migrations 163-165 merged the setup crossed the
suite's jest.setTimeout(30000) on CI runners — the log shows migration
099 still seeding after the hook timed out. Same pass is green locally
and passed on #857's rebase minutes earlier: borderline-slow, not
deterministic.

- jest.config.js: testTimeout 120000 as the default, so bootCrmDb
  suites without an explicit pin stop being time bombs as the chain
  grows
- every suite-level jest.setTimeout below 120s raised to 120s — local
  pins OVERRIDE the config default, so the 30s/60s ones would keep
  flaking regardless of the global bump

No test logic changed anywhere.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-22 21:27:12 +02:00
Paul Nothaft f89d374236 chore(main): release 3.94.0-beta.0 (#859)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-22 19:10:00 +00:00
Paul Nothaft c6ec93eef9 fix(dates): normalize SQLite epoch timestamps at remaining API surfaces (#485 follow-up) (#857)
The audit #485 called for: on SQLite (native installs), timestamp
columns written with a raw `new Date()` through knex store epoch-ms
numbers; Postgres returns ISO strings. Frontend code written against
Postgres calls parseISO() on them — parseISO(number) throws and crashes
the page. #485 fixed admin Users and listed api tokens / photos /
activity as out-of-scope follow-ups.

Verified crash on main: Timeline gallery layout parseISO(uploaded_at)
against photos written by the archive-RESTORE path (raw Date). Other
raw-write surfaces (api_tokens last_used_at/revoked_at, email_queue)
degrade rather than crash but violate the ISO contract.

- extract toIso() from adminUsers.js into utils/dateNormalize.js
  (contract unchanged — the 10 existing #485 tests still pin it)
- write-side: archive-restore uploaded_at, api-token last_used_at /
  revoked_at, email_queue created_at/sent_at now write ISO strings
- read-side (heals existing corrupted rows): gallery /photos normalizes
  uploaded_at/captured_at; api-tokens list normalizes all four
  timestamp fields
- frontend defence-in-depth: Timeline layout parses uploaded_at
  tolerantly (typeof guard) for stale caches / old backends
- 2 regression tests seed literal epoch numbers and assert the API
  serves ISO strings

activity_logs turned out safe (created_at comes from the DB default,
not a raw Date) — left untouched.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-22 21:07:32 +02:00
Paul Nothaft 2f05fcc39d feat(gallery): reveal mode — hide gallery from guests until reveal (#838) (#856)
* feat(gallery): reveal mode — hide gallery from guests until reveal (#838)

Guests can upload during the event but see no photos until the host
reveals the gallery, manually ("Reveal now") or at a scheduled time.

- migration 165: events.reveal_mode / reveal_at / revealed_at. Effective
  visibility is computed at REQUEST time (reveal_at <= now opens the
  gate exactly on schedule); the minutely scheduler only stamps
  revealed_at durably and emits a gallery.revealed workflow trigger
- server-side enforcement in gallery.js: /photos returns the event
  shell with photos: [] + hidden_until_reveal for plain guests;
  image/download/stats endpoints 403 with GALLERY_HIDDEN (photo IDs are
  sequential — listing-only gating would be probeable); feedback-summary
  gated too. Slideshow tokens (surprise beamer), client access and the
  admin preview bypass; the guest upload route stays open
- admin: reveal toggle + optional scheduled datetime next to the guest
  upload settings, status line and "Reveal now" button on the overview;
  re-enabling the toggle clears revealed_at so a gallery can re-hide
- guest UI: upload-only view (hero, friendly message, scheduled time,
  upload button) for every layout; i18n for all 8 locales
- timestamps written as ISO strings — the SQLite driver stringifies raw
  Date objects into garbage; ISO round-trips on both engines
- 14 integration tests over minted gallery/slideshow/client/admin tokens

* fix(gallery): reveal/re-arm semantics + upload button i18n key (#838)

- "Reveal now" also clears a pending reveal_at: the schedule is
  consumed, so the full-form admin save can't accidentally re-hide a
  revealed gallery with a stale future date
- setting a FUTURE reveal_at on a revealed gallery re-arms hiding —
  the one intentional way to re-hide without double-toggling the mode
- guest upload button uses the existing upload.uploadPhotos key
  (gallery.uploadPhotos never existed; the button showed EN everywhere)

* fix(gallery): close reveal bypasses from review round 1 (#838)

- the hero-derivative route and the secure-images token-mint +
  secure-download routes are now reveal-gated: hero serves a 1920px
  derivative of ANY sequential photo id and secure tokens fetch
  originals — both were open bypasses while hidden. blockHiddenGallery
  moved to utils/revealMode.js and shared
- customer-portal tokens (via:'customer', no accessLevel) now bypass
  reveal mode — they are the host/customer, not a guest, and were
  getting the upload-only view
- an open hidden guest view refetches exactly at reveal_at plus a 60s
  fallback poll, so the gallery appears without a manual reload
- gallery.revealed added to the workflow editor's trigger picker so
  the advertised notification hook is reachable in the UI
- migration 165 guards each column independently (partial-state safe)

* fix(gallery): reveal round 2 — remaining bypass surfaces + lifecycle edges (#838)

- legacy /api/images router reveal-gated (view, secure-token + signed-url
  minting), and the signed-URL SERVE path re-checks hidden state via a
  backward-compatible bypass flag in the token payload
- secure-image tokens record revealBypass at mint and are re-validated
  at serve time — a re-hide kills in-flight guest tokens within the
  request, while slideshow/client tokens keep working
- OG metadata and the unauthenticated /og cover fall back to the brand
  logo / 404 while hidden — no hero-photo spoiler for social crawlers
- photo-feedback GET/POST reveal-gated (sequential ids were enumerable);
  /my-feedback returns the empty back-compat shape (rows leak filename +
  storage path)
- the reveal scheduler skips drafts — no premature stamp/notification
  for unpublished galleries
- emitWorkflowEvent gains an additive dedupSuffix; both reveal emitters
  pass the reveal timestamp so a re-hidden gallery's second reveal
  fires workflows again instead of deduping into silence

* fix(gallery): reveal round 3 — schedule consumption + two-way client sync (#838)

- the scheduler now consumes reveal_at when stamping (matching "Reveal
  now"), and re-arming via a partial API update clears a stale PAST
  schedule — previously {reveal_mode:true} without reveal_at could
  instantly re-open the gate through the leftover date
- /photos exposes reveal_armed so an open VISIBLE gallery keeps a 60s
  poll while the mode is on — a re-hide now propagates to open clients
  in both directions, not just hidden→visible

Codex round-3 claim about timestamp-without-timezone drift on non-UTC
Postgres was verified FALSE: knex's table.timestamp() creates
timestamptz on PG (confirmed via information_schema on a live install),
which stores absolute instants regardless of server TZ.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-22 20:59:59 +02:00
Paul Nothaft 3d6c9848dc feat(feedback): emoji reactions on photos (#839) (#855)
* feat(feedback): emoji reactions on photos (#839)

Per-photo emoji reactions from a fixed curated set (❤️ 😂 😍 👏 🎉),
one reaction per guest per photo — same emoji toggles off, another
switches in place. Stored as feedback_type='reaction' rows with per-guest
scoping identical to likes (guest_id when present, device hash otherwise).

- migration 164: allow_reactions toggle (default on, still gated by the
  opt-in feedback_enabled master switch), photo_feedback.reaction value
  column, denormalized photos.reaction_count
- emoji whitelist enforced in the route validator AND the service
  (shared constants/reactions.js, mirrored in the frontend)
- per-emoji tallies + my_feedback.reaction in the photo feedback
  endpoint; hidden-by-moderator reactions leave all counts
- reactions ride the existing rate limiting (like-tier), guest identity
  modes, and moderation actions; long + pivot exports carry the emoji
- gallery: reaction bar in the photo feedback panel (grid lightbox);
  admin: allow_reactions toggle next to likes, analytics tile,
  create/duplicate event paths
- i18n for all 8 locales; 9 service-level tests

* fix(feedback): reach reactions without comments; numeric analytics totals (#839)

- the lightbox feedback-panel toggle was gated on allow_comments only —
  with comments off the new reaction bar was unreachable; the gate now
  opens for comments OR reactions
- the analytics summary now coerces Postgres string counts to numbers:
  total_feedback concatenated instead of adding ("00006")

* fix(feedback): harden reactions from review round 1 (#839)

- per-emoji tallies are gated on show_feedback_to_guests — with sharing
  off a guest sees only their own selection, no aggregate counts
- reaction toggle/switch operate on the guest-scoped row SET, so rows
  duplicated by the (like-parity) check-then-insert race collapse on the
  next interaction instead of counting twice
- rate-limit defaults merge UNDER the persisted settings object —
  stored rows predating the reaction key otherwise dropped it to the
  generic 100/h fallback
- optimistic revert uses the pre-mutation value via mutation context;
  the onError closure sees the post-optimistic render, so the old
  revert froze the wrong state on failed toggles

* fix(feedback): review round 2 — hide reaction_count with sharing off, admin list shows emoji (#839)

- summary.reaction_count is gated on show_feedback_to_guests like the
  per-emoji map, keeping the "no aggregates while sharing is off"
  promise consistent
- the admin feedback list renders the reaction emoji on reaction rows
  and the type filter gains a Reactions option (7 locales; es has no
  types block and falls back to EN defaults)

* fix(feedback): register reaction activity types with translated labels (#839)

photo_reaction / guest_feedback_reaction are logged by the submission
paths but were absent from the frontend activity-type union and the
admin.activities label maps — the recent-activity feed would have shown
the raw identifiers. All 8 locales.

* feat(feedback): reactions in guest CRM and the premium gallery layout (#839)

- guest CRM: per-guest reaction counts in the list aggregation and a
  Reacted tab (photo grid with emoji badges) + stats card in the guest
  detail modal; picks/aggregate/exports stay selection-only by design
- premium layout: its own yet-another-react-lightbox now gets a fixed
  reaction-bar overlay (per-photo fetch, optimistic switch) — reactions
  were otherwise unreachable in this layout since it bypasses the
  shared PhotoLightbox
- allowReactions threaded through the layout feedbackOptions; guest
  i18n keys for the 7 locales that carry the guests block

* fix(feedback): portal the premium reaction bar to document.body (#839)

Inside the layout tree an ancestor stacking context (framer-motion
transforms) painted the bar under yarl's body-level portal — visible
but unclickable, every tap landed on the slide image. As a direct body
child the z-index 10000 genuinely wins over yarl's 9999. Verified by
clicking through in the running app.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-22 20:59:43 +02:00
Paul Nothaft f8a95d29d2 feat(auth): OIDC role mapping + login policy — phase 2 (#798) (#854)
* feat(auth): OIDC role mapping + login policy — phase 2 (#798)

Role mapping: configurable dot-path roles claim (Keycloak realm_access.roles,
Authentik/Pocket ID groups, Entra roles), IdP-value → PicPeak-role mapping
table validated against the roles table, re-evaluated on every SSO login with
highest-priority-wins on multiple matches. The last active super_admin is
never demoted. Optional require-mapped-role policy refuses logins whose token
maps to no role (sso_error=no_role).

Login policy: oidc_disable_local_login makes the API refuse password logins
(403 LOCAL_LOGIN_DISABLED) and the login page render SSO-only; only effective
while SSO is enabled+configured, and OIDC_BREAK_GLASS=true always re-opens
local login. Public settings expose the EFFECTIVE flag only.

Settings UI: Role-mapping card (claim path, mapping rows editor, strict
toggle) and Login-policy card with break-glass hint, EN+DE.

14 new integration tests over the mock IdP.

* fix(auth): harden phase-2 review findings (#798)

- memoize the scrypt-derived OIDC key and serve /public/settings from a
  10s-TTL flag cache — the unauthenticated endpoint no longer pays a
  13-key config read + blocking scryptSync per request (login route
  still checks uncached)
- make the last-super-admin demotion guard atomic (FOR UPDATE on the
  active super rows) — concurrent mapped callbacks could previously
  both count 2 and demote both supers
- own-property lookup in role mapping: IdP values like `constructor`
  now count as unmapped instead of corrupting the roles query
- SsoTab clears oidc_disable_local_login in the same save that turns
  SSO off — the full-form payload otherwise hit the server-side 400

* fix(auth): guarantee break-glass reachability for SSO-only mode (#798)

- wire OIDC_BREAK_GLASS and OIDC_ENCRYPTION_KEY through the quick-start
  docker-compose.yml env allowlist (production compose already passes
  .env via env_file) and document both in .env.example
- refuse enabling oidc_disable_local_login unless an active
  local-password super_admin exists: OIDC_BREAK_GLASS only re-opens the
  password route, which OIDC-owned accounts can never use, and
  settings.edit is super_admin-only — an all-OIDC instance would be
  unrecoverable during an IdP outage

* fix(auth): close SSO-only lockout gaps from review round 3 (#798)

- role sync never demotes the last active LOCAL-password super_admin
  (an OIDC-owned super does not count as break-glass), and
  isLocalLoginDisabled() disarms itself when no such account remains —
  self-healing against manual demotion/deactivation/deletion paths
- the local-super save-time check now validates the MERGED state, so
  re-enabling SSO with a stored disable flag is checked too
- ALL oidc_* keys are reserved from the generic settings upserts/reads
  (prefix match) — policy and mapping invariants can only go through
  the validated PUT /sso
- /admin/login/mfa re-checks the policy so an mfa_pending token minted
  before the flip cannot complete into a local session

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-22 20:59:19 +02:00
Paul Nothaft ad326da35c chore(main): release 3.93.0-beta.0 (#853)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-19 20:40:52 +00:00
Paul Nothaft a6a3c9f9f8 fix(crm): pass trx to logActivity inside transactions — audit rows silently lost on SQLite (#851)
* fix(crm): pass trx to logActivity inside transactions — audit rows were silently lost on SQLite

createContract, updateContract, createStorno and reissueInvoice called
logActivity() (contract paths also adminActor()) from inside a knex
transaction without the trx executor — the pattern db.js:648's comment
explicitly warns about. On single-connection SQLite the audit insert
waits on a second pool connection while the trx holds the only one:
a 60s acquire-timeout stall per call, then logActivity's catch swallows
the failure and the audit row is silently lost. Postgres unaffected.

Fix mirrors the one call site that already did it right
(contract_created_from_quote, conversions.js): resolve the audit actor
before the transaction opens and pass trx as logActivity's executor so
the insert rides the transaction's connection.

Verified NOT affected (logActivity outside any trx, unchanged):
cancelContract, contract_converted_to_event, contract_signed_by_customer,
contract_sent, invoice_sent/_cancelled(draft)/_released/monthly_bill.

Found by the #587 integration-test work (PR #850, which shrank the pool
acquire timeout to tolerate the stall — that workaround can be dropped
once both land).

* fix(crm): run reissueInvoice's createInvoice without a wrapping transaction (codex review of #851)

The round-1 fix passed trx to the reissue audit call — but that point
was never reached on single-connection SQLite: createInvoice internally
reads via the global connection (businessProfileService.getProfile,
getAppSetting, bank-account resolution), so the outer trx deadlocked
first and aborted the replacement AFTER the Storno had already
committed and been emailed.

createInvoice's five other callers all run it without a trx; reissue
now does the same and backlinks afterwards. Trade-off documented in
code: replacement + backlink are no longer atomic — a crash between
them leaves a visible draft without replaces_invoice_id, which beats
the guaranteed stall. New regression test drives a full cancel+reissue
on the SQLite harness and pins the invoice_reissued audit row.

* fix(crm): restore the reissue transaction by routing createInvoice's reads through trx (codex review of #851, round 2)

Round 2 was right that dropping the wrapping transaction traded the
deadlock for orphan drafts: createInvoice inserts the invoice row and
claims a sequence number BEFORE line-item validation can throw, so a
failed reissue would persist partial state after the Storno committed.

Proper fix: the transaction is back, and every read inside createInvoice
now rides it — getProfile and resolveBankAccountForCurrency gained an
optional conn param (default db, all other callers unchanged),
getAppSetting calls pass trx (crm_invoice_round_total + the
resolveNetDays default the regression test flushed out), and the
invoice_created audit uses the trx executor. The reissue regression test
now proves a full cancel+reissue commits atomically on single-connection
SQLite.
2026-07-19 22:36:52 +02:00
Paul Nothaft 997a85cdbc test(crm): mint-path integration tests — quote send, invoice storno, contract countersign (#850)
* test(crm): mint-path integration tests — quote send, invoice storno, contract countersign (#587)

End-to-end through the real HTTP → route → service → DB → email-queue →
file pipeline on full-migration SQLite (helpers/crmDb), real pdfkit/
pdf-lib rendering, no mock-fs, no network. 7 tests.

Deviations from the issue spec — the tests pin the code's real behavior:
- Storno route is POST /:id/cancel (not /:id/storno), responds 200 with
  { cancelled, stornoId } (not 201).
- Quote re-send rejects with 409 (not 400).
- Contract statuses are signed_by_customer → fully_signed; the hash
  columns are pdf_sha256 / signed_pdf_sha256 (no integrity_hash) — the
  test verifies the stored sha256 against the file on disk.
- Business-doc PDFs persist under process.cwd()/storage/business-docs,
  not STORAGE_PATH — isolated via chdir into the temp dir.

Two documented, test-scoped harness workarounds: shrunk pool acquire
timeout (guards against the pre-existing logActivity-inside-transaction
deadlock in createContract/createStorno on single-connection SQLite —
worth its own fix) and Date→ISO binding normalization (node-sqlite3's
cross-realm Date detection under jest's vm sandbox).

Assisted-by: task agent (worktree)

* test(crm): pin sendStorno side effects + real customer-sign flow (codex review of #850)

- Storno test now asserts the delivery leg cancelInvoice deliberately
  swallows on failure: storno status 'sent', PDF on disk, storno_issued
  email queued to the customer — a broken render/persist/queue no
  longer stays green.
- Contract seed goes through sendContract's token + a real
  recordCustomerSignature instead of a direct status UPDATE, so
  countersign exercises the signature-layering path; the test now also
  pins that the customer's signature asset survives countersigning.

* test(crm): prove both signature stamps are embedded in the countersigned PDF (codex review of #850, round 2)

Path/hash assertions alone stay green if countersign stamps the admin
onto the unsigned base PDF. New pdf-lib helper counts embedded image
XObjects per page of the final document and asserts the signature page
carries at least two — customer stamp AND admin stamp.
2026-07-19 22:36:38 +02:00
Paul Nothaft cb5b319f10 feat(notifications): surface guest activity in the admin bell (#849)
* feat(notifications): surface guest activity in the admin bell (#746)

Favorites already reached activity_logs (feedbackService), but gallery
opens and downloads only landed in access_logs — invisible in the
notification bell. Now:

- gallery_opened on the guest photo-list route, debounced in-memory to
  one notification per event per 6h (the endpoint fires per page load;
  per-hit notifications would spam the bell). Slideshow traffic stays
  excluded, matching the analytics exclusion.
- gallery_downloaded on all four download paths (streamed + pre-zipped +
  presigned download-all, download-selected) with scope metadata.
- Frontend: locale entries for galleryOpened/galleryDownloaded (and
  photoFavorite, which previously fell through to the generic 'system
  activity' line) in all 8 languages — resolved via the existing smart
  camelCase fallback, no switch cases needed. Distinct bell icons per
  type.

* fix(notifications): single-photo download activity + render per-type bell icons (codex review of #849)

- The per-photo Save route (GET /:slug/download/:photoId) only wrote to
  access_logs — the most common download path never reached the bell.
  Now emits gallery_downloaded with scope 'single', debounced to one
  notification per event per hour: a guest saving 30 photos is one
  signal, not thirty (exact counts stay in access_logs/analytics).
- getNotificationStyle's icon names were dead — AdminHeader hard-coded
  <Bell> for every row. Added an icon map so gallery opens (Eye),
  downloads (Download), favorites (Heart) and the pre-existing style
  names render their intended icons.

* fix(notifications): notify after successful delivery, complete the icon map (codex review of #849, round 2)

- Single-photo notification now fires on res 'finish' with status < 400:
  emitting up-front logged downloads that then 404ed/failed AND burned
  the 1h debounce window against the next real download.
- Icon map completed over every name getNotificationStyle returns
  (grep-verified) — settings/user/mail/etc. styles render their declared
  icons instead of falling back to Bell.

Deliberately NOT taken from the review: DB-backed debounce state for
multi-worker deployments. The backend's current deployment contract is
single-process (no PM2 cluster in-repo; multi-replica explicitly parked
in #799 — chunked-upload/session state is process-local for the same
reason). Worst case under a future multi-worker setup is N notifications
per window, which degrades, not breaks; a shared-store debounce belongs
to the #799 phase-3 work.

* fix(notifications): attribute client sessions, log cached-ZIP after finish, add Trash2 icon (codex review of #849, round 3)

- gallery_opened/gallery_downloaded now carry the real actor: client
  sessions (accessLevel 'client') are recorded as 'customer' instead of
  being mislabeled 'guest' — #746 explicitly covers client activity, so
  they are attributed, not excluded.
- Cached-ZIP streaming path logs on res 'finish' (< 400) like the
  single-photo path — piping is not delivery. The presigned-redirect
  and on-the-fly-archiver paths keep their existing timing (redirect
  handoff / post-finalize).
- Trash2 added to the icon map (customer_erased, bulk_delete_completed
  no longer fall back to Bell — the grep that built the map missed the
  digit in the name).

* fix(notifications): dashboard formatting, portal dedup, actor-aware wording, archiver finish-hooks (codex review of #849, confirmation round)

- activity_logs feed TWO surfaces: the dashboard's Recent Activity used
  admin.activities.<type> keys that didn't exist, rendering raw
  identifiers — added gallery_opened/gallery_downloaded entries in all
  8 locales.
- Customer-portal opens already log customer_event_access at the
  access-token mint; the ensuing /photos call no longer double-notifies
  (client sessions surface via downloads only).
- gallery_downloaded formatting is actor-aware: customer sessions render
  'Customer downloaded…' (new galleryDownloadedCustomer key ×8) instead
  of 'A guest…'.
- Both on-the-fly ZIP paths (download-all fallback + download-selected)
  notify on res 'finish' < 400 — archive.finalize() ends Archiver's
  input, not the HTTP transfer.

* fix(notifications): key customer dedup/attribution on portal provenance, neutral favorite wording (codex review of #849, final round)

The previous dedup was inverted: portal-minted tokens carry
via:'customer' but NO accessLevel (they run as guest), while PIN-client
logins carry accessLevel:'client' and log nothing else. So PIN clients'
only open signal was suppressed while portal opens still double-
notified and portal downloads read as guest activity.

verifyGalleryAccess now surfaces req.viaCustomer; gallery_opened dedups
on THAT (portal only), and galleryActor treats via-customer OR
accessLevel-client as 'customer'. photoFavorite wording is actor-neutral
across all 8 locales — feedbackService logs favorites without an actor,
so claiming 'a guest' was wrong for customer favorites.
2026-07-19 22:36:23 +02:00
Paul Nothaft e8dad4b40d feat(slideshow): guest-scannable share-link QR overlay (#848)
* feat(slideshow): guest-scannable share-link QR overlay (#837)

- Global settings (Settings → Slideshow): slideshow_qr_enabled/position/
  opacity/size — same option shape and cascade as the watermark.
- Per-event tri-state show_qr (migration 163): NULL inherits the global,
  true/false force on/off; editable in the per-event slideshow card.
- State endpoint ships the QR as a PNG data URI (cached per share URL —
  the 3s projector poll never re-encodes), so the kiosk needs no QR lib
  and no extra authenticated request.
- Kiosk renders the QR in a white padded corner box so it stays
  scannable on any photo.
- i18n: en + de (the slideshow namespace has no other locales yet).

* fix(slideshow): persist per-event QR override, show QR on empty shows, bound the QR cache (codex review of #848)

- OverviewTab never passed event.show_qr into the settings card (and the
  Event type lacked the field), so a stored true/false override always
  displayed as 'inherit' and the next save silently reset it to NULL.
- The QR overlay was nested inside the photos.length > 0 branch — an
  empty or category-filtered live gallery showed only 'Waiting for
  photos', exactly when 'scan to add the first photos' matters most.
  Now rendered for any running show.
- slideshowQrCache: insertion-order eviction at 50 entries — rotated
  tokens and past events no longer accumulate base64 PNGs forever.

* fix(slideshow): derive the QR origin from the kiosk request when the base is loopback (codex review of #848, round 2)

With the compose-default FRONTEND_URL=http://localhost:3000 (or no base
configured) the overlay QR sent scanning phones to their own localhost.
The state poll comes from the kiosk browser itself, so its Host header +
protocol (trust proxy is configured) are exactly the public origin
guests can reach — used whenever the configured base is missing or
loopback. Mirrors the ?origin= fallback #847 uses for the admin-side
QR downloads.

* fix(slideshow): kiosk passes its origin for the QR fallback (codex review of #848, round 3)

req.get('host') is not the browser origin behind the standard proxies —
frontend/nginx.conf forwards $host with the port stripped, so a compose
LAN deployment on :3000 encoded port 80. The kiosk now sends
window.location.origin with the session/state calls (validated
server-side, same pattern as #847's admin downloads); the Host-derived
origin remains as second fallback.

* fix(slideshow): reject loopback kiosk origins, throttle QR regeneration per event (codex review of #848, confirmation round)

- A loopback window.location.origin from the kiosk is no more
  guest-reachable than the loopback base it would replace — rejected;
  when no reachable URL remains the overlay is suppressed entirely (no
  QR beats a QR that sends phones to their own localhost). New test
  pins the suppression.
- The QR cache is keyed by event id with a 60s regeneration throttle:
  the origin is caller-influenced when the base is loopback, so
  URL-keyed caching let a slideshow-link holder force a fresh
  QRCode.toDataURL per request via unique origins — a cheap CPU
  exhaustion path. Encode rate is now bounded per event regardless of
  input. QR margin also raised to the 4-module spec quiet zone,
  matching #847.

* fix(slideshow): never serve a mismatched cached QR + single-flight encoding (codex review of #848, final round)

- A slideshow-token holder could poison the projector's QR: an
  attacker-origin entry cached per event was served to the legitimate
  kiosk for the rest of the throttle window. A cached artifact is now
  only served when its URL matches the request; mismatches inside the
  window suppress the overlay briefly instead of showing foreign
  content.
- Cold-cache stampede closed: concurrent polls share one in-flight
  encode promise instead of each scheduling a 512px render.

Rejected from the same round (false positive, verified empirically):
the loopback regex claim — /^https?:\/\/(localhost|127\.)/ matches
'http://localhost:3000' and '127.0.0.1:port' just fine (no trailing
slash required), and the suppression test runs green.
2026-07-19 22:36:03 +02:00
Paul Nothaft 60cdd07085 feat(events): gallery QR code + printable table-card/poster PDFs (#847)
* feat(events): gallery QR code + printable table-card/poster PDFs (#836)

- GET /api/admin/events/:id/qr — share-link QR as PNG (128-2048px) or
  SVG, inline or attachment; adminAuth + events.view + ownership.
- GET /api/admin/events/:id/qr-print — pdfkit-rendered A6 table card /
  A4 poster with event name, QR, localized caption (8 locales; Cyrillic
  falls back to English — built-in Helvetica has no Cyrillic glyphs) and
  the share URL as footer.
- Event detail: QR section in ShareLinkCard with live preview (blob
  fetch — Bearer auth) and PNG/SVG/table-card/poster downloads; print
  language follows the admin UI language. i18n keys in all 8 locales.
- qrcode + pdfkit were already dependencies (MFA / CRM PDFs).

* fix(events): QR origin fallback, Unicode PDF font, bounded layout, stale-preview guard (codex review of #847)

- QR URLs: prefer the configured public base, but fall back to the admin
  browser's origin (passed as ?origin=, validated) when the base is
  missing or localhost — mirrors buildShareLinkUrl so the QR encodes the
  same URL the card displays instead of an unusable localhost target.
- PDFs render with the bundled IBM Plex Sans TTFs (Latin+Cyrillic+Greek)
  instead of WinAnsi-only Helvetica: Cyrillic event names no longer
  silently disappear, and the caption's English-fallback hack is gone.
- Fixed vertical layout: title gets a bounded two-line ellipsis region
  and all positions derive from constants, so long event names can't
  push the QR/caption over the footer; URL footer bounded too.
- ShareLinkCard preview: stale-response guard — a late blob response
  after unmount/event-switch is revoked instead of leaking and
  overwriting the newer event's QR.

* fix(events): bundle complete IBM Plex Sans for QR PDFs + IPv6 loopback fallback (codex review of #847, round 2)

Round 2 caught that the pre-existing assets/fonts/IBM-Plex-Sans/ files
are 270-glyph Latin SUBSETS — my round-1 font swap didn't actually fix
Cyrillic titles and regressed the ru caption. Now bundling the complete
IBM Plex Sans 400/700 TTFs (1019 glyphs, Latin+Cyrillic+Greek — cmap
verified via fontkit, rendering verified on a generated PDF) under
assets/fonts/IBM-Plex-Sans-Full/ with the OFL license alongside.
~400 KB total; source: IBM/plex release zip @ibm/plex-sans@1.1.0.

Also: LOCAL_BASE_RE now recognizes IPv6 loopback ([::1]) so a
FRONTEND_URL of http://[::1]:3000 falls back to the browser origin like
the frontend's own URL logic does.

Note for a follow-up: the CRM invoice/quote PDFs use the same Latin-only
subsets and share the Cyrillic gap.

* fix(events): responsive QR card that survives preview failures (codex review of #847, round 3)

- The QR section keys off share-link availability instead of a loaded
  preview: a transient failure of the preview request no longer hides
  every download button until reload; a placeholder tile renders in
  place of the image.
- Preview + actions stack on phone widths and the button grid drops to
  one column below sm, so 'Tischkarte (A6)'-length labels don't
  overflow.

* fix(events): QR encodes the stored share_link + spec quiet zone (codex review of #847, confirmation round)

- The QR target is now the STORED share_link — exactly what the card
  displays and the admin copies. Rebuilding from current slug/token/
  short-URL setting could diverge for legacy absolute links or events
  created under a different short-URL setting; a printed QR encoding a
  different URL than the card is a permanent mistake. Rebuild remains
  only as fallback when no share_link is stored.
- QR margin back to the library's 4-module default for all generated
  assets — the spec's quiet zone; margin 2 risks scan failures when the
  printout sits against colored surroundings.

* fix(events): bare share_link tokens resolve as /gallery/<token> in QR URLs (codex review of #847, final round)

Quote-/contract-converted events persist share_link as the raw token —
the frontend's buildShareLinkUrl prefixes those with /gallery/, but the
QR path normalization only added a leading slash, encoding
<origin>/<token> into every image/PDF for such events. Now mirrors the
frontend exactly.

* test(events): 30s timeout for the print-PDF cases (CI fix)

The poster PDF now embeds the full IBM Plex Sans TTFs (~200 KB each);
font parsing + subsetting exceeds jest's 5s default on slower CI
runners — the suite went red on exactly that test after the font
commit.
2026-07-19 22:35:44 +02:00
Paul Nothaft 613133c29c chore(main): release 3.92.2-beta.0 (#852)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-19 20:05:52 +00:00
Paul Nothaft 8337a716b1 fix(file-watcher): bound concurrent photo processing (#846)
* fix(file-watcher): bound concurrent photo processing

chokidar fires 'add' once per file — with no ignoreInitial option the
boot scan fires it for every existing file, and a bulk drop into the
watch folder fires it for every new one at once. Each handler runs DB
lookups plus (for new files) a full sharp pipeline; sharp.concurrency(2)
only caps libvips threads WITHIN one operation, not the number of
parallel pipelines, so unbounded handlers can OOM small hosts.

Gate both 'add' and 'unlink' through a shared p-limit
(FILE_WATCHER_CONCURRENCY, default 2, floor 1) — mass deletes otherwise
burst DB work and ZIP-cache invalidation the same way. p-limit is pinned
to ^3.1.0, the last CommonJS release.

Adapted from the filpgame fork (426ca491) — thanks @filpgame; extended
to cover 'unlink', documented in .env.example, plus a lock-in test for
the existing Sharp cache/concurrency caps this bound relies on.

* chore(compose): pass FILE_WATCHER_CONCURRENCY into the backend container (codex review of #846)

The backend service uses an explicit environment list (no env_file), so
the documented override never reached the container in the default
compose deployments. Added to both compose files + root .env.example.
2026-07-19 22:00:46 +02:00
Paul Nothaft 0310c46fdd fix(uploads): keep videos when thumbnail generation fails (#845)
* fix(uploads): keep videos when thumbnail generation fails

processUploadedVideo() (ffmpeg probe + thumbnail) was unguarded in both
pipeline paths, while the image branch next to each already survives its
thumbnail failures:

- processUploadedPhotos (sync): the throw failed the whole upload — the
  video was lost.
- processPhoto (async worker, the path real uploads take): the throw
  marked the row 'failed', and the guest gallery only lists 'complete' —
  the video became permanently invisible despite being fully uploaded.

Both call sites now fall back to extractVideoMetadata() alone and keep
the video without a preview; if even the probe fails, the video is kept
with no metadata. Idea from the munin92 fork (2026-07-02), reimplemented
for both paths + regression test.

* fix(uploads): placeholder thumbnail for rescued videos (codex review of #845)

A completed video with a NULL thumbnail made the gallery grid fetch the
ORIGINAL video file as an <img> blob (thumbnail_url || url) — a
potentially multi-GB download for a broken tile. Both fallback paths now
generate the existing sharp-rendered play-button placeholder
(generateVideoPlaceholder — ffmpeg-free), so rescued videos get a real
tile. Test asserts the placeholder key lands in thumbnail_path.
2026-07-19 22:00:08 +02:00
Paul Nothaft 8060fedf6a fix(security): read the password-complexity key the settings UI writes (#843)
* fix(security): read the password-complexity key the settings UI writes

The settings UI saves the admin's complexity choice as
security_password_complexity (useSettingsState.ts prefixes security_ to
password_complexity), but getPasswordComplexitySettings() queried
security_password_complexity_level — written by nothing — so the setting
was silently ignored and password validation always used the 'moderate'
default. Spotted in the filpgame fork (their main, 2026-07-14).

* fix(security): accept the Postgres json-column shape of the complexity value (codex review of #843)

On SQLite the TEXT column returns the JSON-stringified value
('"very_strong"'), but on Postgres (production default) setting_value
is a json column and arrives already decoded ('very_strong') — the bare
JSON.parse threw and the outer catch silently fell back to 'moderate'
again. Parse with fallback, mirroring getAppSetting's documented
pattern; test now covers both driver shapes + the empty-value default.
2026-07-19 20:04:31 +02:00
Paul Nothaft f891b16503 chore(main): release 3.92.1-beta.0 (#842)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-19 09:22:09 +00:00
Paul Nothaft 216c282542 Merge pull request #841 from PicPeak/chore/storage-ignore-dead-code
chore(backend): ignore runtime storage in git/docker, remove dead getSafeFilename
2026-07-19 11:18:59 +02:00
Paul Nothaft f7fd89387b Merge pull request #834 from Dodothereal/fix/821-bug
fix(uploads): support configured raw formats
2026-07-19 11:18:43 +02:00
Paul Nothaft 2f4b8a64c0 chore(backend): ignore runtime storage in git/docker, remove dead getSafeFilename
Follow-ups from the codex review of #834:

- .gitignore: backend/storage/ is runtime-generated (media, previews,
  thumbnails, business docs) and was only partially ignored — E2E runs
  left it dangling as untracked, which is how ~12 MB of artifacts nearly
  landed in a commit. Ignore the whole directory (nothing under it is
  tracked); replaces the narrower business-docs rule.
- backend/.dockerignore: the granular storage/* rules missed
  storage/previews, so locally generated previews were copied into
  production images. Exclude storage entirely — the Dockerfile creates
  the needed directories itself (RUN mkdir -p, Dockerfile:96).
- fileSecurityUtils.js: remove getSafeFilename — zero callers across the
  repo, and its private extension whitelist silently drifted from the
  real validation paths (see #834), which is exactly the trap dead
  security code sets.
2026-07-19 00:40:53 +02:00
Paul Nothaft c8eb334637 test(uploads): harden frontend map parser, drop dead getSafeFilename edit (codex review of #834)
- getFrontendExtensionMap now tolerates quoted keys and trailing comments
  and throws on any other unparsable map line, so future syntax drift fails
  loudly instead of silently dropping entries from the comparison.
- Revert the .dng/.heic/.heif addition to getSafeFilename: the helper has
  no callers, so the edit was dead code. Live validation paths already
  cover these formats.
2026-07-18 23:46:40 +02:00
Paul Nothaft 14d5fa6ca5 chore(main): release 3.92.0-beta.0 (#840)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-18 19:02:48 +00:00
Paul Nothaft 84f370f4c2 Merge remote-tracking branch 'origin/main' into pr-834
# Conflicts:
#	frontend/src/components/gallery/UserPhotoUpload.tsx
#	frontend/src/i18n/locales/de.json
#	frontend/src/i18n/locales/en.json
#	frontend/src/i18n/locales/es.json
#	frontend/src/i18n/locales/fr.json
#	frontend/src/i18n/locales/nl.json
#	frontend/src/i18n/locales/pt.json
#	frontend/src/i18n/locales/ru.json
#	frontend/src/i18n/locales/sl.json
#	frontend/src/services/publicSettings.service.ts
#	frontend/src/utils/__tests__/fileTypes.test.ts
2026-07-18 21:02:10 +02:00
Paul Nothaft 8c260c4eeb Merge pull request #833 from PicPeak/feat/guest-upload-dng-raw
feat(uploads): DNG / camera-RAW support via embedded-preview extraction (#821)
2026-07-18 20:58:44 +02:00
Paul Nothaft ec69ad84f2 chore(main): release 3.91.0-beta.0 (#835)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-18 18:52:51 +00:00
Paul Nothaft d7ba781c0f Merge remote-tracking branch 'origin/main' into feat/guest-upload-dng-raw
# Conflicts:
#	backend/src/services/uploadSettings.js
#	backend/src/utils/fileSecurityUtils.js
#	frontend/src/utils/fileTypes.ts
2026-07-18 20:52:08 +02:00
Paul Nothaft ee9d2f70d3 Merge pull request #832 from PicPeak/feat/guest-upload-heic-dynamic-hint
feat(uploads): HEIC/HEIF support + dynamic format hint on guest upload (#821)
2026-07-18 20:47:36 +02:00
Paul Nothaft d0ccadbc99 fix(uploads): RAW derivative key collision, watermark skip, dev exiftool (codex review of #833 round 2)
- Derivative key collision: processUploadedPhotos/replacePhoto passed the
  client-supplied original filename as the RAW output basename, but thumbnails/
  heroes/previews are global keys — two galleries uploading IMG_0001.dng would
  overwrite each other's derivative. Use the unique stored newFilename instead.
  (processPhoto already used the unique photo.filename.)
- Watermark: the watermark path opens the original with sharp, which can't decode
  RAW, so it fell back to the original bytes and recorded the copy as watermarked.
  Skip RAW in generateForPhoto (like videos) so the watermark state stays honest
  until RAW watermarking is properly supported.
- exiftool added to Dockerfile.dev so dev/native runtimes don't accept a DNG then
  fail it with ENOENT.
2026-07-17 22:50:24 +02:00
Paul Nothaft b743ea0398 fix(uploads): apply RAW extraction in the actual async ingest path (codex review of #833)
The RAW/DNG extraction was only wired into processUploadedPhotos() (the
synchronous path), but real uploads queue to 'pending' and are handled by the
background worker → processPhoto(), which generated the thumbnail + dimensions
directly from the DNG (both fail) and then marked the photo 'complete' — success
with no thumbnail. Wire withProcessableImage() into processPhoto() (the live
path) and into photoReplacementService.replacePhoto() (replace-by-name), so all
three ingest paths extract the embedded JPEG preview for RAW.

Updates the processPhoto test's imageProcessor mock with the new
withProcessableImage dependency (pass-through for ordinary images).
2026-07-17 22:35:11 +02:00
Paul Nothaft 808d305549 fix(gallery): serve JPEG preview for non-displayable originals in lightbox (codex review of #832)
The lightbox falls back to photo.url (the ORIGINAL) when preview_url is null,
which happens by default (lightbox_preview_enabled=false). For HEIC/HEIF/DNG the
original bytes aren't renderable in an <img>, so the lightbox showed a broken
image. Now force preview_url for those formats (by MIME or extension) regardless
of the toggle, so the browser always gets the generated JPEG preview. Covers DNG
too (forward-compatible with #833).

EXPERIMENTAL caveat unchanged: whether the preview actually renders still depends
on the backend decoding the source — HEVC-in-HEIC on the prod Alpine image is
unverified, DNG needs exiftool (#833). Documented on the PR.
2026-07-17 22:21:43 +02:00
Paul Nothaft e732e13f24 fix(uploads): DNG magic must be a single entry (.every validation)
The magic-number check in validateFileContent uses .every(), so the two
endianness entries (II + MM) could never both match — an admin DNG upload would
be rejected at content validation. Use the little-endian II magic only (Apple
ProRAW / camera DNGs); a rare big-endian DNG fails the check and is rejected,
which is safe since the embedded-preview extraction validates real content.
2026-07-17 22:07:32 +02:00
Paul Nothaft c9b64d9c1a fix(uploads): register HEIC/HEIF with the file validator + fix admin format hint (codex review of #832)
Two findings from the Codex review:

- validateFileType requires an ALLOWED_MEDIA_TYPES entry, which had neither
  image/heic nor image/heif — so HEIC was rejected before sharp ever saw it,
  despite the EXTENSION_TO_MIME additions. Added both with a single 'ftyp'
  (offset 4) magic number (the check is .every, so alternatives can't be
  separate entries).

- Changing the shared upload.fileRequirements string to interpolate {{formats}}
  left the admin PhotoUpload caller passing only { limit }, rendering the
  placeholder literally (it was also already dropping {{sizeLimit}} from #823).
  The admin caller now passes formats + sizeLimit + limit, from the admin
  settings it already loads.
2026-07-17 22:06:40 +02:00
Paul Nothaft be2ec0a4a1 feat(uploads): DNG / camera-RAW support via embedded-preview extraction (#821)
Sharp's bundled libvips has no raw loader, so a DNG can't be thumbnailed
directly. This adds a preview-extraction step so RAW/DNG uploads get a proper
thumbnail + gallery preview while the original RAW is kept for download.

- imageProcessor: isRawFilename() + extractRawPreview() (exiftool extracts the
  embedded full-res JPEG — JpgFromRaw → PreviewImage → ThumbnailImage, validated
  with sharp) + withProcessableImage() which is a pass-through for ordinary
  images and swaps in the extracted JPEG for RAW. Wired into ingest
  (photoProcessor) and all three on-demand generators (ensureThumbnail/Hero/
  Preview). generateHeroImage/generatePreviewImage gained outputBasename so
  RAW-derived outputs stay named after the source.
- Dockerfile: add exiftool (confirmed present in Alpine v3.24 community).
- Format maps: dng → image/x-adobe-dng in uploadSettings.js and fileTypes.ts;
  ALLOWED_MEDIA_TYPES gains a DNG entry (TIFF magic numbers) so it passes the
  security file-validator.

Strictly gated by extension: nothing in this path runs for jpg/png/webp/etc, so
existing photos are unaffected. If extraction fails (corrupt RAW, no embedded
preview), the photo is marked 'failed' with a clear error — same as any
unreadable upload.

Verification boundary (please validate on a real DNG after the image rebuilds):
the exiftool extraction itself couldn't be exercised in the dev sandbox
(exiftool isn't a dev dependency and there's no DNG fixture). Unit tests cover
the gating (RAW detection + non-RAW pass-through + clean failure without
exiftool); existing processPhoto tests still pass. Known limitation: a DNG is
only accepted when the browser reports its MIME as image/x-adobe-dng (Chrome
does); browsers that send an empty type reject it client- and server-side —
a follow-up can add extension-based acceptance for the RAW set.

Companion to the HEIC/dynamic-hint PR; targets main only.
2026-07-17 21:51:21 +02:00
Dodothereal f4b685a5ab fix(uploads): allow configured raw formats
Assisted-by: Claude Code
2026-07-17 21:50:32 +02:00
Paul Nothaft 8e0005e170 chore(main): release 3.90.2-beta.0 (#826)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-17 19:45:29 +00:00
Paul Nothaft 43c6d22bdd Merge pull request #830 from PicPeak/fix/guest-upload-size-followup
fix(uploads): tighten guest max-file-size setting (codex review of #823)
2026-07-17 21:39:38 +02:00
Paul Nothaft 2b5b23b96f feat(uploads): HEIC/HEIF support + dynamic format hint on guest upload (#821)
Two of the three things from #821:

- HEIC/HEIF (iPhone) can now be enabled. Sharp's bundled libvips decodes `heif`
  input (verified: sharp.format.heif.input.file === true on 0.34.3 / libvips
  8.17.1), so thumbnails generate. Added heic/heif to EXTENSION_TO_MIME in both
  the backend (uploadSettings.js) and the frontend (fileTypes.ts) maps, which
  are kept in sync. (iOS Safari usually transcodes HEIC→JPEG at file selection,
  but a genuine .heic upload is now handled when it arrives.)

- The upload requirements hint no longer hardcodes "JPEG, PNG or WebP". New
  extensionsToLabel() renders the actually-configured, supported formats (e.g.
  "JPG, PNG, WEBP, MOV"), and upload.fileRequirements interpolates {{formats}}
  across all 8 locales. Unsupported extensions are dropped from the label so it
  never advertises a format the backend would reject.

DNG / camera RAW is deliberately NOT included: Sharp's libvips has no raw loader,
so a DNG would upload then fail thumbnailing (photo → 'failed', no preview).
Proper RAW support (embedded-preview extraction) is a separate PR.

Adds vitest coverage for extensionsToLabel + the HEIC mapping.
2026-07-17 21:39:34 +02:00
Paul Nothaft 0245e445ca Merge pull request #828 from PicPeak/fix/hero-logo-visible-null-validation
fix(events): accept hero_logo_visible: null on create/update (#822)
2026-07-17 21:39:12 +02:00
Dodothereal 433fb9a989 fix(uploads): show configured guest file types
Assisted-by: Claude Code
2026-07-17 21:35:53 +02:00
Paul Nothaft e03d13efde fix(uploads): tighten guest max-file-size setting (codex review of #823)
Three follow-ups from the Codex review of #823:

1. PublicSettings TypeScript interface was missing general_max_file_size_mb,
   so UserPhotoUpload's access produced TS2339 under `tsc -b` (build:check). CI
   didn't catch it because the pipeline runs `build` (esbuild, no typecheck),
   but it's a real type gap — the #614 count field is declared, this one wasn't.
   Added the optional numeric field.

2. The general-settings update endpoint validated general_max_files_per_upload
   but not general_max_file_size_mb, so an out-of-range value (0, -1, huge)
   could persist. publicSettings then advertised the raw value while
   getMaxFileSizeMb() normalised it — the guest UI would reject files the
   backend accepts. Added the same validate-and-clamp block (1..MAX_ALLOWED_FILE_SIZE_MB).

3. The update route cleared the file-count cache but not the new file-size
   cache, so for up to 60s the public endpoint could advertise a new limit
   while multer still enforced the old one. Now clears both under the same
   uploadLimitTouched guard.

Follow-up on the merged #823 (main-only), so this targets main only.
2026-07-17 21:30:48 +02:00
Paul Nothaft b97b130cad fix(events): accept hero_logo_visible: null on create/update (#822)
hero_logo_visible is nullable — null means "inherit the global
branding_logo_display_hero toggle" (#756, migration 152). But the create and
update validators used `.optional()` without `{ nullable: true }`, which only
skips `undefined`; an explicit `null` still ran `.isBoolean()` and failed with
HTTP 400 "Invalid value". Saving an event with `hero_logo_visible: null` (the
inherit state the frontend sends) was rejected on v3.45.2.

- Both routes: `body('hero_logo_visible').optional({ nullable: true }).isBoolean()`,
  matching the already-correct `hero_logo_size` rule next to it.
- Create handler: guard on `!= null` instead of `!== undefined` so an explicit
  null stores NULL (inherit) rather than being coerced to 0/false by
  formatBoolean on SQLite. The update handler already did `=== null ? null`.

Left hero_logo_position on plain `.optional()` on purpose: its column is NOT
NULL (no inherit migration) and its handler always resolves to a concrete value
via `|| brandingDefaults`, so null is genuinely invalid there — allowing it
would trade the 400 for a 500.

Adds smoke tests: PUT accepts hero_logo_visible: null and stores NULL; a
non-boolean value is still rejected.
2026-07-17 21:13:39 +02:00
Paul Nothaft 2a0361a83b Merge pull request #824 from PicPeak/fix/update-instructions-production-compose
fix(update): target docker-compose.production.yml in dashboard update steps + gate mailhog
2026-07-17 21:03:06 +02:00
Paul Nothaft 29f1d23a0a Merge pull request #823 from PicPeak/fix/guest-upload-max-file-size
fix(uploads): apply configured max file size to guest uploads (#613 follow-up)
2026-07-17 21:02:35 +02:00
Paul Nothaft 51a505e379 fix(update): target docker-compose.production.yml in dashboard update steps
Production installs use docker-compose.production.yml (the README's documented
path, pinned GHCR images, no dev services), but the dashboard's update
instructions emitted bare `docker compose pull` / `up -d`. Bare `docker compose`
operates on docker-compose.yml — a different, build-based stack — so a
production user who followed the steps:
  - never pulled/recreated their real containers (stayed on the old version,
    e.g. stuck on 3.44.0 after "updating" to 3.45.2), and
  - started the dev-only mailhog service that docker-compose.yml defines
    (reported restart-looping).

The backend runs inside a container and can't stat the host's compose files, but
docker-compose.production.yml passes PICPEAK_RELEASE_CHANNEL into the backend env
and docker-compose.yml does not. detectEnvironment() now derives
isProductionCompose from it, and the Docker update steps prepend
`-f docker-compose.production.yml` when set. The non-production branch keeps the
bare commands but the warning now tells users to add `-f docker-compose.production.yml`
if they installed with it.

Also gates the mailhog service in docker-compose.yml behind a `dev` compose
profile so a plain `docker compose up -d` never starts it (opt in with
`docker compose --profile dev up -d`). Nothing depends on it (SMTP_HOST comes
from .env), so gating is safe. Verified: `docker compose config` lists mailhog
only with `--profile dev`; production compose is unchanged.

Adds unit tests for the production-vs-default command generation.
2026-07-17 20:55:49 +02:00
Paul Nothaft 1e38d84808 fix(uploads): apply configured max file size to guest uploads (#613 follow-up)
The admin's Settings → General → "Max File Size (MB)" value
(general_max_file_size_mb) never applied to guest gallery uploads — the guest
route hardcoded multer's per-file cap at 50MB (gallery.js) and the guest UI
hardcoded the same 50MB client-side guard and "max 50MB" hint text. So a guest
could not upload a large video even when the admin raised the limit (reported by
mat1990dj on #613). Same class as the file-count miss fixed in #614, for size.

- uploadSettings.js: new getMaxFileSizeMb()/getMaxFileSizeBytes() reading
  general_max_file_size_mb (default 50MB, cached 60s, clamped to a 10GB ceiling),
  mirroring getMaxFilesPerUpload.
- gallery.js (guest upload): multer limits.fileSize now resolves from the
  setting; a LIMIT_FILE_SIZE error returns an actionable "max N MB" message.
- publicSettings.js: exposes general_max_file_size_mb (default 50) so the gallery
  UI can render the real limit and guard client-side before an oversized POST.
- UserPhotoUpload.tsx: reads the limit, uses it for the client-side size guard,
  and passes it to the requirements hint. The "max 50MB" literal in
  upload.fileRequirements is now interpolated ({{sizeLimit}}) across all 8
  locales; adds upload.fileTooLarge (en/de; others fall back to en).

Scope: guest path only (the reported gap). The admin path keeps its generous
10GB cap — admins are trusted and default 50MB would otherwise regress large
admin video uploads. Format and batch-size limits already work correctly and are
untouched. Adds SQLite-backed unit tests for the new getter.

Verified end-to-end on a booted instance: admin sets 500MB → persisted → public
settings exposes 500 → guest multer sources its cap from it.
2026-07-17 20:41:27 +02:00
Paul Nothaft 1b32d4691e chore(main): release 3.90.1-beta.0 (#820)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-17 07:35:38 +00:00
Paul Nothaft e7ca8bdb7f Merge pull request #817 from PicPeak/fix/legacy-events-router-bola
fix(security): remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq)
2026-07-17 09:29:20 +02:00
Paul Nothaft 6cd546e86a fix(security): remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq)
The legacy gallery router mounted at /api/events exposed create/list/update/
delete/extend guarded by adminAuth ALONE — no requirePermission, no
requireEventOwnership. adminAuth only checks the token is a valid type:'admin'
session, which every back-office role holds, down to read-only `viewer`. So any
non-super-admin account could:
  - GET /api/events → every gallery's bcrypt password_hash, share_token, and
    client name/email (the list handler selects * and mapEventForApi keeps
    those columns),
  - PUT /api/events/:id → reset any gallery's password (full takeover),
  - DELETE /api/events/:id → delete any gallery,
all bypassing the per-photographer ownership isolation the canonical
/api/admin/events router enforces. Affects any instance with more than the
single super_admin.

Fix: remove the legacy router entirely (mount + require + src/routes/events.js).
It was a superseded duplicate of /api/admin/events and unused by the frontend
EXCEPT for one live route — POST /:id/extend (the "Extend expiration" UI action,
which hit /api/events/:id/extend via the api client's /api base). That route is
migrated to the canonical mount as POST /api/admin/events/:id/extend with the
same guards as every other gallery mutation (adminAuth + requirePermission
('events.edit') + requireEventOwnership), and the frontend is repointed to it.
Behaviour of the extend itself is unchanged (expires_at + reactivate).

Verified end-to-end on a booted instance: /api/events (all methods) now 404;
/api/admin/events/:id/extend returns 401 unauth, 200 for the owner, 403 for a
non-owning editor; the full login→create→extend flow works. Adds a regression
test pinning the router removal and the extend ownership check.
2026-07-17 09:16:51 +02:00
Paul Nothaft 7f22a9ee3d chore(main): release 3.90.0-beta.0 (#816)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-16 12:08:11 +00:00
Paul Nothaft f12606b4e0 Merge pull request #806 from PicPeak/feat/oidc-sso-phase1
feat(auth): OIDC SSO for admin users — phase 1
2026-07-16 14:04:39 +02:00
Paul Nothaft cbde7636aa Merge remote-tracking branch 'origin/main' into feat/oidc-sso-phase1
# Conflicts:
#	backend/src/middleware/maintenance.js
2026-07-16 13:53:28 +02:00
Paul Nothaft b5ac24ea46 chore(main): release 3.89.0-beta.0 (#814)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-16 11:43:50 +00:00
Paul Nothaft a77c2c2c57 Merge pull request #813 from PicPeak/feat/harden-picpeak-restore-robustness
feat(security): harden .picpeak restore robustness — sessions, roles, sequences
2026-07-16 13:39:17 +02:00
Paul Nothaft 7ebc232620 Merge pull request #811 from PicPeak/fix/security-advisories-backend
fix(security): close 4 open security advisories (backup takeover, share-login bypass, ZIP slip, chunked-upload traversal)
2026-07-16 13:37:28 +02:00
Paul Nothaft 199dab82ae Merge pull request #808 from PicPeak/fix/docker-image-os-cves
chore(security): close 21 frontend image CVEs — nginx 1.30 base + apk cache-bust
2026-07-16 13:37:25 +02:00
Paul Nothaft 340d91bdd5 feat(security): harden .picpeak restore robustness — sessions, roles, sequences
Implements the three restore-hardening items deferred from the #811 Codex
review (all validated against a real Postgres, see __tests__/integration/
picpeakRestorePg.test.js). Backend-only; targets main (feature, not a backport).

1. Global session cutoff (utils/sessionCutoff.js). A restore reassigns admin/
   customer/event ids, so ANY pre-restore JWT can rebind to a different restored
   principal. Revoking just the importing token wasn't enough. importFromPicpeak
   now stamps a unix-second cutoff in app_settings after the restore commits, and
   adminAuth / galleryAuth / verifyGalleryAccess / customerAuth reject any token
   whose iat predates it (cached 30s → one in-memory compare on the hot path).
   The operator's forced re-login mints a token past the cutoff, so it passes.

2. Role preservation across an RBAC replace (captureOperatorRole /
   preserveOperatorRole). The operator's role + granted permission NAMES are
   captured before the wipe; after roles/role_permissions are replaced the role
   is resolved by NAME against the restored data, and re-created with its grants
   if the backup omits it — so a crafted or cross-instance backup can't silently
   downgrade or lock out the operator. reinjectCurrentAdmin now returns the
   operator's id so the row can be re-pointed at the resolved role.

3. Postgres identity-sequence resync (resyncSequences). batchInsert writes
   explicit ids without advancing the sequences, so the next natural insert into
   any restored table collided on the PK. Runs AFTER commit (setval isn't
   transactional) and guards every table with a column-existence check —
   pg_get_serial_sequence RAISES on id-less tables like role_permissions.
   No-op on SQLite.

Tests: SQLite unit tests for the cutoff and role preservation; a gated Postgres
integration suite (npm run test:pg with PICPEAK_PG_TEST_URL) covering sequence
resync, the id-less-table guard, explicit-id reinject, role re-creation, and a
full cross-instance replaceAllTables run asserting operator preservation, role
re-establishment, FK integrity, and collision-free post-restore inserts.

Stacks on #811 (shares the reinject hardening); merge after it.
2026-07-16 12:56:34 +02:00
Paul Nothaft 38fd41aad3 fix(security): harden .picpeak restore operator-preservation (GHSA-qxfx follow-up)
The req.admin.id fix activated reinjectCurrentAdmin(); hardening its preservation
logic (found across Codex review rounds of #811):

- MFA hijack: reinject wrote back only password_hash/is_active/
  must_change_password, leaving a crafted backup's two_factor_* on the
  operator's row — it could strip or replace their second factor. The email-
  matched row is now updated with the operator's full AUTH set (login identity,
  password, and all two_factor_* columns). Relationship/audit FKs (role_id,
  created_by) are deliberately NOT forced from the snapshot: on a cross-instance
  restore those pre-restore ids may be absent from the backup and would dangle
  the FK (SQLite rolls back at commit); the restored row keeps its own valid
  values.

- Cross-instance restore rollback / FK safety: reinject matched only by email,
  so a backup shipping a different admin with the default `admin` username hit
  UNIQUE(username) and rolled the whole restore back; email and username could
  even collide on two different rows. Reconciliation is now non-destructive:
  the email-matching row is updated in place (id preserved → restored FKs like
  events.created_by stay valid); any different row holding the operator's
  username is RENAMED, not deleted (deletion would fire ON DELETE actions /
  dangle references); only when no row has the operator's email is a fresh row
  inserted, with created_by nulled and an explicit max(id)+1 id (batchInsert
  left the Postgres identity sequence unadvanced, so a sequence-based insert
  could collide).

- Stale session after restore: admin_users ids shift on restore, but the
  operator's live JWT is bound only to decoded.id (IP logged not enforced; the
  backup controls password_changed_at). The route now revokes the token (result
  checked and logged) and clears the admin cookie; the client redirects to a
  fresh login via a sessionInvalidated flag. Cookie clear is the unconditional
  guarantee.

Adds SQLite-backed reinject regression tests (in-place login/MFA restore with id
and FK columns preserved, username-only rename, email+username on different rows,
clean insert with created_by nulled) and the frontend redirect on
sessionInvalidated.

Deferred (design decisions / pre-existing, need a Postgres test env — see PR
discussion): global "invalidate all pre-restore sessions" cutoff; preserving the
operator's ROLE semantics across an RBAC-table replace; and resyncing Postgres
identity sequences after any restore (batchInsert leaves them behind max(id) —
pre-existing, affects every restored table).
2026-07-16 12:27:59 +02:00
Paul Nothaft 31bc01cb4b fix(security): sanitize chunked-upload filename (GHSA-pc72-jf53-w28j)
The chunked video upload stored req.body.filename unmodified and later built
the merged path as path.join(tempDir, uploadMeta.filename). path.join does not
neutralise '../', so a filename like '../../uploads/logos/evil.svg' escaped the
temp dir on merge and overwrote arbitrary files. Requires admin with
photos.upload.

Fix: path.basename() the client filename in initializeUpload() and reject
names that collapse to nothing. Adds a regression test.
2026-07-16 10:55:10 +02:00
Paul Nothaft 9cd6b08441 fix(security): reject ZIP-slip entries in archive/backup restore (GHSA-jfhw-fj23-fx6x)
node-stream-zip's extract(null, root) writes each entry to path.join(root,
entry.name) without neutralising '../', so a crafted archive entry named
'../../uploads/logos/evil.svg' escaped the target dir and overwrote arbitrary
files (logos, .env, route files → RCE on source deploys). Requires admin with
archives.restore.

Adds assertZipEntriesWithin() to utils/safePath.js — a lexical containment
check run on the entry list BEFORE extract() — and guards both extract sinks:
adminArchives.js (the reported route) and picpeakImportService.js (the sibling
.picpeak import, same sink). Adds unit tests for traversal, absolute-path, and
sibling-prefix entries.
2026-07-16 10:55:10 +02:00
Paul Nothaft 7dace044dc fix(security): share-login must not bypass gallery password (GHSA-9hmx-68vc-qpqw)
POST /auth/gallery/share-login validated only the 128-bit share token and then
minted a full type:'gallery' access token regardless of require_password —
computing requiresPassword at the end only to echo it, never enforce it. Anyone
holding a gallery's share link could read and download every photo in a
password-protected gallery via a direct API call, no password needed.

Fix: compute requiresPassword before minting; for a password-protected gallery
return { requires_password: true } with NO token and NO cookie. The client then
goes through /gallery/verify, which does bcrypt.compare the password. The public
(no-password) auto-login path is unchanged. The frontend already falls through
to the password prompt when share-login returns no token/event.

Adds route regression test covering the bypass, the public path, and bad tokens.
2026-07-16 10:55:10 +02:00
Paul Nothaft 348894efef fix(security): preserve current admin on .picpeak restore (GHSA-qxfx-4493-4v8f)
adminAuth populates req.admin, not req.user, so currentAdminId was always
undefined in the /api/admin/picpeak/import handler. reinjectCurrentAdmin()
then had no account to preserve and the admin_users table was fully replaced
by the uploaded backup — a crafted .picpeak let any admin with backup.restore
take over every admin account (critical). One-line fix: pass req.admin.id.

Closes GHSA-qxfx-4493-4v8f and its duplicate GHSA-pjp6-jcrj-3cr5.
2026-07-16 10:55:10 +02:00
Paul Nothaft efccecb3d8 chore(main): release 3.88.1-beta.0 (#810)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-16 08:36:29 +00:00
Paul Nothaft eadf282755 Merge pull request #807 from PicPeak/fix/settings-secret-exposure-mfa-maintenance
fix(security): mask backup credentials on read + unblock MFA login during maintenance
2026-07-16 10:32:19 +02:00
Paul Nothaft eb03b61268 chore(security): close 21 frontend image CVEs — nginx 1.30 base + apk cache-bust
The frontend image kept shipping vulnerable OS packages (nginx 1.28.3-r1,
curl/libcurl 8.19.0, c-ares 1.34.6) despite the apk upgrade line, for two
independent reasons:

1. The runtime stage's apk upgrade layer was cached indefinitely — the
   CACHEBUST build-arg CI passes (github.run_number) was only declared in
   the builder stage, and ARGs don't cross stage boundaries. Both
   Dockerfiles now redeclare CACHEBUST in the runtime stage and consume it
   in the apk RUN, so every build re-runs the upgrade and picks up current
   Alpine security updates.

2. nginx itself can never upgrade via apk on the nginx.org-based image:
   the bundled nginx-module-* packages pin the exact nginx version, so
   Alpine's patched 1.28.3-r4 is unreachable (verified empirically —
   apk add --upgrade nginx is a silent no-op). nginx fixes must come via
   the base tag, so bump to nginx:1.30-alpine (current stable, 1.30.4 on
   Alpine 3.24, same nginx.org conf.d layout — drop-in).

Verified: local image build scans clean with Trivy (0 OS findings, was 21);
container serves /health, SPA fallback, and BRAND_TITLE envsubst as non-root
nginx user.

Closes code-scanning alerts 371-374, 376-392 (nginx HTTP/2 & module CVEs,
curl CVE-2026-5773/-6276 + 6 medium, c-ares CVE-2026-33630).
2026-07-16 10:29:31 +02:00
Paul Nothaft 07f2c90055 fix(security): mask backup credentials on read + unblock MFA login during maintenance
Two pre-existing bugs surfaced while reviewing #806 (kept separate per
scope policy — no OIDC code here):

- backup_s3_secret_key and backup_rsync_ssh_key (an SSH PRIVATE KEY)
  were returned in PLAINTEXT by GET /admin/backup/config and by the
  generic settings reads (GET /admin/settings and /admin/settings/:type
  — which mask the recaptcha/umami/rybbit keys but not these). All
  three now mask with the established bullet sentinel, and
  PUT /admin/backup/config skips the sentinel on write so the edit form
  round-trips without clobbering stored credentials (same pattern as
  the email/WhatsApp config endpoints)
- /api/auth/admin/login/mfa was missing from the maintenance-mode
  allowlist: the first login step passed, the second factor got a 503 —
  any MFA-enrolled admin was locked out exactly while maintenance mode
  was on

Regression tests: masking on all three read paths, sentinel round-trip
preserves stored values, real rotation still writes.
2026-07-16 10:13:34 +02:00
Paul Nothaft e91c7deaa4 fix(oidc): local-credential lockout, session hydration, split-origin gaps (codex round 3)
- OIDC-owned accounts can never authenticate locally: the password
  login rejects auth_provider='oidc' rows outright (generic 401), and
  the super-admin password reset refuses them with a clear message —
  previously a reset would have minted a local password bypassing the
  IdP's MFA/access policies
- /auth/session now returns a full adminUser payload (role join) and
  AdminAuthContext hydrates user state from it: an SSO redirect
  establishes the session without any login JSON, which left the header
  identity blank and current-admin form defaults empty
- the /sso/login error path redirects absolute to the frontend base
  (same split-origin reasoning as the callback)
- docker-compose.yml passes API_URL through to the backend (production
  compose uses env_file and needs nothing; dev compose is gitignored)
- authSession.symmetry test mock taught the joined admin lookup
  (leftJoin, prefixed columns, aliases) — the route change made the old
  mock throw, which read as "table missing, trust token"

Tests: new case pins that a known-good password on an OIDC-owned row
still gets 401. 14/14 OIDC, 13/13 symmetry.
2026-07-16 10:07:59 +02:00
Paul Nothaft 7f7d38a57f fix(oidc): security + robustness hardening from codex review rounds 1-2
Round 1:
- bind SSO identities to (external_issuer, external_subject): OIDC only
  guarantees sub uniqueness within an issuer, so a sub-only lookup let a
  newly configured IdP's user inherit an old IdP's admin account on
  subject collision; migration 162 gains external_issuer + composite
  unique index (unmerged migration, edited in place)
- fetch UserInfo (with sub cross-check) when the ID token carries no
  email — spec-compliant providers may serve email/profile claims only
  there; ID-token claims win on merge
- allowlist /admin/sso/login + /callback in maintenance mode, or
  SSO-only (JIT) admins are locked out exactly when they need in
- strip reserved keys (oidc_client_secret, setup_token) from BOTH
  generic settings reads (GET / and GET /:type)

Round 2:
- redirect_uri prefers API_URL (the API's public origin — where the
  state cookie lives); final redirects absolute to the frontend base;
  login button builds its URL via buildResourceUrl — split-origin
  deployments (absolute VITE_API_URL) work end to end
- PUT /sso validates the MERGED resulting state (partial update cannot
  blank issuer/client while enabled=true survives; enabling requires a
  derivable redirect URI)
- openid scope forced into oidc_scopes on save
- discovery-cache key includes a secret fingerprint (multi-worker
  secret rotation)
- email→admin linking claims the row atomically (conditional update on
  external_subject IS NULL) — concurrent first-time callbacks with the
  same verified email but different subjects can't both authenticate

Tests: mock IdP gains userinfo endpoint + email-via-userinfo-only mode;
new cases pin the userinfo merge and issuer-collision non-inheritance;
redirect assertions updated for absolute URLs. 13/13.
2026-07-16 09:41:48 +02:00
Paul Nothaft ac1838fbd7 fix(oidc): fail clearly when no public base URL is configured
CI exposed that getFrontendBaseUrl() returns '' without FRONTEND_URL or
the general_site_url setting (local runs were masked by backend/.env):
the flow then sent a RELATIVE redirect_uri to the IdP, which surfaced
as an opaque IdP-side error. getRedirectUri now throws OIDC_BAD_CONFIG
with an actionable message (login route maps it to sso_error=config);
the settings GET degrades to an empty redirect_uri instead of 500ing.
The test pins FRONTEND_URL explicitly so it runs identically with and
without a local .env.
2026-07-16 08:59:48 +02:00
Paul Nothaft ed5fc5ad5c feat(auth): OIDC SSO for admin users — phase 1 (#798)
Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.

Backend:
- migration 162: admin_users.auth_provider ('local' default) +
  external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
  rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
  cached discovery, sub-based identity binding — email linking of
  existing admins only with email_verified=true; JIT behind
  oidc_autoprovision with configurable default role and an unusable
  random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
  cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
  the callback reuses the local login's session establishment
  (completeAdminLogin split into establishAdminSession + JSON wrapper)
  so SSO sessions are identical downstream; every failure lands on
  /admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
  write-only, redacted to a set-flag; registered ABOVE the generic
  /:type matcher which would shadow them); oidc_client_secret added to
  the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
  login page

Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
  autoprovision + default role, button label, enable toggle, redirect
  URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
  param surfaced as translated toasts; EN+DE i18n

Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.

MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
2026-07-16 08:54:26 +02:00
Paul Nothaft f0cdcddb92 chore(main): release 3.88.0-beta.0 (#805)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-15 21:07:21 +00:00
Paul Nothaft 0751a08aa6 Merge pull request #804 from PicPeak/fix/gallery-feedback-filter-chips
fix(gallery): show feedback filter chips on desktop for galleries without categories
2026-07-15 23:02:48 +02:00
Paul Nothaft d64eef8abf Merge pull request #803 from PicPeak/fix/event-type-hardcoded-deps
fix(event-types): un-hardcode event type dependencies in v1 API and CRM
2026-07-15 23:02:35 +02:00
Paul Nothaft 109aba8598 Merge pull request #801 from PicPeak/feat/setup-wizard-event-types
feat(setup): event-types step in first-run wizard + un-hardcode event type dependencies
2026-07-15 23:01:36 +02:00
Paul Nothaft b9283386a5 fix(gallery): show feedback filter chips on desktop for galleries without categories (#802)
The desktop feedback-filter chips (All/Likes/Saved/Rated/Commented)
were nested inside the categories row conditional, and the standalone
fallback block is lg:hidden — so a gallery without photo categories
(the default) rendered no feedback filter at all on desktop, despite
the docs and a fully working filter implementation behind it.

Render the row whenever either part has content and gate only the
category scroller on categories existing. The media-count label hides
below lg when no categories exist so the mobile layout stays unchanged
(mobile keeps its own chip block). With-categories galleries render
identically to before.

Regression test pins both chip groups in the DOM with and without
categories (fails on the pre-fix component).
2026-07-15 22:51:22 +02:00
Paul Nothaft 5da1c3a12f fix(event-types): un-hardcode event type dependencies in v1 API and CRM (#800)
Split out of #801 so the public-API behavior change gets its own review:

- v1 POST /events validates event_type against the live event_types
  catalog instead of the hardcoded whitelist — custom types created in
  Settings → Event Types were rejected with 400. BREAKING for the
  never-seeded 'family' slug, which the old whitelist silently accepted
  and wrote as a dangling reference; create a matching event type to
  keep using it
- new GET /api/v1/event-types (read scope) so API-token clients can
  discover valid slugs; OpenAPI enum replaced accordingly
- standalone contract→event conversion no longer hardcodes
  event_type: 'wedding' — it resolves via crm_default_event_type, then
  the catalog catch-all, same chain as quote→event conversion
- resolveDefaultEventType moved from quoteService to eventTypeService
  for shared use (no behavior change)
2026-07-15 22:31:28 +02:00
Paul Nothaft 93301002ba refactor: move v1 API + CRM event-type un-hardcoding to a follow-up PR
Keeps #801 scoped to the setup-wizard event-types feature and its
load-bearing guards. The v1 validator/discovery endpoint and the
contract-conversion default fix ship separately so the public-API
behavior change gets its own review weight.
2026-07-15 22:30:00 +02:00
Paul Nothaft f8ba669716 fix(event-types): harden setup window + catalog validation (codex review)
Three review rounds on PR #801; fixes in response:

- isValidEventType: live catalog is authoritative when it has rows — a
  deleted or deactivated slug no longer validates via the legacy
  fallback (fallback now only serves an empty-catalog install)
- deleteEventType: refuse deleting the last (and last ACTIVE) type;
  updateEventType: refuse deactivating the last active type (unknown
  slugs are rejected since the validator change, so an empty active
  catalog would brick event creation)
- setup window fails closed: only an explicit stored `false` opens it
  (a portable-backup restore can leave the key absent) and a normal
  admin login durably closes it (abandoned-wizard case)
- reserved bootstrap keys (setup_wizard_completed, setup_token) are
  stripped from ALL generic settings upserts (/general, /security,
  /analytics, /seo) so the marker is genuinely one-way
- wizard step: deletes ordered so the catalog can never end up empty,
  and a genuinely failed system-type deletion reloads the list and
  stays on the step instead of advancing past the only window in which
  it can be retried
- CreateEventPage: snap the hardcoded initial 'wedding' selection to
  the first active type when the catalog no longer contains it
- v1 API: new GET /event-types (read scope) so token clients can
  discover valid slugs; OpenAPI enum replaced with the live-catalog
  description
2026-07-15 22:21:20 +02:00
Paul Nothaft 00fff24a1c test(v1): stub eventTypeService in events.create suite + cover unknown-type 400
The catalog-backed event_type validator (#800) makes a db('event_types')
lookup before the handler runs, which consumed the first queued mock
chain and shifted the pinned db() call sequence — 5 tests failed on CI.
Stub isValidEventType to true (validation isn't this suite's subject)
and add an explicit test for the new 400-on-unknown-type path.
2026-07-15 21:36:50 +02:00
Paul Nothaft 7eb6357b4a feat(setup): event-types step in first-run wizard + un-hardcode event type deps (#800)
Fresh installs can now shape the event-type catalog during the setup
wizard — rename, delete or replace the seeded defaults while nothing
references them. On existing installs system types stay protected.

- New wizard step between features and config: edit name/URL prefix,
  remove, or add types; defaults shown as recommendations
- setup_wizard_completed app setting (migration 161): seeded true when
  an admin already exists, false on fresh installs; POST /api/setup/
  complete (adminAuth) flips it when the wizard finishes
- deleteEventType: system types deletable only while the flag is unset;
  in-use check extended to quotes; per-type reminder template
  (event_reminder_<slug>) is deleted with the type
- reminder-template self-heal no longer resurrects templates for slugs
  removed from the catalog
- v1 API event creation validates event_type against the live catalog
  instead of a hardcoded whitelist (custom types were rejected; the
  never-seeded 'family' slug is no longer silently accepted)
- contract→event conversion resolves the event type via
  crm_default_event_type / resolveDefaultEventType instead of
  hardcoding 'wedding' (resolveDefaultEventType moved from quoteService
  to eventTypeService for reuse)
2026-07-15 21:30:23 +02:00
Paul Nothaft aab9e1a937 chore(main): release 3.87.0-beta.0 (#797)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-11 20:24:23 +00:00
Paul Nothaft ffd4a7eee6 Merge pull request #796 from Luca-Timo/feat/invoice-vat-note
feat(invoices): configurable VAT note under MwSt. line + fix multi-page page-number overlap (#794)
2026-07-11 22:21:41 +02:00
Luca 1476884dd0 feat(invoices): configurable VAT/free-text note + fix multi-page page-number overlap (#794)
Two invoice-PDF changes from #794.

1. VAT / free-text note (Benedikt's request, placement A). A new
   `crm_invoices_vat_note_text` setting (Settings → CRM → Invoices) prints a
   free-text line directly under the MwSt. row on every invoice. Data-driven:
   the admin types the exact wording (Austrian Kleinunternehmer § 6 Abs. 1 Z 27
   UStG, German § 19, reverse-charge, …) — no jurisdiction hardcoded. The
   totals-block reserve grows by the measured note height so a long note can't
   push the grand total into the footer. Read in invoice/render.js, threaded
   through normaliseContext, drawn in drawTotals. Empty → row omitted; quotes
   unaffected.

2. Multi-page footer overlap. On a full continuation page the line-item table
   filled to the bottom margin, but the "Seite X von Y" stamp was drawn at
   marginBottom-12 — INSIDE that fill zone — so items overlapped the page
   number. Move the stamp into the bottom margin (below the content edge),
   zeroing that page's bottom margin during the write so it can't trigger
   PDFKit's auto-page-break. Verified: on a full page the lowest item text is
   at pdfkitY ~790 while the page number sits at ~816 — ~26pt clearance.

Tests: render the note on a single page (byte-delta proves it renders) and
paginate a long invoice with the note (2–3 pages, no stray blank page).
2026-07-11 02:01:20 +02:00
Paul Nothaft e3d597b89a Merge pull request #787 from PicPeak/ci/push-images-to-dockerhub
ci(docker): also publish images to Docker Hub (picpeak/backend, picpeak/frontend)
2026-07-10 20:35:58 +02:00
Paul Nothaft ea9caa9c5d chore(main): release 3.86.0-beta.0 (#795)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-10 18:30:28 +00:00
Paul Nothaft d51112e761 Merge pull request #790 from Luca-Timo/feat/category-reorder
feat(categories): per-event category ordering — global default + override (#782)
2026-07-10 20:26:11 +02:00
Luca a4b4485d32 fix(categories): address PR #790 review — event ownership, migration renumber, nits
- 🔴 Event ownership: GET /event/:eventId and DELETE /reorder/:eventId now use
  requireEventOwnership; POST /reorder (event_id in body) gets the equivalent
  inline check (super_admin bypasses; others limited to owned/ownerless events).
  New test covers a settings.edit-holding non-super_admin blocked (403) on all
  three per-event routes.
- 🔴 Migration renumber: 158→159, 159→160 (upstream #788 already took 158);
  headers + the test's require path updated.
- 🟢 Nits: stale inline "Drag the arrows" fallback → "Use the arrows" (matches
  en.json; control is click-only); invalid bg-accent-dark/150 → bg-accent-dark.
2026-07-10 20:10:40 +02:00
Luca 8d0a946478 test(categories): integration tests for layered category ordering (#782)
Real-DB coverage: migration 158 backfill; global default reorder + a
non-customised event following it; per-event override + isolation from other
events; override accepts globals / rejects a foreign event's category; reset
clears the override; create appends.
2026-07-10 16:24:17 +02:00
Luca 4698402b54 feat(categories): per-event category ordering — global default + override (#782)
Order a gallery's categories in the flow of the day instead of A–Z. Two layers,
resolved per event: per-event override > global default > name.

- migration 158: photo_categories.display_order (global default), backfilled
  from the current alphabetical order so existing galleries don't reshuffle.
- migration 159: event_category_order (event_id, category_id, position) — the
  per-event override; no backfill, every event starts on the default.
- utils/categoryOrder: shared resolution used by the admin event view and the
  public gallery; fails safe to the global default if the table is absent.
- adminCategories: POST /reorder sets a per-event override (globals +
  event-specific, interleaved); DELETE /reorder/:eventId resets; POST
  /reorder-global sets the global default. Ordering endpoints + create append.
- gallery renders the resolved order.
- Settings → Photo Categories reorders the global default; an event's Categories
  tab reorders that gallery (one combined list + Reset to default). Up/down
  buttons — no drag-and-drop dependency.
- en/de strings.
2026-07-10 16:24:17 +02:00
Paul Nothaft ed0fa3241b chore(main): release 3.85.0-beta.0 (#793)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-10 13:38:57 +00:00
Paul Nothaft 54676424f2 Merge pull request #788 from PicPeak/feat/slideshow-order-category
feat(slideshow): per-event play order + category filter (#202)
2026-07-10 15:35:45 +02:00
Paul Nothaft b41cb1586d chore(main): release 3.84.1-beta.0 (#792)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-10 12:21:09 +00:00
Paul Nothaft 1f3bc3c343 Merge pull request #791 from PicPeak/fix/docker-v-tag-via-ref
fix(ci): publish v-prefixed image tags via type=ref,event=tag (#668)
2026-07-10 14:17:11 +02:00
Paul Nothaft 39db7bf6cb fix(ci): publish v-prefixed image tags via type=ref,event=tag (#668)
#783 added `type=semver,pattern=v{{version}}` to the merge-job metadata,
but metadata-action silently dropped it on prereleases — the 3.84.0-beta.0
build published only :3.84.0-beta.0 + :sha, not :v3.84.0-beta.0 (verified
in the merge-backend push log + GHCR: :v3.84.0-beta.0 → 404).

Replace the v{{version}}/v{{major}} semver patterns with type=ref,event=tag,
which emits the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0) for both
stable and beta tags — exactly the string users pin (matches the GitHub
release). Applies to both backend + frontend merge metadata steps.

Takes effect on the next release build. The bare :3.84.0-beta.0 tags stay
(the {{version}} patterns are unchanged), so both forms resolve.
2026-07-10 14:13:17 +02:00
Paul Nothaft aeade94a35 Merge pull request #786 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.84.0-beta.0
2026-07-10 13:04:13 +02:00
Paul Nothaft b768a53c5b feat(slideshow): per-event play order + category filter (#202)
The Live Slideshow already covers the core of #202 (fullscreen kiosk,
live-appending new uploads, timing/transitions/watermark, per-event
opt-in via the share link). This adds the two customization dimensions
the reporter also asked for:

- **Play order** (show_order): 'chronological' (upload order, default) or
  'random' — the client shuffles the initial set (Fisher-Yates) so
  live-appended uploads keep working.
- **Category filter** (show_category_id): restrict the slideshow to a
  single photo category (NULL = all photos, default). Enforced
  server-side on the slideshow /photos access and mirrored in the
  /session + /state photo_count, so the kiosk viewer can't widen the set.

Per-event enable/disable (default off) is unchanged — it's the existing
'Generate/Disable slideshow link' flow (no token = no slideshow).

- Migration 158: show_order (default 'chronological') + show_category_id.
- Admin: Play-order dropdown + category picker in the Live Slideshow card
  (picker hidden for events without categories); EN + DE i18n.
- Verified: migration (SQLite + PG); live API (category filter → 3/2/5
  photos + matching count; order propagates) and the running kiosk
  requests exactly the filtered set; tsc clean, 136 backend tests pass.
2026-07-10 10:46:54 +02:00
Luca 1f19fbb1b2 ci(docker): mirror published images to Docker Hub
Add picpeak/backend + picpeak/frontend on Docker Hub alongside GHCR. The
merge jobs already assemble the multi-arch manifest from the per-arch GHCR
digests via 'imagetools create'; adding Docker Hub to metadata-action's
images list + a Docker Hub login makes the same command push the manifest to
both registries (blobs copied from GHCR). No change to the build-by-digest
jobs.

Full tag parity (main, stable, latest, semver, sha). Gated on
DOCKERHUB_ENABLED (github.repository == PicPeak/picpeak) so forks stay
GHCR-only and keep building. Requires repo secrets DOCKERHUB_USERNAME and
DOCKERHUB_TOKEN.
2026-07-10 10:26:06 +02:00
Paul Nothaft 03ded870bf chore(main): release 3.84.0-beta.0 2026-07-10 10:20:17 +02:00
Paul Nothaft df5aeaba41 Merge pull request #785 from PicPeak/docs/releasing-stable-version-alignment
docs(releasing): align stable version to main on promote (Option A)
2026-07-10 10:20:04 +02:00
Paul Nothaft 279e0472c7 Merge pull request #784 from PicPeak/feat/admin-github-repo-button
feat(admin): GitHub repo button in the sidebar footer (#778)
2026-07-10 10:19:49 +02:00
Paul Nothaft 2ee4146d9a Merge pull request #783 from PicPeak/fix/docker-versioned-tags-v-prefix
fix(ci): publish v-prefixed image tags so :vX.Y.Z resolves (#668)
2026-07-10 10:19:22 +02:00
Paul Nothaft 5dea0c9695 docs(releasing): align stable version to main on promote (Option A)
The two release-please tracks count independently — main bumps on every
merge, stable only on promotion — so they drifted far apart (main
v3.83.x-beta while stable sat at v3.45.0 for the same code). Document
the alignment convention: a promotion pins the stable version to main's
base version via a Release-As commit (new step 5 in the cut procedure),
so stable tracks main instead of lagging.

Also records the release-engineering note that release-please.yml must
keep target-branch: stable (the missing pin cut a bogus v2.7.0 once).
2026-07-10 10:03:30 +02:00
Paul Nothaft d3d7df46f2 feat(admin): GitHub repo button in the sidebar footer (#778)
Adds a subtle 'View PicPeak on GitHub' link in the admin sidebar footer
(next to the version/storage widgets), so admins can reach the repo —
star it, browse source, report an issue — from anywhere in the dashboard,
not just the setup screen.

- Centralizes the repo URL as `repoUrl` in utils/githubReleaseUrl.ts
  (githubReleaseUrl now derives from it) so the org URL lives in one place.
- target=_blank + rel=noopener noreferrer; EN + DE i18n
  (`admin.viewOnGithub`); dark-mode aware, matches the muted footer style.
2026-07-10 09:50:05 +02:00
Paul Nothaft 784d059c3d fix(ci): publish v-prefixed image tags so :vX.Y.Z resolves (#668)
docker/metadata-action's type=semver strips the leading 'v', so releases
published only :3.45.0 / :3.83.1-beta.0. But git tags + GitHub releases
are named v3.45.0, so anyone pinning ghcr.io/.../backend:v3.45.0 (the
obvious choice) hit 'manifest unknown' — exactly #664.

Add v-prefixed semver patterns (v{{version}}, v{{major}}.{{minor}},
v{{major}}) alongside the existing bare ones, for both backend and
frontend. Now both :v3.45.0 and :3.45.0 resolve.

Applies to future releases; the already-published v3.45.0 only has the
bare :3.45.0 tag (retagging past releases is out of scope).
2026-07-10 09:46:10 +02:00
Paul Nothaft dbe4b588eb chore(main): release 3.83.1-beta.0 (#776)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-09 11:17:23 +00:00
Paul Nothaft 274ef0cd73 Merge pull request #774 from PicPeak/fix/release-please-stable-target
fix(release): target stable in release-please + undo bogus 2.7.0 bump
2026-07-09 13:13:23 +02:00
Paul Nothaft 65ac6eddac fix(release): target stable in release-please.yml + undo the bogus 2.7.0 bump
The stable release-please workflow (release-please.yml, triggered on
push to stable) had no `target-branch`, so it defaulted to the repo
default branch (main) and computed the next version from main's stale
`.release-please-manifest.json` (2.6.1) — cutting a spurious **v2.7.0**
stable release (a version regression from 3.44.0) when #771 landed on
stable, and bumping main's package.json + manifest to 2.7.0.

- release-please.yml: add `target-branch: stable` so it releases from
  the stable branch (3.44.0 → 3.45.0), like release-please-beta.yml
  already pins `target-branch: main`.
- Restore main's version to 3.83.0-beta.0 (backend + frontend
  package.json), set `.release-please-manifest.json` to 3.44.0, and drop
  the bogus 2.7.0 CHANGELOG section.

The v2.7.0 tag/release is deleted separately; the real v3.45.0 stable is
cut by re-running release-please on the stable branch after this lands.
2026-07-09 11:39:16 +02:00
Paul Nothaft be710eb1de Merge pull request #773 from PicPeak/release-please--branches--main
chore(main): release 2.7.0
2026-07-08 21:14:07 +02:00
Paul Nothaft 58a86af868 chore(main): release 2.7.0 2026-07-08 20:46:56 +02:00
Paul Nothaft 1250306d11 Merge pull request #772 from PicPeak/ci/run-tests-on-stable
ci: run the Tests workflow on stable-targeted PRs
2026-07-08 20:42:01 +02:00
Paul Nothaft 80503c52b9 ci: run the Tests workflow on stable-targeted PRs
tests.yml (the backend/frontend Jest+Vitest jobs) only triggered on
main/beta, but those two jobs are required status checks on the stable
branch. A beta→stable promote PR therefore hung forever on
'Expected — Waiting for status to be reported' for backend/frontend,
while docker-build / install-smoke / schema-drift (already listing
stable) ran fine. Add stable to the push + pull_request filters so the
Tests suite runs on promote PRs too.
2026-07-08 20:29:19 +02:00
Paul Nothaft c29ad747f2 chore(main): release 3.83.0-beta.0 (#770)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-08 10:42:24 +00:00
Paul Nothaft a71b9b5ed7 Merge pull request #769 from Luca-Timo/feat/messages-email-client
feat(messages): unified Messages email client (flag-gated, default off)
2026-07-08 12:38:58 +02:00
Luca 1e08a4fb15 fix(messages): PR #769 nits — server-side search, bare-email recipient, DE i18n
- Search now hits the backend (debounced) so results aren't truncated to the
  first loaded page: /received gains a `q` filter (sender/subject); the frontend
  passes the debounced term to every list query. The instant client-side filter
  stays for responsiveness.
- Reply/compose recipient extracts the bare address from a "Name <addr>" From
  header (extractEmail) — also used for the customer-lookup key.
- Added the full de + en `messages.*` and `email.customerMailbox.*` translation
  namespaces (were English inline-fallbacks only). Swiss-German spelling.
2026-07-07 18:39:04 +02:00
Luca bb235e72e5 fix(messages): PR #769 review — escape reply sender (XSS), gate backend routes, exact customer match
- BLOCKER: stored XSS via inbound sender display name. The reply stub built raw
  HTML with the unsanitized From name and set it as innerHTML on the composer's
  contentEditable (admin origin) → onerror JS ran on Reply. Now HTML-escape
  from_address in the stub AND DOMPurify-sanitize the composer body before
  innerHTML (defense in depth).
- Gate the NEW Messages routes with requireFeatureFlag('messaging') per-route
  (queue/:id, received/:id, item/*, identities, accounts, accounts/test, send)
  — NOT the shared /email mount, so the pre-existing email-config endpoints stay
  ungated.
- DocumentActionModal auto-picks a customer only on an EXACT email match
  (customer search is prefix/fuzzy), else leaves the picker to the admin.
2026-07-07 18:28:26 +02:00
Luca 99d5996561 feat(messages): search bar + Archive/Delete with Archived & Deleted folders
- Search box in the header filters the current folder's list (sender/subject),
  client-side; works across the merged Archived/Deleted views too.
- Archive and Delete are now implemented as soft moves: migration 157 adds
  mailbox_state ('active'|'archived'|'deleted') to email_queue + received_emails.
  Archive → 'archived', Delete → 'deleted' (trash). Restore → 'active'. Deleting
  FROM the Deleted folder is permanent (hard row delete).
- New cross-account system folders Archived + Deleted (merge sent + received of
  that state, sorted by date). Normal folders now exclude archived/deleted.
- Backend: /queue + /received gain a `state` filter (default active + legacy
  NULL); new POST /item/:kind/:id/state (archive/delete/restore) and DELETE
  /item/:kind/:id (purge, email.edit).
- Toolbar Archive/Delete wired; Restore + "Delete permanently" shown in the
  system folders.

Frontend build + migration boot (157) verified.
2026-07-07 16:16:51 +02:00
Luca c8cb4c88ca harden(messages): SSRF guard on mailbox host, strict sandbox + sanitizer, per-account TLS
Pre-upstream review hardening:
- /accounts + /accounts/test now reject private/internal IMAP/SMTP hosts via
  isPrivateIP(), matching /config + /incoming-config (SSRF).
- QueueDetail body iframe uses sandbox="" (script-less, no same-origin) like the
  inbound pane, instead of allow-same-origin.
- /send sanitizer drops the <style> tag + data: scheme to match the stricter
  inbound sanitizeBody allowlist.
- Per-account SMTP transport sets tls.rejectUnauthorized explicitly.
2026-07-07 16:04:11 +02:00
Luca 2c5c1d561b fix(messages): show the resolved customer's name in the doc-action modal
CustomerPicker uses its 'label' prop as the selected-customer chip text, so
passing the static 'Customer' string hid the actual name. Pass the resolved
customer's name as label and add a separate field heading.
2026-07-07 15:45:59 +02:00
Luca 0dbf863f60 feat(messages): create/select quote, contract, invoice, gallery from a message
The toolbar doc buttons now open a real document-action flow instead of just
loading an email template:

- DocumentActionModal resolves the customer from the message's sender address
  (customers/search); if no match, the CustomerPicker lets you search or create
  a passive customer inline.
- Create new -> jumps to the real editor prefilled with the customer
  (quotes/contracts/bills ?customerAccountId=), so numbering, line items and PDF
  all come from the existing CRM. Gallery opens the event editor.
- Select existing -> lists that customer's quotes/contracts/invoices and drops
  the chosen document number into a reply composer.
- Toolbar buttons are gated by the global feature flags (quotes/contracts/bills).

Adds the missing customerAccountId prefill to ContractEditorPage (quotes + bills
already had it). Frontend-only; reuses existing endpoints. Build verified.
2026-07-07 13:16:18 +02:00
Paul Nothaft 72cbb95b44 chore(main): release 3.82.6-beta.0 (#768)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-07 10:35:31 +00:00
Luca c622a35033 Merge pull request #358 from Luca-Timo/feat/messages-email-client
fix(messages): show only the mailbox local part in the sidebar (full …
2026-07-07 12:33:18 +02:00
Luca 88fe9f9844 fix(messages): show only the mailbox local part in the sidebar (full address on hover) 2026-07-07 12:32:08 +02:00
Luca 9596342d6a Merge pull request #764 from Luca-Timo/fix/dunning-backfill-on-enable
fix(workflows): backfill existing invoices + anchor dunning grace to due date when enabled (#750)
2026-07-07 12:31:15 +02:00
Luca 7cc1c59661 Merge pull request #357 from Luca-Timo/feat/messages-email-client
fix(messages): dynamic addresses, branding accent, compose/sync, per-identity SMTP
2026-07-07 12:08:59 +02:00
Luca f9c2b4ed75 fix(messages): dynamic addresses, branding accent, compose/sync, per-identity SMTP
Addresses dev-test feedback:
- Sidebar + reading-pane addresses (rechnungen@ / hello@ / no-reply@) are now
  read from the mail config via GET /admin/email/identities, not hardcoded.
- Highlight/selection now uses the branding accent (bg-accent-soft /
  text-on-accent-soft / accent-dark) instead of hardcoded blue, so it follows
  the admin's CI colour like the sidebar.
- Header gains "New message" (compose) and "Sync" (poll mailboxes now) buttons.
- Composer modal enlarged (920px, taller editable body).
- Customer mailbox (hello@) now has BOTH incoming (IMAP) and outgoing (SMTP)
  settings — migration 156 adds smtp_* + from_* to mail_accounts;
  emailProcessor.sendRawEmail takes an accountKey and sends via that mailbox's
  SMTP identity (falls back to the global from). Manual/reply sends from the
  Messages UI use the 'customers' identity, so replies come from hello@.

Frontend build + migration boot (156) verified.
2026-07-07 11:58:41 +02:00
Luca 976d52280e Merge pull request #356 from Luca-Timo/feat/messages-email-client
Feat/messages email client
2026-07-07 11:11:21 +02:00
Luca 0ed3bbefa1 chore(messages): describe the feature as 'unified' rather than by a third-party product name 2026-07-07 11:10:00 +02:00
Luca b96ad36f5d fix(messages): make the Messaging feature flag toggleable
The Messaging FeatureCard was a hardcoded-disabled 'roadmap' placeholder
(no-op toggle), so the messaging flag could never be turned on — the Messages
sidebar item + page stayed hidden. Wire the toggle to setFlag, mark it 'new',
and describe the actual admin Messages client.
2026-07-07 11:06:16 +02:00
Luca d0bdcb1a6a Merge pull request #355 from Luca-Timo/feat/messages-email-client
feat(messages): Outlook-style Messages email client (3 phases, flag-gated)
2026-07-07 10:48:46 +02:00
Luca 768e84711f feat(messages): Phase 3 — editable-template composer, reply + create actions
The CRM action buttons and Reply now open a send-composer, not a silent
templated send.

- New send-composer (MessageComposer): loads the rendered template (via
  previewTemplate) or a reply stub into a fully-editable body — the admin can
  rewrite it or drop a note anywhere before sending. On send it goes out as-is
  (server-sanitized), no template re-render.
- Backend: emailProcessor.sendRawEmail() sends admin-edited HTML via the
  configured SMTP identity; POST /admin/email/send sanitizes + sends + records
  the message in email_queue as a 'manual' send.
- Migration 155: email_queue.origin ('system' default | 'manual'). The Sent
  stream now splits by origin — Automated ▸ Sent = system, Customers ▸ Sent =
  the human/edited messages (which finally populates that folder). /queue gains
  an origin filter + returns origin.
- Toolbar wired: Reply enabled on inbound customer mail (prefilled + quoted);
  Create Quote/Contract/Invoice open the composer with that template loaded;
  Gallery opens a blank compose. Accounting/Forward/Archive/Delete stay disabled
  (later phases). After send, jumps to Customers ▸ Sent.

Deferred to a later phase: two-way IMAP write-back; per-identity SMTP (manual
sends currently use the global from address). Frontend build + migration boot
verified.
2026-07-07 10:42:54 +02:00
Luca da3a77dac4 fix(workflows): scope dunning backfill to its own flow via targetWorkflowId
Address review on #764: backfillDunningRuns emitted invoice.sent without a
target, so enabling dunning would also enroll every historical open invoice
into any custom invoice.sent flow. Pass the enabled flow's id through to
emitWorkflowEvent so the backfill only touches dunning. Also note the
computeWakeAt both-fields (untilVar + delay) behaviour change in its comment.
2026-07-07 10:33:01 +02:00
Luca ee46cf2125 feat(messages): Phase 2 — customer (hello@) mailbox + inbound body capture
Second inbound mailbox and real message bodies for the Messages viewer.

Backend:
- Migration 154: mail_accounts table (additional inbound mailboxes beyond the
  primary accounting IMAP) + received_emails.{account_key,to_address,body_html,
  body_text}. Additive/guarded.
- emailIntakeService now polls the accounting mailbox AND every enabled
  mail_accounts row. Extracted pollAccountOnce(cfg, {accountKey, routeToExpenses});
  accounting keeps its exact attachment->expenses behavior, customer mail is
  logged with its body and NOT routed to accounting. Inbound HTML is sanitized
  server-side (sanitize-html) on ingest.
- adminEmail: /received gains an account filter + returns account_key/to_address
  (bodies excluded from the list); new GET /received/:id returns the body;
  GET/POST /accounts + /accounts/test manage the extra mailboxes.

Frontend:
- Customers inbox now pulls the hello@ mailbox; reading pane renders the
  sanitized body in a strict (script-less, no same-origin) sandboxed iframe.
  Accounting inbox shows bodies too. Toolbar context keys off the mailbox.
- CustomerMailboxCard in Settings -> Email (behind the messaging flag) to
  configure + test the hello@ IMAP box.

No behavior change to the existing accounting inbound flow. Frontend build +
migration boot verified.
2026-07-07 10:11:58 +02:00
Paul Nothaft c0ac5c36a4 chore(main): release 3.82.5-beta.0 (#767)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-07 07:08:02 +00:00
Paul Nothaft 760a201b60 Merge pull request #766 from PicPeak/fix/date-input-invalid-crash
fix(admin): stop the event-date field crashing the page on backspace
2026-07-07 09:04:04 +02:00
Paul Nothaft 945026d446 fix(admin): stop the event-date field crashing the page on backspace
Repro: create an event, click into the date field, backspace a day digit.
The whole page white-screened and needed a reload.

Root cause: LocalizedDateInput's `toIso` only checked the day/month were
1-2 digits, not that they formed a real date — so a mid-backspace value
like "0/07/2026" was coerced to the string "2026-07-00" and committed to
`event_date`. CreateEventPage then rendered
`format(addDays(new Date('2026-07-00'), days))`, and date-fns `format`
throws RangeError on an Invalid Date — thrown during render, so React
tore the tree down to the error boundary.

Two complementary fixes:
- `toIso` round-trips the parsed y/m/d through `Date` and rejects
  impossible dates (day 00, month 13, 31 Feb…), so the field never
  commits a value that isn't a real calendar date.
- `useLocalizedDate.format`/`formatDistanceToNow` guard with `isValid`
  and return '' instead of throwing — defence in depth for the ~57 call
  sites that could otherwise white-screen on a bad date.

Verified live: backspacing to a partial/invalid date no longer crashes
(the form stays rendered), a valid date still commits + the expiry
preview renders. Adds a LocalizedDateInput regression test; tsc + build
green.
2026-07-07 08:56:04 +02:00
Paul Nothaft 2522d7e1ce chore(main): release 3.82.4-beta.0 (#765)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-07 05:13:53 +00:00
Paul Nothaft 0c2d319fc1 Merge pull request #763 from Luca-Timo/fix/email-language-and-payment-confirm
fix(email,ui): billing emails follow customer language + readable payment-check confirmation
2026-07-07 07:10:09 +02:00
Luca 26eeb76197 feat(messages): Phase 1 read-only Messages viewer (email client shell)
New admin "Messages" page — a three-pane mail viewer over the mail picpeak
already stores, feature-flagged behind `messaging` (default off):

- Sidebar account tree: All mail / Customers (hello@) / Accounting (rechnungen@)
  / Automated (no-reply@), matching the agreed IA.
- Automated + All Sent = email_queue (listQueue); Accounting + All Inbox =
  received_emails (listReceived). Customers folders show an explanatory empty
  state pending the hello@ mailbox (Phase 2).
- Reading pane renders the sent body from rendered_html (migration 119) in a
  sandboxed iframe; new GET /admin/email/queue/:id returns body + cc +
  attachment filenames (disk paths never exposed).
- Received supplier invoices: envelope + rasterized PDF viewer reusing the
  accounting inbound blob endpoint, plus "Open in Accounting inbox".
- Context toolbar (Reply/Forward/Create Quote-Contract-Gallery-Invoice /
  Book-as-expense-Re-bill) present but disabled — wired in later phases.

Reuses email.service, accounting inbound blob endpoint, RequireFeature +
PermissionGate (email.view), Tailwind dark: theming. No schema change.
2026-07-07 03:15:41 +02:00
Luca c0008be39b fix(email): sibling billing emails follow customer language too
Addresses the PR #763 review: the invoice_sent / storno_issued / payment-check
/ paid-admin-notification emails share the identical event-first language bug
and never set __language, so a German customer on an English-gallery event got
an English email body with German-formatted amounts.

Each call site already computes the locale it formats amounts in, so this is a
one-liner per call — the body language now matches the amount formatting:
- invoice_sent, storno_issued (sending.js) -> __language: ctx.locale
- payment-check, invoice_paid_admin_notification (payments.js) -> __language: locale

Leak-safe (no template references {{__language}}) and falls back to the
existing event-first resolution when unset, per the mechanism added in #763.
2026-07-06 22:10:18 +02:00
Luca 2c7b351458 fix(workflows): backfill existing invoices + anchor grace to due date when dunning is enabled (#750)
Enabling the invoice-dunning built-in suppressed the legacy reminder ladder
but only created runs for invoices sent AFTER enabling — already-sent unpaid
invoices got dunned by neither. Now:

- Turning dunning ON enrolls every open sent/overdue unpaid invoice via
  emitWorkflowEvent('invoice.sent') (engine.backfillDunningRuns(), wired into
  the workflow enable toggle). Idempotent via the per-(flow,entity) dedup.
- The grace wait is anchored to the invoice's due date: computeWakeAt now
  treats { untilVar, delayDays } as "var + offset" (was var-only OR now+offset),
  and the built-in's waitGrace becomes { untilVar: 'dueDate', delayDays:
  firstDays } (seed v6 -> v7). An already-overdue invoice duns on its real
  timeline instead of restarting a fresh grace clock.

Note: the due-date-anchored graph applies to freshly seeded built-ins; an
already-admin-enabled dunning workflow still enrolls via backfill but keeps its
current grace timing until re-seeded.
2026-07-06 21:57:23 +02:00
Luca ea86871b81 Merge pull request #354 from Luca-Timo/fix/email-language-and-payment-confirm
fix(email,ui): billing emails follow customer language + readable pay…
2026-07-06 19:33:47 +02:00
Luca fcc3e9195d fix(email,ui): billing emails follow customer language + readable payment-check confirmation
- Billing/dunning emails no longer render in the gallery event's language.
  emailProcessor now honors an explicit `__language` in the email data
  (else falls back to the event-first recipient resolution), and the invoice
  reminder passes the customer/invoice locale (customer.preferred_language
  || invoice.language || 'de'). Fixes German customers getting English
  dunning notices. (#760)
- Payment-check confirmation card ("Action recorded") is now theme-adaptive
  (green tint + readable text on both light and dark surfaces) instead of a
  hardcoded light-green mix + dark-green title that vanished in dark mode. (#759)
- The per-customer "Preferred language" field already exists
  (CustomerDetailPage) plus the business-profile default; updated the helper
  text to note billing emails now honor it too. (#761)
2026-07-06 19:27:57 +02:00
Paul Nothaft a52317d8a5 chore(main): release 3.82.3-beta.0 (#758)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-06 09:53:29 +00:00
Paul Nothaft a88da99c8d Merge pull request #757 from PicPeak/fix/hero-logo-global-inherit-756
fix(branding): make 'Show logo in hero' a true global toggle with per-event override (#756)
2026-07-06 11:50:06 +02:00
Paul Nothaft 60b03b1728 fix(branding): unify hero logo SIZE the same way as visibility (#756)
Follow-on to the visibility fix in this PR — hero_logo_size had the same
split-brain: GalleryLayout read the global branding_logo_size live while
the hero-header path used the per-event snapshot, so a hero logo could
render at different sizes on different layouts and the global size didn't
reach hero-header galleries.

Now mirrored on the visibility model: NULL per-event hero_logo_size =
inherit branding_logo_size; explicit = override.

- Migration 153: hero_logo_size nullable + backfill NULL so existing
  galleries inherit the global size (restores GalleryLayout's prior
  live-global behaviour and fixes the hero-header staleness).
- Creation stores NULL unless explicit; gallery.js resolves
  per-event ?? global and sends the effective size.
- GalleryLayout now consumes that resolved size for the hero logo (new
  heroLogoSize prop) instead of the global — both render paths match.
- Admin size control gains a 'Use branding default' (inherit) option.

Verified: migration on SQLite + PG; live resolution (inherit follows
global both ways, override wins); creation stores NULL on PG; tsc clean,
106 adminEvents+gallery tests pass, build green.
2026-07-06 11:44:33 +02:00
Paul Nothaft 96fe478bf8 fix(branding): make 'Show logo in hero' a true global toggle with per-event override (#756)
Before: the global branding_logo_display_hero toggle was only a
creation-time default — snapshotted into each event's hero_logo_visible
column at creation and never consulted again. Disabling it did nothing
to existing galleries (the reporter's bug), and the two gallery render
paths disagreed (GalleryLayout read the global, HeroHeader read the
per-event snapshot).

Now: NULL per-event hero_logo_visible = 'inherit the global toggle';
an explicit true/false is a per-gallery override.

- Migration 152: make events.hero_logo_visible nullable and NULL out the
  defaulted  rows so existing galleries follow the global going
  forward. Deliberate per-gallery hides () are preserved.
- Creation stores NULL unless the admin explicitly sets it; the update
  path preserves NULL.
- gallery.js resolves per-event ?? global (branding_logo_display_hero,
  default true) and sends the EFFECTIVE value on both gallery responses.
- Both frontend render paths now consume that resolved value
  (GalleryLayout gets it via a new heroLogoVisible prop).
- Admin per-event control is now tri-state: Use branding default /
  Always show / Always hide (en + de).

Verified: SQLite migration + live resolution (inherit follows global
both ways; override wins both ways); PG migration SQL dry-run; the admin
tri-state renders 'Use branding default' for an inherited event; tsc
clean, 106 adminEvents+gallery tests pass, build green.
2026-07-06 11:20:36 +02:00
Paul Nothaft 43213b50ce chore(main): release 3.82.2-beta.0 (#755)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-05 21:43:21 +00:00
Paul Nothaft a87ad77d8d Merge pull request #754 from PicPeak/fix/og-shorturl-slideshow-viber-699
fix(og): route branded short URLs + slideshow to OG handler, add Viber (#699)
2026-07-05 23:39:59 +02:00
Paul Nothaft a0a28a4777 fix(og): broaden social-crawler coverage (Bluesky Cardyb, WeChat-scraper, fediverse, etc.)
From alexvaltchev's field UA list on #699. Adds CRAWLER-EXCLUSIVE tokens
to both the nginx UA regex and SOCIAL_CRAWLER_PATTERNS (kept in sync):
Cardyb (Bluesky's actual link-card fetcher), facebookcatalog, Signal,
Misskey, Pleroma, Synapse, Nextcloud, Rocket.Chat, kakaotalk-scrap,
Google-PageRenderer, OdklBot, ZoomBot.

Deliberately NOT added: UAs shared with real human in-app browsers
(WeChat MicroMessenger, LINE 'Line/', Zalo) and broad strings
('InAppBrowser', 'preview', 'unfurl', 'XING' → matches 'boxing'). Our OG
response is meta-only with no redirect, so matching those would serve a
human the bare stub. New negative test locks that exclusion in.

Verified: nginx -t passes; live harness confirms the new tokens rewrite
to /og while the in-app-browser UAs still get the SPA. Backend suite 15/15.
2026-07-05 23:37:16 +02:00
Paul Nothaft 0dffe0ce92 fix(og): route branded short URLs + slideshow links to OG, add Viber (#699)
Follow-up to #699/#700/#702 — the OG SSR handler existed but three link
shapes never reached it behind the frontend nginx:

- Branded short URLs (/s/<slug>, #702) had NO nginx location, so they fell
  through to the SPA — which has no /s/ route. Dead for humans (no 302
  redirect) and crawlers (no OG). Add an ^~ /s/ proxy to the backend, whose
  /s/:shortSlug route already handles both.
- Slideshow links (/gallery/<slug>/show/<token>) have TWO extra path
  segments; the crawler-detect location regex allowed only one, so they
  never rewrote to /og and got generic site-wide OG. Widen to {0,2} extra
  segments (quoted regex — the braces would otherwise be parsed as nginx
  config delimiters). client-access still matches (its token is in ?query,
  one path segment).
- Viber's preview fetcher wasn't in either UA list, so Viber shares showed
  no preview. Add it to nginx + SOCIAL_CRAWLER_PATTERNS (kept in sync).

Verified end-to-end: nginx -t passes; a live nginx+mock-backend harness
confirms /s/ proxies to the backend, slideshow + Viber + share-token +
client-access crawler UAs all rewrite to /og/gallery/<slug>, and browsers
still get the SPA. Backend isSocialCrawler test extended for Viber.
2026-07-05 21:19:53 +02:00
Paul Nothaft f15e104702 chore(main): release 3.82.1-beta.0 (#753)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-05 17:27:41 +00:00
Luca 9a763337b6 Merge pull request #752 from Luca-Timo/fix/dunning-payment-check-template-key
fix(invoices): correct payment-check email template key so dunning email sends
2026-07-05 19:24:37 +02:00
Luca 3682de195b fix(invoices): correct payment-check email template key so dunning email sends
queuePaymentCheckEmail queued the admin payment-check email with template
key 'invoice_payment_check_admin', but no such template exists — the only
one is 'invoice_payment_check' (crmEmailTemplates.js:217, seeded by
migration 116), which IS the admin "Paid / Partial / Not paid" email. The
processor does an exact template_key lookup and throws "template not
found", so every dunning admin payment-check email failed, retried to the
cap, and got stuck pending.

One-word fix: queue 'invoice_payment_check'. Unbreaks the built-in
invoice-dunning flow's email step. (Rebased onto the post-decompose
invoiceService refactor — the line now lives in invoice/payments.js.)
2026-07-04 23:51:22 +02:00
Paul Nothaft c3ed7f1693 chore(main): release 3.82.0-beta.0 (#742)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-03 11:10:31 +00:00
Paul Nothaft a5f49e3235 Merge pull request #741 from PicPeak/feat/setup-final-step-and-button-fix
feat(setup): final community step (#732) + fix create-admin button overflow (#730)
2026-07-03 13:07:21 +02:00
Paul Nothaft dadaaeea77 feat(setup): final community/thank-you step (#732); fix create-admin button overflow (#730)
#730 — the account step's Create-admin button shared a flex row with Back;
its label + loading spinner exceeded the card width, so the button
overflowed the card outline while submitting (and was fragile for longer
i18n labels). Stack both buttons full-width — the primary always has room
for the spinner now, matching every other wizard step.

#732 — add a final 'community' step, shown once on first-run after
config / no-config, before entering the app. Mission line + four link
cards (report a bug, request a feature, star/share, Buy Me a Coffee),
all target=_blank rel=noopener, and a Finish → Dashboard button. Fully
i18n (en + de). Restore keeps its reload flow (a restored instance is no
longer first-run, so it never reaches this step). Adds .github/FUNDING.yml
so GitHub renders a Sponsor button too.

Verified live: drove the real first-run wizard end to end — stacked
account buttons render inside the card, community step shows the mission
+ all four links, Finish lands on the dashboard.
2026-07-03 12:29:58 +02:00
Paul Nothaft c76877c0c4 chore(main): release 3.81.0-beta.0 (#740)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-03 10:08:29 +00:00
Paul Nothaft e985c6bacd Merge pull request #733 from PicPeak/refactor/codebase-cleanup
refactor: codebase-wide cleanup — dead code, dedup, standardization, god-file decomposition
2026-07-03 12:05:33 +02:00
Paul Nothaft f564b38c5a Merge remote-tracking branch 'origin/main' into refactor/codebase-cleanup
# Conflicts:
#	backend/src/routes/adminEvents.js
#	backend/src/routes/protectedImages.js
#	frontend/src/pages/admin/EventDetailsPage.tsx
2026-07-03 11:54:01 +02:00
Paul Nothaft cf073615ef Merge pull request #739 from PicPeak/feat/admin-mfa
feat: admin two-factor authentication (TOTP) with recovery codes + CLI reset
2026-07-03 11:49:07 +02:00
Paul Nothaft b732974779 Merge pull request #737 from PicPeak/fix/auth-access-control
fix(security): cross-event thumbnail leak, bulk-op ownership bypass + auth hardening
2026-07-03 11:47:25 +02:00
Paul Nothaft b187f588b4 Merge pull request #734 from PicPeak/fix/event-create-nan-and-bool-render
fix: event creation 500s on PostgreSQL (NaN slideshow seed) + stray "0" boolean renders
2026-07-03 11:46:58 +02:00
Paul Nothaft 96e3c68b9d feat(admin-ui): TOTP MFA enrollment + two-step login; remove stub 2FA toggle
Frontend for #738.
- mfa.service.ts + MfaSettingsCard (Settings → General → Admin Account):
  per-user setup (QR + manual secret + verify), recovery codes shown once
  (copy/download/confirm), status, regenerate, disable. Renders for
  super_admin (closes #735).
- Two-step login in AdminLoginPage: on {mfaRequired,mfaToken} swap to a
  code step (TOTP or recovery), call /auth/admin/login/mfa; handle
  MFA_INVALID / MFA_SESSION_EXPIRED / 423 lockout.
- Removed the non-functional global enable_2fa checkbox from SecurityTab
  (and its persistence) — replaced with a note pointing to per-user setup.
- en + de i18n.

Verified live in-browser: enroll (QR→code→recovery codes), logout, and
the two-step challenge into the dashboard as super_admin.
2026-07-03 11:44:07 +02:00
Paul Nothaft cdbfb514bd test(auth): MFA unit + route + CLI coverage (39 tests)
mfaService unit (encrypt/decrypt, TOTP, single-use recovery, isEnrolled),
adminMfa HTTP (enroll/challenge/verify/recovery/disable; super_admin
enrollment guards #735), and reset-admin-mfa.js CLI.
2026-07-03 11:37:51 +02:00
Paul Nothaft 72e2ef6721 feat(auth): admin TOTP MFA — enrollment, login challenge, recovery, CLI reset
Backend for #738. Real TOTP 2FA for admin accounts, all roles incl.
super_admin (closes #735).

- mfaService: otplib TOTP; AES-256-GCM encryption of the secret at rest
  (key derived from MFA_ENCRYPTION_KEY or JWT_SECRET); bcrypt-hashed,
  single-use recovery codes; otpauth URI + QR.
- Migration 151: adds two_factor_recovery_codes + two_factor_enrolled_at
  (secret/enabled columns already existed from legacy 016).
- Enrollment endpoints (behind adminAuth, per-user): GET /mfa/status,
  POST /mfa/{setup,enable,disable,recovery-codes}. Disable/regenerate
  require a current code so a hijacked session can't strip 2FA.
- Login challenge: /admin/login returns {mfaRequired, mfaToken} (no
  session) when 2FA is on; /admin/login/mfa exchanges a TOTP or recovery
  code for the session. Lockout counter is NOT reset until the second
  factor passes, so MFA brute-force is rate-limited too.
- CLI break-glass: scripts/reset-admin-mfa.js --email <e> | --all --yes,
  audit-logged, matches reset-admin-password.js convention.
- Docs + optional MFA_ENCRYPTION_KEY env.

Verified end-to-end on a live backend: enroll (super_admin), challenge,
TOTP + single-use recovery login, disable, and CLI reset.
2026-07-03 11:33:38 +02:00
Paul Nothaft 081f3edcdf fix(security): close cross-event thumbnail leak, bulk-op ownership bypass, + hardening
Auth/access-control audit fixes (all pre-existing on main; none are
regressions). Verified end-to-end where noted.

HIGH
- Thumbnail enumeration: photoAuth granted any gallery token access to any
  flat /thumbnails/thumb_* file, so a visitor to one gallery could
  enumerate another (password-protected) gallery's entire thumbnail set.
  Scope thumbnail access to the token's event via photos.thumbnail_path.
  Live-verified: cross-event fetch now 404s, own-event still 200s.
- Bulk ownership bypass: bulk-archive/bulk-delete acted on body-supplied
  event ids with no owner filter (single-event routes enforce
  requireEventOwnership), letting admin/editor archive or cascade-delete
  any event. Add filterOwnedEventIds; also guard rename + import-external;
  tighten photo-retry to scope admin (not just editor). Fix misleading
  bulk-delete comment.

MED
- verifyGalleryAccess never checked decoded.type — assert 'gallery'
  instead of relying on other token types incidentally lacking eventId.
- secure-images generate-token/secure-download missing denySlideshowToken
  (#646 bypass): a leaked slideshow token could download originals.
- Frontend: AuthenticatedImage + api.ts attached the gallery bearer token
  to absolute/external URLs — only attach to relative same-app paths.

LOW hardening
- Pin algorithms:['HS256'] on all auth-boundary jwt.verify calls.
- crypto.timingSafeEqual for share-token + HMAC compares (utils/timingSafe).
- Remove dead photoAuth import in galleryFeedback.

Tests: new regression suites for thumbnail scoping + filterOwnedEventIds;
fixed verifyGalleryAccess.customerRevoke fixture (real customer tokens
carry type:'gallery'). Full backend suite at the pre-existing baseline
(5 suites/27 tests fail on main too), zero new failures.
2026-07-03 10:27:28 +02:00
Paul Nothaft 5b26dbd935 chore(main): release 3.80.0-beta.0 (#736)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-03 08:05:34 +00:00
Paul Nothaft e513e8345b Merge pull request #731 from Luca-Timo/feat/setup-wizard-and-backup-roundtrip
feat: first-run setup wizard (feature selection + config) and portable .picpeak backup roundtrip
2026-07-03 10:03:22 +02:00
Paul Nothaft 766351b588 fix: mirror #734 onto decomposed files (PG NaN slideshow seed, SQLite bool renders)
Same two pre-existing-on-main bugs, at their post-decomposition
locations: clampIntOrUndefined in adminEvents/crud.js slideshow seed;
!! coercion in EventDetailsHeader, EventInformationCard,
ClientAccessCard. Keeps this branch correct in either merge order with
#734 — when merging main afterwards, resolve the adminEvents.js
modify/delete conflict by keeping the deletion.
2026-07-03 09:01:01 +02:00
Paul Nothaft 760c3d7b67 fix(admin): stray literal "0" rendered from SQLite integer booleans
On SQLite deployments boolean event columns come back as 0/1, and
{event.is_draft && ...} renders the 0 as a literal text node. Visible on
the event details page in three spots: above the tab bar (is_draft),
in the download-protection badge row (disable_right_click /
enable_devtools_protection / watermark_downloads), and in the Client
Access card (client_access_enabled). Coerce with !! at the render sites.
2026-07-03 08:57:48 +02:00
Paul Nothaft 8c86518aad fix(events): NaN from slideshow seed breaks event creation on PostgreSQL
The create route seeds show_interval_ms/show_transition_ms from
app_settings through an inline guard that pre-checked Number.isFinite(+v)
but then used parseInt(v). The two disagree for null/''/true — +null is 0
(finite) while parseInt(null) is NaN — so when the slideshow settings rows
are absent (getAppSetting returns its null default), NaN flowed through
Math.min/Math.max into the INSERT. PostgreSQL rejects NaN for integer
columns; SQLite silently stores NULL, which is why every SQLite-based
test passed while POST /api/admin/events 500'd on the PG dev stack and
broke the e2e smoke suite.

Fix: parse first, then check — clampIntOrUndefined in utils/numericHelpers
(unit-tested against every failure-mode input). Verified end-to-end: the
previously-failing minimal create now succeeds against the PG dev stack.
2026-07-03 08:57:48 +02:00
Paul Nothaft 2ea26a4962 fix: conform moved code to eslint indent/quotes, 4-arg mutation callbacks
- eslint --fix on branch-changed backend files (indent shift from the
  module-wrapper nesting in decomposed files); backend lint now 904
  errors vs 1,315 on main
- useMutationWithToast forwards all four TanStack v5 callback args
  (tsc -b strict build flagged the 3-arg passthrough)
2026-07-03 08:17:14 +02:00
Paul Nothaft b5eafc52bd refactor(frontend): decompose EventDetailsPage and ThemeCustomizerEnhanced
Move-code split, entry paths/exports unchanged:
- EventDetailsPage.tsx (2,697 -> 679) + pages/admin/event-details/* (16 files)
- ThemeCustomizerEnhanced.tsx (1,541 -> 349) + admin/theme-customizer/* (11)
Known ephemeral-UI delta: widget-local state (copied-link flags, unsaved
PIN input, modal selection) now resets when a tab unmounts.
2026-07-03 08:10:25 +02:00
Paul Nothaft fdf62dafef refactor(backend): decompose invoiceService, contractService, adminEvents
Move-code split, public entry points unchanged:
- services/invoiceService.js (3,623 -> 99) + services/invoice/* (10 modules)
- services/contractService.js (2,363 -> 83) + services/contract/* (6 modules)
- routes/adminEvents.js -> routes/adminEvents/* (crud, slideshow, resets,
  archive/bulk, logo); route registration order verified identical
Lazy cross-service requires preserved to keep the module graph acyclic.
2026-07-03 08:10:25 +02:00
Paul Nothaft 51f827774a refactor(frontend): extract shared PhotoCard from gallery layouts
One card implementation (hover overlay, download/expand/select, likes,
identity flow, lazy render) replaces per-layout copies in Masonry,
Justified, Grid, Mosaic, Timeline (-1270/+395 in layouts). Carousel,
Premium, Story stay bespoke — different DOM/design by intent.
2026-07-03 07:49:45 +02:00
Paul Nothaft 0f230f53fb refactor(frontend): useMutationWithToast + useModal hooks, migrate admin surfaces
- 92 mutations across 40 files moved to useMutationWithToast
  (success/error toast + invalidateKeys); complex flows left as-is
- 24 boolean modal flags moved to useModal
- Mutations without an original onError intentionally not migrated
  to avoid introducing new error toasts
2026-07-03 07:49:45 +02:00
Paul Nothaft 6eb46d8c31 refactor(backend): standardize error responses, logging, pagination
- errorResponse(res, error, status, publicMessage) in routeHelpers,
  wired into 125 catch blocks across 10 route files; wire format
  ({ error: <string> }) unchanged byte-for-byte
- Replace remaining console.* with logger across src (178 sites);
  3 intentional console sites kept (install boot, unbound .catch ref)
- Adopt getPagination in 6 routes where semantics match exactly
2026-07-03 07:49:45 +02:00
Paul Nothaft d44ead41c5 test: add smoke tests for invoiceService, adminEvents routes, backupService
26 tests as a safety net ahead of decomposition — invoice create/list/
status transitions, adminEvents CRUD via Supertest+SQLite, backup config
parsing and manifest validation.
2026-07-03 07:49:45 +02:00
Paul Nothaft 9d7b6f0e11 test: extend documentSequences mock for shared nextDocumentNumber 2026-07-03 07:27:43 +02:00
Paul Nothaft eb71fcf209 refactor: remove dead files, dedupe formatBytes and document numbering helpers
- Delete unused adminEvents-enhanced.js, backupService.original.js,
  databaseBackup.example.js, s3Storage.example.js, ThemeCustomizer.tsx
- Extract shared formatBytes to utils/formatBytes.js (was copied 4x)
- Centralize formatNumberInTemplate + next-document-number logic in
  utils/documentSequences.js (was copied in invoice/quote/contract services)
2026-07-03 07:25:07 +02:00
Luca fa7665c5b1 fix(backup): address .picpeak review — table filter, superuser guard, tests
From the-luap's review:

- Import no longer trusts manifest.tables blindly. It now intersects the
  manifest's table list with the real data tables of THIS database
  (listDataTables(), which already excludes knex_migrations/_lock) and
  drops anything else. A crafted/corrupted .picpeak listing knex_migrations
  or a non-existent table can no longer wipe it; skipped tables are logged.
- The Postgres session_replication_role='replica' SET (needs superuser) is
  now wrapped: on a managed-PG non-superuser it fails BEFORE any rows are
  deleted (transaction rolls back) and surfaces a clear, actionable 400
  instead of a cryptic permission error.
- Export: on an archiver error, the temp out dir (a partial plaintext-secret
  archive) is now removed instead of orphaned.

Tests (+4, now 26): engine-mismatch rejection, forward-only newer-refused,
non-picpeak rejection, and files/ restored + filesRestored asserted.
2026-07-03 01:39:13 +02:00
Luca 07b450a954 feat(setup): per-feature config step after feature selection
When the chosen features need config the wizard can collect, 'Finish' on
the usage step now advances to a lean config step instead of jumping to
the dashboard:
- Invoicing (if Invoices): company/legal name, address, VAT-ID or tax
  number, IBAN, currency → saved to business-profile + a default bank
  account. Carries the bank/VAT legal disclaimer.
- Email (if reminders/incoming-mail/whatsapp/invoices): SMTP host/port/
  user/pass/from → saved to email_configs.
Each section persists only if started, and 'Skip for now' is always
available — soft settings keep their seeded defaults. en + de strings.
2026-07-02 22:10:54 +02:00
Luca a95ee473ae feat(setup): add restore-from-backup branch to the first-run wizard
The usage step now offers 'Migrating from another PicPeak?' → a restore
step that uploads a .picpeak (reusing PicpeakRestoreCard) to clone another
instance onto this fresh one, preserving the account just created. en + de
strings added.
2026-07-02 21:57:50 +02:00
Luca 86324e7da7 feat(backup): fold .picpeak restore into the Restore wizard's Upload source
Removes the redundant standalone .picpeak card. The wizard's 'Upload
Backup' source now splits into two kinds: '.picpeak backup' (the working
portable restore — renders the upload + destructive-confirm flow inline)
and 'Manifest + files' (legacy, still 'Manifest Upload functionality
coming soon'). en + de strings added.
2026-07-02 21:38:48 +02:00
Luca d4b143f313 fix(setup): keep the first-run wizard light regardless of dark mode
The setup page background used var(--color-background), which flips to
#0a0a0a under the .dark class while the wizard card stays hardcoded light
— giving a dark page + light card mismatch in dark mode. Pin the first-run
screen to its intended light branded look (fixed #fafafa bg / #171717 text)
so all three steps render consistently.
2026-07-02 21:29:47 +02:00
Paul Nothaft b04ef216f5 chore(main): release 3.79.1-beta.0 (#729)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-02 19:03:05 +00:00
Paul Nothaft 4aa6583bae Merge pull request #728 from Luca-Timo/fix/settings-duplicate-mail-import
fix(settings): remove duplicate Mail import that crashes the dev server
2026-07-02 20:59:40 +02:00
Paul Nothaft c8610276c2 Merge pull request #727 from PicPeak/chore/security-nginx-npm-bumps
chore(security): bump frontend nginx to r4 (close 4 HTTP/2 & module CVEs)
2026-07-02 20:53:45 +02:00
Luca cc79b3d9ec refactor(backup): move .picpeak download to the Dashboard tab
Downloading a portable backup is a "make a backup" action, so it belongs
next to "Run Backup Now" on the Dashboard, not under Restore. Split the
combined card into PicpeakExportCard (Dashboard) and PicpeakRestoreCard
(Restore). The manifest stays bundled inside the .picpeak, so there is no
separate manifest-only download for the portable format.
2026-07-02 20:37:35 +02:00
Luca f57462f798 fix(backup): make .picpeak roundtrip work on Postgres
Two Postgres-only bugs found by a live docker-pg roundtrip (SQLite tests
passed because neither reproduces on SQLite):

- Export: knex `.stream()` pulls in the optional `pg-query-stream` module
  (not bundled) and throws on pg. Switched to a plain per-table `select`
  — works on both engines, no new dependency. Rows are DB metadata
  (blobs live under files/), so holding a table in memory is fine.
- Import: the pg driver returns json/jsonb columns as parsed JS values,
  so re-inserting a scalar like the string "PicPeak" sent it unquoted and
  pg rejected it ("invalid input syntax for type json"). Now introspects
  each table's json/jsonb columns and re-serialises those values before
  insert (pg only; SQLite stores json as TEXT and round-trips as-is).

Verified end-to-end on docker Postgres: export 85 tables, full-override
import, current account preserved, post-backup data removed.
2026-07-02 20:13:18 +02:00
Luca 66d61c87ca feat(backup): .picpeak download + upload-restore UI in Backup Manager
Adds a self-contained "Portable backup (.picpeak)" card to the Restore
tab, completing the GUI-only roundtrip:
- Download: optional "include original photos" toggle + a prominent
  plaintext-secrets warning, streams the file via a blob download.
- Restore: file picker → destructive confirmation modal ("replaces ALL
  data except your current account, cannot be undone") → multipart upload
  to /admin/backup/picpeak/import → success summary. If the backup uses
  external media, shows a banner to reconfigure the mount, with a docs link.
Kept separate from the legacy RestoreWizard (different format/flow). en+de
strings added; dark-mode variants throughout.
2026-07-02 19:45:16 +02:00
Luca 2b66f6d889 feat(backup): upload + restore endpoint for .picpeak
POST /admin/backup/picpeak/import — multipart upload of a .picpeak,
streamed to a temp file (after auth, so unauthenticated requests can't
push a large file to disk), then restored via picpeakImportService with
currentAdminId = the logged-in operator (preserved across the override).
Gated on backup.restore. Returns usesExternalMedia so the UI can prompt to
reconfigure the external-media mount. Temp upload is always unlinked.

Completes the backend half of the GUI-only roundtrip (export download +
import upload). Multipart is already allowed by the CSRF content-type guard.
2026-07-02 19:37:17 +02:00
Luca 2920d82186 feat(backup): .picpeak import/restore (full override, keeps current account)
Receiving half of the roundtrip. picpeakImportService.importFromPicpeak():
- Validates the manifest: rejects non-picpeak files, a newer format, an
  engine mismatch (pg↔pg / sqlite↔sqlite only), and a backup from a NEWER
  schema than this instance (forward-only). knex_migrations absence is
  tolerated (test harnesses).
- Snapshots the current logged-in admin, then wipes + reloads every table
  from the backup NDJSON in one transaction with FK enforcement suspended
  (pg: session_replication_role=replica reset before commit; sqlite:
  defer_foreign_keys). knex_migrations is never touched, so the target's
  schema/migration state is preserved.
- Re-injects the current account so the operator is never locked out; a
  backup admin colliding on email is overwritten with the current creds.
- Restores files/ into storage and detects external-media references so the
  caller can prompt to reconfigure the mount.

Roundtrip integration test proves: backup data restored, current account
survives a full override (different email → added), and the email-collision
case keeps the operator's password.
2026-07-02 19:35:48 +02:00
Luca 422dfe1cc8 feat(setup): add "How will you use PicPeak?" feature-selection step
After the admin account is created (and we're logged in), the wizard now
shows an opt-in feature step instead of jumping straight to the dashboard.
Grouped ticks (Client management / Accounting / Automation) map to the
existing feature flags; galleries/analytics/userManagement stay always-on
and are noted, not listed.

- Selection is saved via the existing authenticated PUT /admin/feature-flags,
  whose server-side applyDependencyRules resolves dependencies (e.g. Invoices
  pulls in Accounting) — the wizard only sends raw ticks.
- Labels/descriptions reuse settings.features.<key>.title/description so
  translations stay in sync (en + de verified for all 14 features).
- Saving is best-effort: on failure the admin still enters the app and can
  set features later in Settings.
- New en/de strings for the usage step.

Option A (lean wizard): this is the feature-selection foundation; per-feature
hard-required config steps + the restore-from-backup branch come next.
2026-07-02 19:31:10 +02:00
Luca 38b3aef63d feat(backup): .picpeak portable export (engine-neutral logical snapshot)
First half of the GUI-only backup roundtrip. Adds a self-describing
".picpeak" archive that can be downloaded from one instance and (later)
re-uploaded to another via the web UI only.

- picpeakExportService.createPicpeak(): dumps every table as NDJSON
  (tables introspected at runtime — no hardcoded list, won't rot), plus
  a manifest (format version, app version, DB engine, latest migration,
  per-table row counts + checksums, includePhotos, contains_secrets),
  plus files/ (business-docs + uploads always; original gallery photos
  only when includePhotos). NDJSON is engine-neutral so the target
  rebuilds schema via migrations then loads rows — enabling pg↔pg /
  sqlite↔sqlite and forward-only auto-migrate.
- GET /admin/backup/picpeak/export?includePhotos= streams the file and
  sets X-Picpeak-Contains-Secrets (the file holds plaintext SMTP pass,
  admin hashes, API keys — the UI must warn).
- Purely additive: no existing backup/restore path is touched.

Integration test proves the archive shape, knex-table exclusion, and
row-count/NDJSON consistency (85 tables on the seed schema).
2026-07-02 19:13:58 +02:00
Luca 5b535f8658 fix(settings): remove duplicate Mail import that broke the dev server
SettingsPage.tsx imported `Mail` from lucide-react twice — in the main
icon block (line 20) and again in a later import (line 58). The
@vitejs/plugin-react babel transform rejects the duplicate with
"Identifier 'Mail' has already been declared", so `npm run dev` crashed
when the module loaded. The production `vite build` (esbuild) silently
dedupes it, which is why CI/Docker builds passed and it went unnoticed.

The two imports overlap only on `Mail`; drop it from line 58, keeping
that line's six unique icons (Briefcase, Receipt, ScrollText, Landmark,
Smartphone, MonitorPlay). Verified: single Mail import remains, prod
build passes, and the vite dev transform of SettingsPage now returns 200
with no "already been declared" error.
2026-07-02 17:37:51 +02:00
Paul Nothaft 64e4925fbb chore(security): bump backend npm 10 -> 11 to patch bundled sigstore/tar
Closes Trivy alerts #375 (sigstore CVE-2026-48815), #321 (@sigstore/core),
#314 (tar) — npm@10 bundles the vulnerable sigstore 3.1.0; npm 11 ships the
patched 4.x. Safe because this npm is CLI-only in the final image: runtime
deps come from the builder stage's node_modules and the entrypoint runs node,
not npm, so the install-behaviour issues that motivated the 10.x pin never run
here. npm 11 requires Node >=22.9 — satisfied by node:22-alpine.
2026-07-02 17:16:09 +02:00
Paul Nothaft 12a9d963f5 chore(security): bump frontend nginx to r4 — close 4 HTTP/2 & module CVEs
Trivy flagged nginx 1.28.3-r1 in the frontend image (alerts #371-374):
- CVE-2026-42055 (HIGH) HTTP/2 heap overflow
- CVE-2026-49975 (HIGH) HTTP/2 DoS
- CVE-2026-9256  (HIGH) rewrite_module code exec / DoS
- CVE-2026-48142 (MED)  charset_module memory disclosure

All fixed in nginx 1.28.3-r4. The Dockerfile already ran 'apk upgrade
--no-cache', but the pushed image predated the fixed package and the layer
was cached on r1. Add an explicit nginx upgrade to force the layer to rebuild
against the current Alpine repos (which now carry r4).
2026-07-02 17:14:29 +02:00
Paul Nothaft 24c287d051 chore(main): release 3.79.0-beta.0 (#726)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-02 15:12:45 +00:00
Paul Nothaft 681619f0a1 Merge pull request #718 from PicPeak/feat/setup-wizard-unattended
feat: setup wizard + argument-driven unattended install
2026-07-02 17:08:21 +02:00
Paul Nothaft d35c413651 feat(setup): step-by-step wizard + argument-driven unattended install
Restructure picpeak-setup.sh around two clear modes:

- Interactive wizard (run_wizard): asks method → install dir → channel →
  domain → HTTPS handling → admin email → SMTP, then shows a review and
  confirms before installing. Each value already passed as a flag is
  respected and its question skipped.
- Unattended (--unattended + flags): validate_unattended fills defaults and
  fails fast on impossible combos (e.g. --enable-ssl without --domain).
  New flags: --admin-password, --install-dir, --channel.

Align the Docker path with the rest of the project:
- Use the committed docker-compose.production.yml (prebuilt GHCR images) via
  COMPOSE_FILE in .env instead of hand-generating a divergent compose file.
- Drop the broken setup_ssl_docker call (was referenced but never defined).
- Update path pulls images instead of building.

Admin bootstrap follows the browser-first model (#714): by default no
password is written; the one-time /setup token is surfaced (from
data/SETUP_TOKEN or the logs) with browser instructions. --admin-password
keeps the legacy seeded-admin + ADMIN_CREDENTIALS.txt flow for headless runs.

Depends on #714 (setup-token backend + secrets-init in production compose)
for the browser-first + zero-secret behavior at runtime.
2026-07-02 16:44:09 +02:00
Paul Nothaft 6850bd3bef chore(main): release 3.78.0-beta.0 (#725)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-02 14:35:07 +00:00
Paul Nothaft 97b9853709 Merge pull request #724 from PicPeak/fix/release-automerge-pat
fix: enable release-PR auto-merge with the PAT so releases actually publish
2026-07-02 16:32:04 +02:00
Paul Nothaft bafc96f468 Merge pull request #714 from Luca-Timo/feat/first-run-setup-wizard
feat: zero-config first run — in-browser admin bootstrap + auto-generated secrets
2026-07-02 16:19:33 +02:00
Paul Nothaft e08a33d9ea fix(ci): enable release-PR auto-merge with the PAT, not GITHUB_TOKEN
Auto-merge enabled via GITHUB_TOKEN attributes the eventual merge commit to
github-actions[bot], so recursion prevention suppresses the resulting push to
main — the follow-up release-please run that cuts the tag/release never fires.
Net: the version PR merges but no release/tag/images are ever produced (#719).

Enable auto-merge with RELEASE_PLEASE_TOKEN instead (a real identity) so the
merge triggers the tag-cutting run. Approval stays on GITHUB_TOKEN because it
must be a different identity than the PR author (the PAT) to count as a review.

Observed on #723: merged 3.77.3-beta.0 but no run followed and no tag was cut.
2026-07-02 15:41:25 +02:00
Paul Nothaft 2b2dc6e35c chore(main): release 3.77.3-beta.0 (#723)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-02 13:29:08 +00:00
Paul Nothaft d00d52a221 Merge pull request #722 from PicPeak/fix/release-please-gh-repo
fix: set GH_REPO in release-please auto-merge step
2026-07-02 15:23:49 +02:00
Paul Nothaft e54100ac46 Merge pull request #721 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.77.2-beta.0
2026-07-02 15:23:08 +02:00
Paul Nothaft 0cab43ed89 fix(ci): set GH_REPO in release-please auto-merge step
The auto-merge step runs in a job with no actions/checkout, so gh could not
infer the repository from a git remote and failed with 'not a git repository'
(#719 follow-up). Set GH_REPO=${{ github.repository }} so gh pr list/review/
merge work without a checkout — same fix as the whatsnew workflow (2a5f0a8).

Confirmed working otherwise: with RELEASE_PLEASE_TOKEN set, release PR #721 is
now PAT-authored and its required checks run automatically (no manual approval).
2026-07-02 15:20:07 +02:00
Luca b0912c7427 feat(setup): validate setup token at step 1 before advancing
Previously "Continue" on the token step only checked the field was
non-empty; a wrong token wasn't caught until the final submit, after the
user had filled in email + password. Add a non-burning verify:

- backend: POST /setup/verify-token constant-time compares the token
  without consuming it (createInitialAdmin still claims it atomically on
  submit), gated on no-admin-exists and rate-limited like /setup/admin.
- frontend: step-1 "Continue" calls verifyToken and only advances on a
  valid token; a wrong token shows the invalidToken error on the field,
  429 -> too-many-attempts, 409 -> redirect to login.

Adds integration tests for accept-without-burn / reject / closed-once-set.
2026-07-02 15:18:22 +02:00
Paul Nothaft 95dac43fdf chore(main): release 3.77.2-beta.0 2026-07-02 15:17:15 +02:00
Paul Nothaft fb64ec0910 Merge pull request #720 from PicPeak/fix/release-please-automation
fix: auto-publish release-please PRs without manual approval
2026-07-02 15:16:54 +02:00
Luca 3e69c5df3f fix(setup): match first-run logo size to the login page default
The header logo used a hardcoded 64px frame; the login page renders a
medium (200x150) frame via resolveLoginLogoClasses. Reuse that helper
with the default size so /setup and /admin/login read identically.
2026-07-02 15:03:56 +02:00
Paul Nothaft a3e7232b8e fix(ci): auto-publish release-please PRs without manual approval (#719)
The release PR (authored by github-actions[bot] via GITHUB_TOKEN) sat open
forever: its workflows were held behind 'awaiting approval' and the required
review could not be satisfied by the bot. Both release-please workflows now:

- Use a dedicated ${{ secrets.RELEASE_PLEASE_TOKEN }} (fine-grained PAT) with a
  GITHUB_TOKEN fallback. A PAT-authored PR runs CI automatically (no 'awaiting
  approval') and can be merged without a human.
- Auto-approve (as github-actions[bot], a different identity than the PR
  author) and enable auto-merge on the open release PR, so it publishes once
  checks pass. Skipped when no PAT is configured — falls back to today's manual
  flow, nothing breaks.

A PAT also un-suppresses the tag-push and release-published triggers on
docker-build (GITHUB_TOKEN suppressed them), so add a concurrency group there
to collapse the duplicate same-version builds into one.

Requires (repo/org settings, one-time):
- Create fine-grained PAT RELEASE_PLEASE_TOKEN (contents:write, pull-requests:write).
- Enable 'Allow auto-merge' on the repo (currently off).
- 'Allow GitHub Actions to approve pull requests' — already enabled.
2026-07-02 15:01:52 +02:00
Luca d9b0eb7232 feat(setup): brand first-run screen and split into two-step wizard
Address post-merge UI feedback on the first-run setup screen — the first
screen any new admin sees:

- Use the bundled PicPeak logo (same asset the login page falls back to)
  on the cream brand plate instead of the generic lucide Sparkles icon.
- Split the flow into two steps: step 1 takes only the one-time setup
  token, with the `docker compose logs backend | grep -i "setup token"`
  recovery command shown prominently (with a copy button) directly under
  the field, plus a docs link for when the logs have rotated away; step 2
  collects email + password. A rejected token bounces back to step 1.

en/de strings added; other locales fall back to en.
2026-07-02 14:56:14 +02:00
Paul Nothaft 934cc92bb8 Merge pull request #717 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.77.1-beta.0
2026-07-02 14:51:09 +02:00
Luca 286975dc52 fix(setup): address PR #714 review — password UX, script token, race, nits
Blockers:
- SetupPage now mirrors the server password rule (>=8 with upper/lower/digit) so
  a green client isn't bounced by the server; server errors carry a `field`
  (routes/setup.js) that the client maps to a translated key instead of
  rendering raw English. New i18n: setup.invalidToken, setup.passwordRequirements.
- picpeak-setup.sh: the ADMIN_CREDENTIALS.txt block no longer dead-ends on the
  wizard path — when no legacy admin was seeded it prints the one-time setup
  token (from data/SETUP_TOKEN / docker compose logs) and points at /setup.

Concern:
- createInitialAdmin creates the admin + burns the token in ONE transaction,
  atomically claiming the token (null-if-present, expect 1 row) so a
  double-submit can't create two super_admins. Cross-DB (whereNotNull, trx-only
  writes). Added a concurrency test.

Nits:
- SetupPage redirects to /login when /setup/status errors (no form flash on a
  configured instance).
- Dropped the unused DATABASE_URL from docker-compose.yml.
- Documented why secrets are chmod 644 (three different reader users).
2026-07-02 13:21:55 +02:00
github-actions[bot] 6f5a02b817 chore(main): release 3.77.1-beta.0 2026-07-02 11:01:34 +00:00
Paul Nothaft f5b4aa7a5b Merge pull request #716 from PicPeak/docs/require-ui-screenshots
docs: require screenshots for UI changes in PRs
2026-07-02 13:01:04 +02:00
Paul Nothaft 8ca74776f4 docs: require screenshots for UI changes in PRs
Any PR that changes a user-facing surface must include a screenshot of the
result in the description (before/after where it helps). Reviewers ask for
one before reviewing UI-touching PRs; backend/non-visual changes are exempt.
2026-07-02 11:57:32 +02:00
Luca 415bffa04c feat: zero-config first run — in-browser admin bootstrap + auto-generated secrets
Fresh installs need nothing in .env. See PR description for the full feature.
2026-07-01 14:49:18 +02:00
Paul Nothaft b8b33ae6d6 Merge pull request #713 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.77.0-beta.0
2026-07-01 10:00:29 +02:00
github-actions[bot] e1dae31e16 chore(main): release 3.77.0-beta.0 2026-07-01 07:55:55 +00:00
Paul Nothaft e873f7c98c Merge pull request #712 from PicPeak/chore/conventional-pr-title-guard
feat: admin photos list/grid toggle + upload failure report (#707, #708)
2026-07-01 09:55:37 +02:00
Paul Nothaft 6f95796b7c feat: admin photos list/grid toggle + upload failure report (#707, #708)
Records two features that merged with gitmoji commit subjects and were
therefore skipped by Release Please, so the next beta credits them:
- #707 grid/list layout toggle on the admin Photos tab
- #708 per-file upload failure report in the upload modal

No code change — the features are already on main; this commit only gives
Release Please a Conventional Commit to cut the release from.
2026-07-01 09:38:34 +02:00
Paul Nothaft 4881d2040a ci: validate PR titles against Conventional Commits
Release Please only recognizes Conventional Commit prefixes (feat:, fix:,
...). PRs merged with other conventions (gitmoji, free-form) are silently
skipped, shipping changes with no version bump or changelog entry (see
#707/#708). Fail such PRs early via amannn/action-semantic-pull-request.
2026-07-01 09:38:34 +02:00
Paul Nothaft b8665e1d86 Merge pull request #708 from andredlng/feat/upload-failure-details
Surface which files failed during photo upload
2026-07-01 09:29:19 +02:00
Paul Nothaft f4a1db8b6a Merge pull request #707 from andredlng/feat/photos-list-view
Add grid/list layout toggle to admin Photos tab
2026-07-01 09:00:31 +02:00
André Deuerling 1be871cb9a 🐛 Address review: fix spinner hang + show processing failures
Blocker: the "every file rejected" reset never fired because the backend
returns `upload_id` unconditionally (with count 0), so `anyQueued` was
always true and the completion effect (gated on total > 0) never ran —
modal spun forever. Gate `anyQueued` on `count > 0` so a zero-photo
response takes the terminal reset path.

Concern 1: processing-stage failures were invisible — the modal
auto-closed on clean transfer before the worker reported them. Defer the
settle/close decision to the completion effect (combining transfer +
processing failures), and persist failed photos into `processingFailures`
state before `uploadIds` is cleared, so the rows don't vanish the instant
they appear.

Also: report card gets role="status"/aria-live (nit), and tests now cover
the whole-chunk transfer failure, the clean-settle path, and the
onUploadSettled contract from the real component.
2026-07-01 08:03:01 +02:00
André Deuerling 2a81d992f1 🎨 Address review: persist-on-click + radiogroup toggle
- Persist the layout choice in the toggle click handlers instead of a
  useEffect, so simply opening the Photos tab no longer re-writes the
  value it just read from localStorage (review concern 1).
- Give the Grid/List toggle radiogroup/radio + aria-checked semantics
  so a screen reader announces them as one mutually-exclusive set
  (review concern 2).
- Add a test that mount performs no localStorage write.
2026-07-01 07:55:49 +02:00
Paul Nothaft 22b20e8314 Merge pull request #711 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.76.2-beta.0
2026-06-30 22:57:16 +02:00
github-actions[bot] 79924e10d9 chore(main): release 3.76.2-beta.0 2026-06-30 20:52:33 +00:00
Paul Nothaft 3feed0fae6 Merge pull request #710 from PicPeak/fix/whatsnew-workflow-no-checkout
fix(ci): whatsnew highlights — set GH_REPO so gh runs without a checkout
2026-06-30 22:52:08 +02:00
Paul Nothaft 2a5f0a8601 fix(ci): whatsnew highlights — set GH_REPO so gh runs without a checkout
Release Please Beta on v3.76.1-beta.0 hard-failed at the very first
`gh release view "$TAG"` call:

  failed to run git: fatal: not a git repository
  (or any of the parent directories): .git

The reusable `whatsnew-highlights.yml` (PR #703) doesn't run
actions/checkout — so when `gh` tried to infer the target repo from
the runner's empty workspace it errored out. The first time it ran
against an actual release (#709 → 3.76.1-beta.0), the whole job died
before the deterministic-fallback path could save it.

Two changes, both single-line:

1. `env.GH_REPO: ${{ github.repository }}` at job scope. `gh` honours
   this and won't fall back to parsing `.git/config`, so no checkout
   is needed (the workflow only calls the GitHub API, never reads
   repo files).

2. `continue-on-error: true` on the "Extract Features" step. The
   file's comments say "never let highlights break a release", but
   the original wiring only soft-failed the AI + inject steps. A
   transient API hiccup at extract still hard-failed the whole job —
   defeating the design intent. Match the comment.

Why not just add actions/checkout? It would work, but pulls the whole
repo over the wire on every release just for `gh` to read its own
config. GH_REPO is the lighter idiom.

Net impact today: v3.76.1-beta.0 shipped without the `<!-- whatsnew -->`
block; the app's parseWhatsNew() already falls back to the raw Features
list so the admin "What's New" banner still works. The next beta release
will pick up the polished version.
2026-06-30 22:47:10 +02:00
Paul Nothaft 152952877f Merge pull request #709 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.76.1-beta.0
2026-06-30 22:42:09 +02:00
github-actions[bot] 753e680b1c chore(main): release 3.76.1-beta.0 2026-06-30 20:33:22 +00:00
Paul Nothaft 0b74b51f40 Merge pull request #703 from Luca-Timo/ci/whatsnew-highlights-workflow
ci(whatsnew): generate release highlights via GitHub Models
2026-06-30 22:33:00 +02:00
André Deuerling cc26296c58 🐛 Keep upload modal open when some files fail
The failure report lives inside the upload modal, but the modal
auto-closed the instant the transfer finished (handleUploadComplete →
onClose), unmounting the report before the user could read it — so the
"which files failed" list never actually appeared.

Split the modal's completion callback in two:
- onUploadComplete: refresh the grid only (no close), as bytes land and
  again when processing finishes
- onUploadSettled({ hasFailures }): fired once the transfer settles; the
  modal auto-closes only on a clean upload and stays open (report
  visible) when any file failed

Also reset the transfer UI when nothing was queued (every file failed),
which previously left the modal spinning forever. Add a PhotoUploadModal
test covering close-on-clean vs stay-open-on-failure.
2026-06-30 21:22:49 +02:00
André Deuerling 1b0100cfad Surface which files failed during photo upload
A partial upload only told the admin "some files failed" with no way to
find out which ones — even though the data existed. The backend already
returns per-file rejections (response.errors: [{filename, error}]) and the
progress hook already exposes failedPhotos, but both were dropped.

Add a dismissible failure report to the upload modal listing every file
that didn't make it into the gallery, grouped by stage with its reason:
- rejected: per-file validation rejections from the upload response
  (previously discarded entirely)
- transfer: whole-chunk request failures (now captured with the error,
  not just the filename)
- processing: background-worker failures from useUploadProgress.failedPhotos

Replace the count-only "some files failed" toast with one that points at
the list. Add en/de keys under upload.failures.* and a component test
covering the rejected + processing rows and dismissal.
2026-06-30 21:08:02 +02:00
André Deuerling 46ce59d82e Add grid/list layout toggle to admin Photos tab
The event detail Photos tab (AdminPhotoGrid) only offered a thumbnail
grid. Add a Grid/List toggle in the action bar so admins can scan
photos in a compact, metadata-oriented list.

- New utils/photoViewPrefs.ts persists the choice per admin via
  localStorage (mirrors utils/calendarPrefs.ts), defaulting to grid
- List view is a compact <table> following the established admin
  list pattern (EventsListPage), with responsive column hiding:
  Photo (thumbnail + filename + original + Video/Hidden badges),
  Category (lg+), Uploaded date (md+, via useLocalizedDate),
  Engagement views/downloads/likes (xl+), Feedback rating/comments
  (sm+), Size, and hover Actions (download, delete)
- Rows reuse the existing selection, download, delete and category
  handlers; row click opens the photo viewer
- Toggle buttons use LayoutGrid / List icons with aria-pressed state
- Add en.json + de.json keys under admin.photos (viewMode, gridView,
  listView, columns.*)
- Tests for the persistence util and the toggle's render + persistence
2026-06-30 19:17:26 +02:00
Luca 5582644dc4 fix(whatsnew): decode HTML entities and trim em-dash detail in fallback bullets
The Features-fallback showed raw changelog text, so a commit subject like
'branded URL shortener — /s/<slug> with OG injection' surfaced two problems
in the admin banner:
- release-please escapes <slug> to &lt;slug&gt;; React renders the literal
  entity, so the banner read '/s/&lt;slug&gt;'. Decode the entities
  (&lt; &gt; &amp; &quot; &#39;), &amp; last to avoid double-decoding.
- the technical tail leaked into a user-facing highlight. Drop a trailing
  '— detail' clause (em dash only, so 'mark-paid' is untouched) so the bullet
  reads as the headline 'branded URL shortener'.

Only affects the deterministic fallback; curated <!-- whatsnew --> blocks are
unchanged.
2026-06-30 18:39:21 +02:00
Luca d25178d2e5 ci(whatsnew): let the Models step fail soft so the fallback runs without Models
If GitHub Models is disabled for the org the ai-inference step errors;
without continue-on-error the job would go red and skip the inject+fallback.
Mark it continue-on-error so an unavailable Models cleanly degrades to the
deterministic bullets — the feature now works with Models off, not just on.
2026-06-30 17:28:31 +02:00
Paul Nothaft 627c655a4d Merge pull request #704 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.76.0-beta.0
2026-06-30 17:12:31 +02:00
github-actions[bot] 6439cf2e42 chore(main): release 3.76.0-beta.0 2026-06-30 15:02:34 +00:00
Paul Nothaft a0f7033ffc Merge pull request #702 from PicPeak/feat/branded-short-urls-699
feat(gallery): branded URL shortener — /s/<slug> with OG injection (#699)
2026-06-30 17:01:16 +02:00
Paul Nothaft 3ac88370d6 Merge pull request #701 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.75.1-beta.0
2026-06-30 17:00:10 +02:00
Luca 5aeb6905ac ci(whatsnew): generate release highlights via GitHub Models
Activate the What's New highlights step that condenses each release's
Features into <=8 short bullets and injects a <!-- whatsnew --> block the
app reads (utils/whatsNew.parseWhatsNew), with a deterministic fallback.

Runs as a needs: job inside the release-please workflows rather than on a
standalone release: published trigger, because release-please creates the
release with GITHUB_TOKEN and GitHub never starts new workflow runs from
token-generated events -- a standalone trigger would never fire. Shared as
a reusable workflow_call so the stable and beta channels stay in sync.

Best-effort: continue-on-error + fallback mean it can never break a release.
Requires GitHub Models enabled for the org; until then the fallback is used.
2026-06-30 16:43:54 +02:00
Paul Nothaft 56c2386c90 feat(gallery): branded URL shortener — /s/<slug> with OG injection (#699)
Issue 3 from #699 (@alexvaltchev's report): expose a custom-named short
URL per event that bots scrape for OG previews and browsers redirect to
the underlying gallery. WhatsApp / iMessage / Facebook cache the OG
metadata by the URL they crawl, so the SHORT URL becomes the cache key
— admins can rotate or split-test underlying gallery URLs without
re-pushing a fresh link to clients.

Additive feature; no existing route, table, or column is modified.

## Backend

- `gallery_short_urls` table (migration 150): id, short_slug UNIQUE,
  event_id FK CASCADE, target_path TEXT, created_by/at, hit_count,
  last_hit_at, deleted_at/by. hasTable-guarded so the migration is
  idempotent on re-run.

- `src/services/galleryShortUrlService.js` — validator + CRUD +
  resolver. Slug rules: `/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/`,
  reserved blocklist (admin, api, auth, gallery, og, s, login, ...).
  target_path snapshots at create-time from the event + global
  short-URL toggle, so a later flip of the toggle does NOT silently
  change where existing short URLs resolve.

- `src/routes/adminShortUrls.js` — `GET/POST
  /api/admin/events/:eventId/short-urls`, `DELETE
  /api/admin/short-urls/:id`. Structured errors: 400 INVALID_SLUG,
  409 SLUG_TAKEN (with `suggested`), 404 EVENT_NOT_FOUND. Gated by
  events.view / events.edit + requireEventOwnership.

- `server.js` /s/:shortSlug public route. Bot UA → server-render the
  same OG metadata the existing /og/gallery/<slug> handler produces,
  then override og:url to point at /s/<shortSlug> itself (cache-key
  invariant — social platforms key by the URL they scrape).
  Browser UA → 302 to target_path. Soft-deleted slug → 410 Gone
  (intentional-delete signal, distinct from 404 unknown slug).
  Hit accounting is fire-and-forget.

## Frontend

- `services/shortUrls.service.ts` — list/create/remove.
- `components/admin/ShortUrlsCard.tsx` — per-event card on the
  EventDetailsPage. Form for custom or auto-generated slug, list with
  copy-to-clipboard + soft-delete. SLUG_TAKEN error surfaces the
  service's `suggested` slug with a "use suggested" button.
- i18n: events.shortUrls.* added to EN + DE.

## Tests

78 new tests, all passing:

- `__tests__/utils/galleryShortUrlValidation.test.js` (48) — pure-
  function tests for validateSlug: accepts/rejects, reserved-slug
  blocklist, path-traversal + URL-injection vectors.
- `__tests__/integration/galleryShortUrls.test.js` (19) — service
  layer against a real SQLite DB. Covers custom + auto-generated
  slugs, collision + SLUG_TAKEN + suggested, target_path
  snapshotting (backward-compat invariant), soft-delete + slug
  rotation, hit counting.
- `__tests__/integration/galleryShortUrlRoute.test.js` (11) —
  HTTP-level: 302 redirect for browser UA, 200 + OG HTML for bot UA,
  og:url canonical points at /s/<slug>, 410 for soft-deleted +
  orphaned events, 404 unknown + malformed.

Regression sweep: 47 existing migration-chain integration tests still
pass; migration 150 is additive only.

## Backward compatibility

- Existing `/gallery/<slug>`, `/gallery/<32-hex-share-token>`,
  `/gallery/<slug>/show/<token>`, `/og/gallery/<slug>`,
  `/og/gallery/<slug>/cover` routes are untouched.
- The `/s/` namespace is new; no existing route lives there.
- Migration 150 only ADDs the new table — no ALTERs on existing
  schema, no destructive changes.
- target_path is snapshotted at create-time so flipping the global
  "Use short gallery URLs" setting after a short URL exists does NOT
  change where that short URL resolves.
2026-06-30 16:30:13 +02:00
github-actions[bot] 541b3d32ef chore(main): release 3.75.1-beta.0 2026-06-30 14:09:58 +00:00
Paul Nothaft 25bf7bb523 Merge pull request #700 from PicPeak/fix/og-injection-share-token-and-slideshow-699
fix(og): rich social previews for share-token + slideshow URLs (#699)
2026-06-30 16:09:31 +02:00
Paul Nothaft 1b8747dc82 fix(og): rich social previews for share-token + slideshow URLs (#699)
Two SSR-OG injection bugs reported by @alexvaltchev. Both made his link
previews fall back to the brand logo + site-wide tagline instead of the
event-specific name/photo, even though the bot UA was hitting our
already-existing OG handler. He compensated with a Cloudflare Worker as
SSR middleware — which then created bug 3 below (og:image at the
auth-gated /api/.../hero/ path, not the public /og/.../cover one), so
Instagram never rendered the image either.

## Bug A — slideshow URLs miss the OG handler entirely

`/gallery/<slug>/show/<token>` has 3 segments after `/gallery/`. The OG
route was wired only at `/gallery/:slug/:token?` (1-2 segments), so
slideshow links fell through to the SPA-catchall `/gallery/*` and never
invoked the OG handler at all. Added a second route handler for the
3-segment slideshow shape, sharing the same intercept middleware so a
recognised social crawler still gets the rich preview.

## Bug B — share-token-only URLs resolve to nothing

`/gallery/<32-char-share-token>` (the form produced when migration 525's
short-URLs option strips the event slug) routes to the OG handler with
`slug=<token>`. resolveSlug then queries `events.slug = <token>`, which
never matches because the token is in a separate `share_token` column.
Result: falls through to the "no event found" branch and serves the
generic site-wide OG.

Fix: when the slug shape matches a 32-char hex AND the slug lookup
missed AND no redirect rule applies, try `events.share_token = slug` as
a final fallback. Real slugs are kebab/dot/underscore mixes, never pure
32-hex, so the extra DB roundtrip is gated to only fire for the
token-shaped URL.

## Tests

3 new tests in galleryOgService.shareImage.test.js using non-entropy
32-hex fixtures (deliberately zero-padded to avoid tripping
GitGuardian's Generic High Entropy Secret detector while still
matching the route's /^[a-f0-9]{32}$/i shape check):
- share-token slug resolves via the share_token column (alex's case)
- malformed/expired 32-hex token returns the site-wide fallback (no leak)
- non-hex slugs skip the share_token query entirely (hot-path cost guarded)

All 14 tests in the file pass.

## Out of scope here (separate follow-up)

- Issue 2 (Instagram og:image) — alex-side CF Worker bug pointing
  og:image at /api/gallery/<slug>/hero/<id>, which requires gallery
  auth. PicPeak already has the right unauthenticated path
  (/og/gallery/<slug>/cover) gated by events.og_image_share_enabled
  per-event opt-in (#474). Documented in the issue reply.
- Issue 3 (URL shortener with custom names) — real feature request,
  meaningfully different from the existing #525 short-URLs option that
  just strips the slug. Designing separately.
2026-06-30 16:03:49 +02:00
Luca de789faec5 Merge pull request #698 from Luca-Timo/docs/comparison-pixieset 2026-06-30 12:35:29 +02:00
Paul Nothaft 52dfe2723d Merge pull request #697 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.74.1-beta.0
2026-06-30 11:44:10 +02:00
github-actions[bot] 5925bed761 chore(main): release 3.75.0-beta.0 2026-06-30 09:44:00 +00:00
Paul Nothaft a1a73bf75f Merge pull request #694 from Luca-Timo/feat/whatsnew-notifications
feat(updates): "What's New" highlights after update + pre-update teaser
2026-06-30 11:43:39 +02:00
Luca 3528f6b8b7 Merge pull request #696 from Luca-Timo/docs/contributor-luap
docs(readme): credit @the-luap as creator/lead maintainer

Docs only no change in Codebase
2026-06-30 11:23:02 +02:00
Luca b0439638bd docs(readme): clarify comparison footnotes — $0 cost caveat + Pixieset video cap
- you still bring your own server (own hardware or VPS) and optional domain.
- Pixieset "unlimited" storage is photos only — video is capped per plan (~0–10 h depending on tier).
- Renumber the PicPeak storage footnote (* → **) so the three markers don't collide.
2026-06-30 11:20:37 +02:00
Luca 721f440fa6 docs(readme): add Pixieset to comparison + customer-accounts/CRM/accounting rows 2026-06-30 11:07:58 +02:00
Luca 748238e8ca docs(readme): credit @the-luap as creator/lead maintainer 2026-06-30 11:06:04 +02:00
Paul Nothaft 0191b283d7 Merge pull request #692 from PicPeak/chore/security-bumps-aug-2026-batch
chore(security): close all 27 code-scanning alerts via dep + base-image bumps
2026-06-30 10:58:09 +02:00
Paul Nothaft e48b81fb8d Merge pull request #693 from PicPeak/chore/workflow-required-checks-always-fire
ci: required-check workflows fire on every PR (drop paths filter + post-rename branch list)
2026-06-30 10:53:25 +02:00
Luca 500cf8522e feat(updates): "What's New" highlights after update + pre-update teaser
Surfaces release highlights to admins, sourced from the GitHub release notes
(no AI at runtime). Bullets are written once per release in CI via GitHub Models
(see docs/ci/whatsnew-highlights.yml) into a <!-- whatsnew --> block; the app
reads that block and falls back to the changelog's "### Features" for releases
without it — so it works against today's releases immediately.

- backend utils/whatsNew.parseWhatsNew(body): curated block else Features
  section, strips scope/PR-links, de-dups, caps at 8 (tested).
- GET  /admin/system/updates/whatsnew: highlights for every version moved
  through since the per-instance marker (whatsnew_last_seen_version); fresh
  installs self-anchor silently. Best-effort, never errors.
- POST /admin/system/updates/whatsnew/seen: advance the marker (per-instance).
- /admin/system/updates also returns latestHighlights for the teaser.
- Frontend: WhatsNewBanner (green bar -> modal with "Full changelog" link) on
  the dashboard via adminService; UpdateNotification shows a "New features
  include:" teaser. i18n de/en. No migration (uses app_settings).
2026-06-30 02:31:13 +02:00
Paul Nothaft a40ab6a9b1 ci: required-check workflows now fire on every PR (no paths filter)
Branch protection on `main` + `stable` lists `upgrade-from-bootstrap`
and `fresh-install` as REQUIRED checks. The producing workflows had
`paths:` filters in their `pull_request` triggers, so they correctly
skipped on PRs that didn't touch migrations / package.json. But a
skipped workflow doesn't satisfy a required check — it leaves the
status "missing", which blocks merge on every unrelated PR.

Concretely surfaced on PR #692 (security bumps): all 12 visible checks
were green, but the merge button was blocked because the two
path-filtered workflows skipped and their required-check names never
reported.

This PR drops the `paths:` filter from both workflows so they always
fire on PRs against `main` + `stable`. Costs:
- `schema-drift` (`upgrade-from-bootstrap`): ~75 s per PR (Postgres
  service boot + migrate:safe run + schema assertion).
- `install-smoke` (`fresh-install`): ~2 min per PR (full Docker
  Compose boot + login).

Both are buying unconditional safety nets on the install + migration
paths, which is what the required-check gate is supposed to model.

Also fixes the trigger branch list while in the file: `[main, beta]`
→ `[main, stable]`, completing the post-#669 rename for these two
workflows that were missed in PR #686.

## What this does NOT fix

`GitGuardian Security Checks` is the third required check that's
currently missing on PRs — but that's a separate problem. The
GitGuardian GitHub App was installed at the user-account level
(`the-luap`) before the org transfer and didn't move with the repo.
Re-installing it on the org via the GitHub Marketplace is a UI step
the maintainer needs to do; can't be done via API.
2026-06-30 00:07:44 +02:00
Paul Nothaft 7546f104a3 chore(security): close 27 code-scanning alerts via dep + base-image bumps
Single PR closing every open code-scanning alert at
https://github.com/PicPeak/picpeak/security/code-scanning. Both repos go
from 27 open alerts → 0 across direct deps, transitive deps, and build-
time bundled deps.

## Backend (`backend/package.json` + overrides)

Direct dep bumps:
- axios          1.15.2   → 1.16.0       (closes 9 alerts: 7 high + 1 med + 1 low)
- nodemailer     8.0.10   → ^9.0.1       (closes 1 high — SSRF + file-read via raw option)
- multer         2.1.1    → 2.2.0        (closes 2 alerts: 1 high + 1 med)
- form-data      4.0.5    → 4.0.6        (closes 1 high)
- tar            ≥7.5.13  → ≥7.5.16      (closes 1 med)
- postcss        8.5.6    → 8.5.10       (closes 1 med)
- i18next-http-backend  3.0.2  → 3.0.5   (closes 1 med — backend lagged frontend)
- js-yaml        4.1.1    → ^4.2.0       (closes 1 med)
- joi            17.13.3  → ^17.13.4     (closes 1 med)

Overrides updated to match deps (npm rejected the install otherwise) +
nodemailer ^9.0.1 added as override so imapflow + mailparser transitive
bundling of older nodemailer is also fixed. Babel devDep auto-bumped via
`npm audit fix` (low-severity arbitrary file read).

Backend npm audit: 0 vulnerabilities.

## Frontend (`frontend/package.json`)

Direct dep bumps:
- axios                 1.15.2  → 1.16.0
- postcss               8.5.6   → 8.5.10
- i18next-http-backend  3.0.5   → 3.0.5  (already current — kept for parity)

`npm audit fix` swept up 12 transitive issues at the same time:
- vitest (1 critical — file read on UI server)
- vite (2 high — fs.deny bypass, NTLM hash via launch-editor)
- ws (2 high — uninitialized memory + DoS)
- dompurify (8 mod — multiple IN_PLACE / hook-pollution XSS vectors)
- react-router-dom + react-router (1 mod transitive)
- esbuild (1 mod — dev server file read)
- @babel/core (1 low)

Frontend npm audit: 0 vulnerabilities.

## Frontend Dockerfile

- Build stage: `node:20-alpine` → `node:22-alpine`

Closes the npm-bundled CVE class (picomatch, ip-address, brace-expansion,
@sigstore/core, tar) that came from Node 20's older bundled npm. Matches
the backend Dockerfile base. The nginx serving stage stays at
`nginx:1.28-alpine` — that tag is rolling, so the next build picks up
the fixed 1.28.3-r4 layer that closes the 4 nginx CVEs.

## Verification

- Backend: `npm audit` → 0 vulnerabilities 
- Frontend: `npm audit` → 0 vulnerabilities 
- Backend Jest (workflow engine, rounding, WhatsApp): 47/47 pass 
- Frontend Vitest: 84/84 pass 
- `frontend npm run build`: succeeds 
- nodemailer 9 sanity check: our usage is `createTransport({host,port,secure,auth})`
  + `sendMail({from,to,subject,html,text})` — we don't touch the `raw`
  option that 9.x tightened, so the major bump is API-compatible.
2026-06-29 23:26:32 +02:00
Paul Nothaft a24821de55 Merge pull request #691 from PicPeak/chore/bypass-size-gate
ci: bypass size gate — cap self-merge PR size for bypass users
2026-06-29 23:15:45 +02:00
Paul Nothaft 806b1ac921 ci: bypass size gate — cap self-merge PR size for review-bypass users
@Luca-Timo is on main's review-bypass list so he can self-merge small
bugfixes without waiting for a maintainer review. The bypass list alone
is binary (he can merge anything), so this adds a complementary required
status check that fails when a bypass user's PR exceeds a configured
line-count threshold — blocking merge for genuine features while leaving
small bugfixes flowing.

How it works:
- Trigger: pull_request_target (so the workflow runs in the base repo's
  context with permissions to write a check status — script never
  executes PR code, so fork-PR-attack-safe).
- For PRs authored by a bypass user (default: @Luca-Timo):
    - linesChanged = additions + deletions
    - If ≤ LINE_LIMIT (300): check = success → bypass works → self-merge OK
    - If > LINE_LIMIT: check = failure → required-check gate blocks merge
      regardless of bypass; needs a maintainer review.
- For everyone else: check = success ("not applicable"). They go through
  the normal review path and are unaffected.

Both constants (LINE_LIMIT, BYPASS_USERS) are at the top of the workflow
for easy tuning.

After this lands on main, a separate API step adds 'bypass-size-gate' to
the main branch's required_status_checks list so the gate is actually
enforced. Until that's in place the check runs but doesn't block.
2026-06-29 23:13:09 +02:00
Paul Nothaft beae46e408 Merge pull request #689 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.74.0-beta.0
2026-06-29 22:44:39 +02:00
github-actions[bot] 5ba45753c1 chore(main): release 3.74.0-beta.0 2026-06-29 20:39:22 +00:00
Paul Nothaft 14bd3e1a6c Merge pull request #688 from PicPeak/chore/readme-migration-banner
docs: prominent migration banner at the top of README
2026-06-29 22:29:09 +02:00
Paul Nothaft 5839bba72a docs: prominent migration banner at the top of README (#669)
GitHub-flavored `> [!IMPORTANT]` callout right below the title, before
the badges/hero block, so it's the first thing a visitor or repo browser
sees in the rendered README. Mirrors the in-app banner (#687) so an
operator gets the same message whether they're browsing the repo or
logged into the admin dashboard.

Body covers:
- Image-path change with the literal new path
- Branch rename (beta → main, main → stable) with auto-redirect note
- Link to docs/migration-to-org.md for the exact compose-file edit

Remove (or downgrade to a regular note) after the migration window
settles, same lifecycle as the in-app banner constant.
2026-06-29 22:24:56 +02:00
Paul Nothaft 02133478bd Merge pull request #687 from PicPeak/chore/in-app-migration-banner
feat(admin): in-app migration banner for the org rename
2026-06-29 22:22:52 +02:00
Paul Nothaft 297c2d3df0 Merge pull request #686 from PicPeak/chore/post-rename-workflow-triggers
chore: workflows + RELEASING.md for the post-rename branch model
2026-06-29 22:21:56 +02:00
Paul Nothaft 166ef47611 Merge pull request #685 from PicPeak/chore/post-org-move-docs-and-contributing
docs: branch model + migration-to-org guide + PR template
2026-06-29 22:21:24 +02:00
Paul Nothaft 2a4bf3b868 feat(admin): in-app migration banner for the org rename (#669)
One-time banner shown at the top of the admin layout to surface the org
rename + GHCR registry change for operators who haven't read the release
notes. Sits right below the existing maintenance banner — same pattern.

## What it looks like

Blue, dismissible banner with a short body:

> PicPeak's image registry has moved
> Update your docker-compose.yml to pull from
> ghcr.io/picpeak/picpeak/{backend,frontend} — the old path is no longer
> being updated. [See migration notes]

The link goes to `docs/migration-to-org.md` on the new org repo.

## Design choices

- **No backend feature flag.** A hard-coded `MIGRATION_BANNER_ENABLED`
  constant in the component file (1 line) gates global display. After
  ~1 quarter, flip it to false (or drop the mount in `AdminLayout.tsx`)
  in a small follow-up PR. A backend `app_settings` row + Settings UI
  toggle would be overkill for a one-time migration event.

- **Per-admin dismissal via localStorage.** Key is `picpeak:migration-banner:v1`
  (versioned so a future "we've moved AGAIN" banner can show without
  inheriting earlier dismissal). Wrapped in try/catch so private-mode
  browsers + storage-quota-exceeded errors don't crash the layout.

- **EN + DE strings** under a new top-level `migrationBanner` namespace.
  Other locales (fr, nl, pt, ru) fall through to EN — `migrationBanner.*`
  keys aren't translated there yet, deliberate (per #669 the ops
  banner is operator-facing and admins reading EN/DE is the majority).

- **Reuses `common.dismiss`** for the close-button aria-label.

## Test plan

- [ ] Frontend `npm run build:check` passes (TS + build)
- [ ] Open admin dashboard in EN → banner shows at top, below header,
      above main content
- [ ] Switch to DE → banner shows German strings
- [ ] Click dismiss → banner hides, doesn't re-appear on hard refresh
- [ ] Clear localStorage `picpeak:migration-banner:v1` → banner returns
- [ ] Flip `MIGRATION_BANNER_ENABLED` to false → banner doesn't render
      for anyone, regardless of dismissal state

Refs #669.
2026-06-29 22:19:22 +02:00
Paul Nothaft 3ca6378bd7 chore: workflows + RELEASING.md for the post-rename branch model
After the org move + branch rename (#669):
  beta  → main   (active development)
  main  → stable (curated release channel)

This PR rewires the workflows that referenced the old branch names so
release-please and the Docker build target the right channels.

## Workflow changes

### `.github/workflows/docker-build.yml`

- **Push triggers**: `[main, beta]` → `[main, stable]` (both `push.branches`
  and `pull_request.branches`). `beta` no longer exists; `stable` is the
  curated channel that should also produce builds.
- **`is_prerelease` detection**: pre-release context was decided by
  `refs/heads/beta`; now decided by `refs/heads/main` (active dev →
  prerelease, `-beta.N` version suffix unchanged).
- **`:latest` + `:stable` tagging**: were gated on `{{is_default_branch}}`
  (which used to be `main` = stable channel). Default branch is now `main`
  = active dev, so the implicit gate would have aliased `:latest` to dev.
  Both tags now explicitly gate on `refs/heads/stable` OR a non-prerelease
  release tag.
- **`:beta` tag**: REMOVED. Active-dev pulls are `:main` (auto-generated
  by `type=ref,event=branch`). The pre-rename `:beta` tag remains frozen
  at its last build under Option B / #669 — operators are expected to
  update to `:main` or pin to a versioned tag.

### `.github/workflows/release-please.yml`

- `branches: [main]` → `branches: [stable]`. This is the **stable**
  release-please workflow (uses `release-please-config.json`); after the
  rename, the stable channel lives on the `stable` branch.

### `.github/workflows/release-please-beta.yml`

- `branches: [beta]` → `branches: [main]`.
- `target-branch: beta` → `target-branch: main`.
- This is the **pre-release** release-please workflow (uses
  `release-please-config-beta.json`, `prerelease: true`); after the rename,
  pre-releases are cut from the new `main` (active dev). The version-suffix
  scheme stays `-beta.N` so existing operator pins keep working.

## RELEASING.md

Rewrote the TL;DR, "How a stable release is cut", and hotfix path to
reference the new branch names. Added a one-line "branch model background"
note pointing at #669 so future maintainers know why `main` means active
dev (the opposite of what some projects use). Filename conventions:
`release/X.Y.Z-merge-from-main` (was `…-from-beta`); promotion PR title
`promote main → stable as vX.Y.Z` (was `promote beta → main`).

## Why combined with PR A's content as a single PR

Originally planned as two PRs (B = workflow triggers, C = release-please
reconfigure). Splitting wasn't worth it: the configs are branch-agnostic
(`release-please-config.json` and `release-please-config-beta.json` don't
mention branch names internally), and not bundling them meant a window
where the stable release-please workflow would fire on pushes to the new
`main` (active dev) — exactly the wrong place. Single PR closes that gap.

## Versioning scheme — kept

No version-scheme decision needed. The `-beta.N` suffix on pre-release
versions is preserved (existing operator pins like `v3.71.3-beta.0` keep
working). If a `v4.0.0-pre.N`-style reset is desired later, that's a
separate PR with explicit operator-comms attached.
2026-06-29 22:15:31 +02:00
Paul Nothaft d606fcd5a4 docs: branch model + migration-to-org guide + PR-template target hint
Operator + contributor docs for the post-org-move world. None of these
files reference the legacy branch names (`beta` / old `main` meaning) —
they describe the new shape (`main` = active dev, `stable` = curated
release channel), so they're correct from the moment the rename happens.

Three additions/edits:

1. `docs/migration-to-org.md` (new) — operator-facing one-pager that the
   in-app migration banner + the OLD GHCR package URLs (now 404) can
   point at. Walks through the single `docker-compose.yml` edit needed.

2. `CONTRIBUTING.md` — new "Branch model" section explaining which
   branch to target (`main` for features + most fixes; `stable` only
   for small, surgical bugfix backports). Updates the "fork from beta"
   step to "fork from main". Updates the release-process paragraph to
   describe the two-channel model instead of the old beta→main promote.

3. `.github/PULL_REQUEST_TEMPLATE.md` — adds a target-branch hint at
   the top of the template (HTML comment so it shows during PR
   composition but doesn't render in the merged PR body).
2026-06-29 22:05:56 +02:00
Paul Nothaft 448da95020 Merge pull request #684 from PicPeak/chore/migrate-image-registry-to-picpeak-org
chore: migrate Docker registry + GitHub URLs to PicPeak org
2026-06-29 20:25:15 +02:00
Paul Nothaft 0205c7dcce chore: migrate Docker registry + GitHub URLs to PicPeak org
Repo transferred from the-luap/picpeak → PicPeak/picpeak. Docker images
publish to ghcr.io/picpeak/picpeak/{backend,frontend} (lowercase, per the
GHCR canonical form computed by docker-build.yml's `${GITHUB_REPOSITORY,,}`).

Sweep covers:
- docker-compose.production.yml + Dockerfiles → new image registry path
- README, CONTRIBUTING, SECURITY, SIMPLE_SETUP, scripts/picpeak-setup.sh
  → new GitHub URLs
- Update-check / release-notes services (updateCheckService,
  environmentService, updateNotificationService, adminSystem,
  UpdateNotification, githubReleaseUrl) → GitHub API + tag URLs use the
  canonical PicPeak/picpeak path
- Issue templates + README-DOCKER + workflow README → updated package URLs
- One commit-context comment in migrations/090 + customerAccountsService

CHANGELOG.md is intentionally untouched (historical release entries are
immutable; GitHub auto-redirects the old URLs indefinitely).
CLAUDE.md keeps the bare `(the-luap)` reference — that's the maintainer's
personal handle, not a repo URL.

22 files, 48/48 line swaps (every change is a 1:1 URL replacement).
2026-06-29 20:20:13 +02:00
Paul Nothaft 5ebe126970 Merge pull request #683 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.73.0-beta.0
2026-06-29 20:00:07 +02:00
github-actions[bot] 578a73f352 chore(beta): release 3.73.0-beta.0 2026-06-29 17:57:55 +00:00
Paul Nothaft 86475582e8 Merge pull request #682 from Luca-Timo/fix/invoice-draft-and-nits
CRM: held invoices read as "Draft", + mark-paid / dashboard / reminder fixes
2026-06-29 19:57:27 +02:00
Luca e4367e028a fix(invoices): badge held (unsent, no send date) invoices as "Draft"
The earlier change only relabeled is_monthly_draft rows. But a per-event
invoice created from hours is status 'scheduled' with scheduled_send_at = NULL
and is_monthly_draft = false — it never auto-ships (the scheduler only picks
rows with scheduled_send_at <= now), yet it still read "Scheduled" on the
customer panel + lists.

Add a shared isDraftInvoice() helper (scheduled && no send date, or a
monthly/manual accumulator) and use it for the badge in the Bills list, the
invoice detail header, and the customer profile's invoice panel. A scheduled
invoice WITH a future send date keeps "Scheduled".
2026-06-29 19:39:40 +02:00
Luca d1c9e02bcf feat(dashboard): revenue "year" tile toggles 365 days ↔ calendar YTD
Per request, keep the dashboard to four tiles rather than adding a fifth: the
"Revenue · last 365 days" tile is now clickable and toggles in place between
the trailing-365-day window and calendar year-to-date (since Jan 1).

- adminDashboard: new calendar-year cutoff + revenue.calendarYearMinor (same
  cash-basis paid_at window logic as the existing trio).
- StatCard gains an optional onClick (renders as a button); the year tile uses
  it, with a "Tap to switch window" hint for discoverability.
- bills.service CrmOverviewStats.revenue gains calendarYearMinor.
2026-06-29 19:39:40 +02:00
Luca e96ef4c5a3 fix(invoices): add bank transfer to the mark-paid method list
The mark-paid dialog offered Cash / Card / PayPal / TWINT but not bank
transfer — the default method for the QR-bill / IBAN invoices picpeak issues
(createInvoice even falls back to 'bank_transfer'). Added it as the first
option. Backend already accepts paymentMethod as a free string, so no API
change; i18n bills.payment.methods.bankTransfer (de "Überweisung").
2026-06-29 19:39:40 +02:00
Luca ca0944293f fix(invoices): show "Draft" on the invoice detail page for accumulator drafts
Follow-up to the Bills-list change: the invoice detail header still printed
"Scheduled" for a running monthly/manual draft (is_monthly_draft). It already
had a separate monthly-draft badge, but the status pill itself now reads
"Draft" too, matching the list and the Billed-chip link target.
2026-06-29 19:39:40 +02:00
Luca e457656b9d feat(invoices): surface monthly/manual accumulator drafts in the Bills list
Manual/monthly-cadence customers accumulate logged hours into one running
draft invoice (is_monthly_draft, migration 128). That draft gets a real
invoice number and stamps the hours ("Billed: R-2026-0026"), but listInvoices
hid is_monthly_draft rows from the main list — so the invoice looked lost even
though it existed on the customer's monthly-queue card. It also carried status
'scheduled' despite never auto-sending on manual cadence, reading misleadingly
as "Scheduled".

- Bills list now opts into drafts via a new `includeDrafts` query param
  (GET /admin/invoices → listInvoices includeMonthlyDrafts). Pickers/sub-lists
  that reuse billsService.list leave it off, so they're unaffected.
- Draft rows render a distinct "Draft" badge instead of "Scheduled"
  (transformInvoice already exposes isMonthlyDraft).
- The hours "Billed: R-…" chip now links straight to its invoice.
- i18n: bills.status.draft (de "Entwurf", en "Draft").
2026-06-29 19:39:40 +02:00
Luca b9d91385b4 fix(reminders): wrap is_active/is_archived wheres in formatBoolean
eventReminderService used bare boolean literals in its knex .where() calls
(events.is_active/is_archived/event_reminder_disabled and the assigned-
customer c.is_active), instead of the codebase's formatBoolean() convention
(utils/dbCompat). On SQLite, booleans are stored as 0/1, so a bare `true`
relies on knex's coercion rather than the explicit helper every other service
uses — the maintainer flagged this twice (#674, #679). Wrap all four.
2026-06-29 19:39:40 +02:00
Paul Nothaft 06f4c109bc Merge pull request #680 from Luca-Timo/fix/invoice-pdf-multipage
Fix/invoice pdf multipage
2026-06-28 22:17:07 +02:00
Paul Nothaft 23d1f5d609 Merge pull request #681 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.72.0-beta.0
2026-06-28 21:57:58 +02:00
github-actions[bot] c4ed88b085 chore(beta): release 3.72.0-beta.0 2026-06-28 19:53:32 +00:00
Paul Nothaft ec33ec7670 Merge pull request #679 from Luca-Timo/feat/booking-cutover
feat(workflows): booking cutover — wire booking actions + hold documents behind approval gates
2026-06-28 21:53:09 +02:00
Luca c2bc2b098e fix(invoices): show sub-cent Rundung in the editor totals preview
The live totals panel in the quote/invoice editor (LineItemsTable) summed the
per-line rounded totals and showed that as Total — so with crm_invoice_round_total
on, a 4 × (2.5h @ 32.25) invoice previewed CHF 322.52 while the saved invoice +
PDF correctly show 322.50 with a Rundung row. The preview now mirrors the backend.

- LineItemsTable gains a `roundTotal` prop. When set, it computes the clean net
  (full-precision sum rounded once — same rule as backend
  utils/invoiceRounding.cleanNetMinor, including the migration-119 priced
  sub-item override), shows a "Rundung" row for the drift, and folds it into the
  VAT base + Total. Off ⇒ unchanged (no row).
- Bill + Quote editors pass roundTotal from appSettings.crm_invoice_round_total.
- i18n: crm.lineItems.rounding (de "Rundung", en "Rounding").

The saved-invoice detail view already shows the stored clean total, so no change
there.
2026-06-28 17:28:21 +02:00
Luca 4670292139 feat(invoices): optional sub-cent rounding reconciliation ("Rundung" row)
Per-line totals are each rounded to the cent before the net is summed, so
a long time-based invoice can drift a few Rappen from qty × rate — e.g.
68 h × 32.25 = 2193.00, but the 21 rounded line totals sum to 2193.02. This
is the standard "sum of rounded lines" convention (Stripe/QuickBooks/Xero
do the same) and it foots, but some issuers want the total to match the
customer's arithmetic.

New per-issuer setting `crm_invoice_round_total` (default OFF, no migration —
read via getAppSetting with a false default). When on, the create paths store
the full-precision net rounded ONCE (cleanNetMinor), and the drift is shown to
the reader as an explicit "Rundung" row:

    Betrag Netto    2'193.02   (= Σ visible line totals, still foots)
    Rundung            -0.02
    Gesamtbetrag    2'193.00

- New util src/utils/invoiceRounding.js (cleanNetMinor) mirrors the
  migration-119 hierarchy (priced sub-items override their parent) but sums
  at full precision; rate-agnostic, so mixed hourly rates reconcile to one
  clean net. Single document-level VAT rate ⇒ one Rundung row.
- computeTotals (quotes) + createInvoice + payload-preview gain the toggle.
- Render contexts derive the row as storedNet − Σ(line totals); legacy/off
  documents have equal values ⇒ adjustment 0 ⇒ byte-identical output.
  Suppressed on Storno/Mahnung (negated net + sign-flipped lines).
- Storno/tax-report stay correct: both use the stored net scalar, which is
  the clean value (createStorno negates net_amount_minor; it never re-sums).
- pdf-i18n: totals_rounding in all 6 locales (de/en/fr confident; nl/pt/ru
  machine-translated — flag for native review).
- Frontend: toggle on Settings → CRM (Invoices), default off.

Tests: backend/__tests__/utils/invoiceRounding.test.js (real 68h invoice,
mixed rates, discounts, sub-item hierarchy, no-op case).
2026-06-28 15:53:44 +02:00
Luca 2205b0bd68 fix(pdf): correct multi-page invoice/quote layout + drop IBAN dup under Swiss QR
Before drawing the line-items table, the renderer inflated page 1's bottom
margin to reserve room for the bottom-pinned totals block, but the `finally`
restored it on whichever page the table *ended* on — leaving page 1
permanently short on any multi-page document. On long invoices and quotes
this caused:

  - the table to break far too early (only ~6 items on page 1, large blank
    gap beneath)
  - the page-number stamp to land below page 1's phantom bottom margin,
    auto-paginating a stray blank trailing page and desyncing the
    "Seite X von Y" labels (page 1 unnumbered, the blank page labelled
    "Seite 1 von N")

Let the table paginate with the document's normal margins so each page fills
to the bottom; the existing desiredTotalsY check already advances to a fresh
page when the last item row would collide with the pinned totals block.

Also suppress the IBAN block under the totals when a Swiss QR-bill slip is
appended: the slip already prints the account/IBAN in human-readable form,
so it was pure duplication. The EPC QR path keeps the block (its QR lives on
a trailing page, so on-page bank details still help).
2026-06-28 14:49:50 +02:00
Luca 6e20d58487 fix(workflows): make the dashboard pending-approvals card items clickable too
The dedicated Approvals page rows open the underlying document on click, but the
identical card on the admin dashboard didn't — so 'clickable approvals' only half
worked depending on where you looked. Apply the same treatment: the info area is
now a button that navigates to the run entity's detail page (quote -> /admin/quotes/:id,
invoice -> /admin/bills/:id, etc.), reusing the workflows.approvals.openEntity
tooltip. Confirm/Deny stay separate; items with no mappable entity render as plain
text.
2026-06-28 14:34:36 +02:00
Luca 539a83711d fix(workflows): defer quote.accepted/declined emit until the 15-min response window locks
The customer's accept/decline can be toggled for crm_quotes_accept_window_minutes
(default 15) before it locks, and the public page promises exactly that. But the
booking workflow fired on the FIRST accept click and immediately converted the
quote (status -> 'converted'), so a decline within the window was rejected
('Quote cannot be responded to in status converted') — the grace period was dead
on arrival.

recordResponse / adminAcceptQuote now DEFER the workflow emit while the toggle
window is open; the new scheduler sweep finalizeQuoteResponses fires the FINAL
status once response_locked_at passes (idempotent via the new
quotes.workflow_response_emitted_at column, migration 149). A response recorded
with the window already closed (0-min window, or admin decline which locks
immediately) still emits inline. So toggling accept->decline->accept inside the
window converts at most once, for the final state, after the customer's grace
period — and a plain decline never converts.

Trade-off: with the hourly CRM scheduler, the booking flow now starts up to ~1h
after the window locks instead of instantly. Acceptable — the flow gates on admin
review anyway, and the alternative (graph-level wait) wouldn't reach already-
enabled built-ins (admin_toggled_at blocks re-seed).

Adds a finalize sweep test (deferred while open, fires + stamps once locked,
idempotent).
2026-06-28 14:30:26 +02:00
Paul Nothaft e6f29b6345 Merge pull request #678 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.71.3-beta.0
2026-06-27 21:56:11 +02:00
github-actions[bot] 74d53b22b1 chore(beta): release 3.71.3-beta.0 2026-06-27 19:50:21 +00:00
Paul Nothaft f01754247c Merge pull request #676 from the-luap/fix/whatsapp-public-events-route-647
fix(events): wire customer notifications into both public API entry points (#647)
2026-06-27 21:50:00 +02:00
Paul Nothaft 54c52d3cf7 Merge pull request #677 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.71.2-beta.0
2026-06-27 21:49:30 +02:00
github-actions[bot] 5c04137a6b chore(beta): release 3.71.2-beta.0 2026-06-27 19:45:27 +00:00
Paul Nothaft c8714ca42f Merge pull request #674 from Luca-Timo/feat/workflow-engine
fix: event-reminder, email-language & gallery-publish bugs surfaced during workflow testing
2026-06-27 21:45:07 +02:00
Paul Nothaft 511d647eec fix(events): wire customer notifications into both public API entry points (#647)
Two entry points for event creation were missing customer notifications, both
discovered while triaging @Rekoo-PS's report that "API created events" don't
send WhatsApp after #649/#650 landed.

POST /api/v1/events (the OpenAPI-spec'd bearer-token API at v1/events.js):
- gallery_created email was NEVER queued — only the webhook fired.
- WhatsApp was NEVER queued either.

POST /api/events (legacy admin-auth route at routes/events.js):
- gallery_created email was queued, but WhatsApp was not.
- customer_phone wasn't read from the body at all.

Both routes now mirror the adminEvents.js create-and-publish path: best-effort
queues that never block the API response, gated on customer_email / customer_phone
presence and the global event_phone_field_enabled toggle for the phone field.

The webhook subject from POST /api/events now also includes customer_phone, so
downstream integrations get the same shape as the v1 API.

No schema change. No migration. customer_phone column already exists on events
(migration 080). WhatsApp config + template_language + template_params resolve
through the existing queue processor.
2026-06-27 21:28:56 +02:00
Luca 882cfc0661 fix(workflows): held booking invoices are 'scheduled', not 'pending_delivery' — so send_document can issue them
A quote with no explicit payment timing falls back to a single after_delivery
installment. spawnInstallmentInvoices marked those 'pending_delivery' even in
hold mode, so the booking flow's send_document -> sendInvoice threw 'Cannot send
invoice with status pending_delivery', the run failed, and no invoice email went
out (the symptom: approve the quote->invoice flow, receive nothing).

In hold mode the flow's review gate + explicit send_document IS the delivery
release, so a held invoice is always 'scheduled' (editable + sendable) regardless
of trigger; scheduled_send_at stays null so the scheduler never auto-sends it.
Non-hold after_delivery invoices keep 'pending_delivery' as before.

Adds a regression test (default after_delivery term -> draft -> scheduled+null).
2026-06-27 12:36:35 +02:00
Luca 7727b6714b feat(workflows): make approval rows clickable to open the underlying document
Each approval asks the admin to confirm/deny, but they couldn't see what they
were approving. The row's prompt/meta area is now a clickable button that
navigates to the run entity's detail page (quote -> /admin/quotes/:id, invoice
-> /admin/bills/:id, event/contract/customer likewise) so the admin can review
before deciding. Confirm/Deny stay as separate buttons; rows whose entity has no
detail route (or no entity) render as plain, non-clickable text. Adds the
approvals.openEntity tooltip string (en + de).
2026-06-27 01:03:31 +02:00
Luca 9414b42b7f feat(workflows): implement remaining stub actions (prepare_quote, prepare_gallery, reserve_date)
These were the last guard-stubbed actions — offered in the builder palette but
refused on enable. Now all three are real, backed by existing converters:

- prepare_gallery: alias of prepare_event (a gallery IS an event in picpeak).
- reserve_date: convertToEvent({ skipInvoices: true }) — a pure draft date hold
  with no money documents (new skipInvoices option on convertToEvent).
- prepare_quote: createQuote (customer entity) or duplicateQuote (quote entity),
  producing a status='draft' quote; idempotent via ctx.vars.preparedQuoteId.

With no stubs left, the enable-guard switches from a hardcoded DOCUMENT_ACTIONS
list to a registry lookup: an action node whose config.action has no registered
handler is unimplementable. This can't drift from what the engine can run and
also catches typo'd/future actions. (Fixes the enable-route node mapping to
carry node.type so the action-node filter matches.)

Extends the single-connection SQLite in-trx deadlock fixes to the quote-create
path (prepare_quote runs unattended): nextQuoteNumber reads getAppSetting
through trx, createQuote logs via trx and hoists its hasColumnCached schema-drift
checks before the transaction.

Adds tests for reserve_date (no invoices), prepare_quote (draft, no deadlock),
and registry coverage; retargets the enable-guard refusal test at a genuinely
unregistered action. Full backend suite: 985 passed, 1 skipped.
2026-06-27 00:49:35 +02:00
Luca 4faf5a344a feat(workflows): implement prepare_event so booking_full/booking_simple are enableable
The booking_full / booking_simple flows go prepare_event -> prepare_invoice,
but prepare_event was still a guard-stub, so enabling either flow returned
409 'uses actions that aren't implemented: prepare_event'.

prepare_event now calls convertToEvent({ hold: true }): convertToEvent already
creates the event as is_draft=true AND schedules its invoices, so this creates
those invoices on HOLD (scheduled_send_at NULL) and stashes their ids in
ctx.vars.preparedInvoiceIds. The downstream prepare_invoice already short-
circuits on a populated preparedInvoiceIds, so it ADOPTS the event's held
invoices instead of calling convertToInvoiceOnly again (which would both
double-create and throw ALREADY_CONVERTED_TO_EVENT). The review gate, the
wait-until-event-date, and send_document then issue those same invoices.
send_document(event)=publish is intentionally left a graceful skip — the
gallery is published manually after photos are uploaded, not auto-published
on an empty draft.

convertToEvent gains the same single-connection SQLite deadlock fixes as
convertToInvoiceOnly (getAppSetting reads through trx; logActivity moved after
commit) since prepare_event runs unattended, returns invoiceIds (incl. the
idempotent already-converted re-entry, which recovers them by event_id), and
removes prepare_event from the enable-guard list.

Adds a convertToEvent hold-mode test (draft event + held invoices + quote
linkage) and updates the enable-guard test to a still-stub action
(prepare_gallery). Full backend suite: 982 passed, 1 skipped.
2026-06-27 00:08:05 +02:00
Luca cf424efb4a feat(workflows): wire booking document actions (prepare_invoice/contract + send_document)
Implements the draft-seam booking cutover so the booking_invoice_only flow
becomes enableable. The booking flows trigger on quote.accepted, so the run
entity is the quote:

- prepare_invoice: convertToInvoiceOnly({draft:true}) creates the invoice(s)
  on HOLD (scheduled_send_at NULL, status stays 'scheduled') so the scheduler
  never auto-sends before the review gate; crash-recovery recovers drafts by
  the quote's deal_uuid. Stores ids in ctx.vars.preparedInvoiceIds.
- prepare_contract: createFromQuote (idempotent via converted_contract_id).
- send_document: dispatches the prepared draft (invoice -> sendInvoice each id,
  contract -> sendContract).
- resolveActor: quote creator -> workflow creator -> first admin.
- prepare_contract/prepare_invoice/send_document removed from the enable-guard
  list; prepare_event/prepare_quote/prepare_gallery/reserve_date still guarded,
  so booking_full/booking_simple stay blocked until the event-path increment.

Fixes a latent single-connection SQLite deadlock these unattended paths would
hit: getAppSetting/logActivity/adminActor read or write the global db, which
deadlocks when issued inside an open knex transaction. Thread the active trx
through getAppSetting, logActivity, nextInvoiceNumber, nextContractNumber, the
spawnInstallmentInvoices audit log, and hoist adminActor before createFromQuote's
transaction. convertToInvoiceOnly now logs after commit and returns invoiceIds.

Adds bookingCutover integration test (hold-mode null send-at, normal scheduled
contrast, contract path no-deadlock) and a route test that the now-implemented
booking invoice actions can be enabled.
2026-06-26 23:48:12 +02:00
Paul Nothaft 4212062c12 Merge pull request #673 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.71.1-beta.0
2026-06-26 17:03:18 +02:00
Luca f54ec66d05 Merge pull request #337 from Luca-Timo/feat/workflow-engine
Feat/workflow engine
2026-06-26 17:02:05 +02:00
Luca 3356b22688 Merge remote-tracking branch 'origin/beta' into feat/workflow-engine
# Conflicts:
#	frontend/src/components/admin/PublishGalleryDialog.tsx
2026-06-26 17:01:16 +02:00
Luca aa3471efe1 fix(gallery): publish dialog stuck for password-protected galleries with no inline email
handleSubmit required a password whenever requirePassword was true, but the
password field only renders on the inline-email path (requirePassword &&
customerEmail). For a password-protected gallery with no inline email the field
was hidden, so submit blocked on the missing password and the dialog never
closed. Gate password collection + validation on a single `needsPassword`
(requirePassword && customerEmail); the no-inline-email path publishes without
re-entering the password (existing hash kept, customer reaches it via portal).
2026-06-26 15:28:58 +02:00
Luca c657892bc8 feat(gallery): publish notifies assigned customer accounts via the account email
Publishing a gallery with no inline customer_email but assigned customer
account(s) previously sent nothing (the dialog said "no notification"). Now the
publish route falls back to the existing customer_gallery_assigned "your
galleries" email (sent per assigned active account in their preferred language)
so registered customers learn the gallery is available. Inline-email path
(gallery_created) is unchanged.

The publish dialog now reflects this: with an inline email it notifies that
address; with only assigned accounts it says the account(s) will be notified;
with neither, the button is just "Publish" (no false notify promise). Exports
notifyCustomerOfNewAssignments; EN/DE strings added.
2026-06-26 15:26:28 +02:00
github-actions[bot] 218b0dfd94 chore(beta): release 3.71.1-beta.0 2026-06-26 11:05:06 +00:00
Paul Nothaft 748af98f3d Merge pull request #671 from the-luap/fix/publish-dialog-cta-overflow-670
fix(admin): stack publish-gallery dialog CTAs so the German label fits (#670)
2026-06-26 13:04:45 +02:00
Luca 3ccaed06a2 feat(crm): pre-event reminder falls back to the assigned customer account
When an event has no inline customer_email/host_email but has customer
account(s) assigned (event_customer_assignments), the pre-event reminder now
sends to those registered customers instead of skipping with no_recipient.
Recipients sent to an assigned account are queued WITHOUT eventId so the
language resolver uses the customer's preferred_language (vs the event's
language for inline-email sends). Applies to both the flow path
(sendReminderForEvent) and the legacy pass. The gallery-ready mail deliberately
does NOT fall back to accounts — only the reminder does. Test covers the
no-inline-email + assigned-customer case.
2026-06-25 20:01:40 +02:00
Luca 10559fd68e fix(email): resolve recipient language from the queue row's event_id, not just email_data
Language priority is event.language → customer preferred_language → app default
→ … → en, but it was keyed on email_data.eventId, which only queueEmail injects.
Direct email_queue inserts (e.g. the gallery-publish "notify customer" path) set
the event_id COLUMN but not email_data.eventId, so those mails skipped
event.language and fell through to the default — e.g. a gallery-ready mail in EN
while the same event's pre-event reminder (sent via queueEmail) was DE.

The processor now backfills emailData.eventId from the authoritative event_id
column before rendering, so every send path resolves language from the event
consistently.
2026-06-25 19:50:08 +02:00
Paul Nothaft dddf932f13 Merge pull request #672 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.71.0-beta.0
2026-06-25 19:26:31 +02:00
Luca 250b240337 fix(crm): pre-event reminder passes raw event_date (fixes "Invalid Date" in the email)
composePayload pre-formatted event_date to DD.MM.YYYY, but emailProcessor runs
date variables through formatDate(value, language) — new Date("25.06.2026")
can't parse → the email rendered "Invalid Date". Pass the raw event_date and let
the processor localise it, matching the expiry mailer's contract. Pre-existing
in the migration-143 composePayload (dormant while the legacy pass was gated
off); surfaced once the pre_event_email flow ran.
2026-06-25 19:22:04 +02:00
github-actions[bot] ef5247d013 chore(beta): release 3.71.0-beta.0 2026-06-25 17:17:34 +00:00
Paul Nothaft 15be3b8d32 Merge pull request #667 from Luca-Timo/feat/workflow-engine
feat: admin-configurable workflow engine + dunning/Mahngebühr rework (RFC — feedback welcome)
2026-06-25 19:17:03 +02:00
Luca 675e41a2f7 feat(workflows): route webhook node through the delivery pipeline (full Option 1)
Replaces the one-shot guarded POST with the maintainer's intended end-state: the
webhook node now references a CONFIGURED webhook subscription (Settings →
Webhooks) and enqueues a real webhook_deliveries row via
webhookService.enqueueForWebhook. Delivery then rides the existing worker
pipeline, inheriting — not reimplementing — per-delivery SSRF re-validation
(validateExternalUrl / GHSA-wmjx-pc37-272r), HMAC signing with the
subscription's secret, retry/backoff, and the deliveries audit log.

- webhookService.enqueueForWebhook(webhookId, eventType, data): enqueue for one
  active subscription, bypassing fire()'s event-type matching. No schema change.
- webhook action: config.webhookId; unset/missing/inactive → observable skip;
  dry-run does not enqueue. event_type = workflow.<trigger>.
- Editor: webhook node config is now a subscription dropdown (was a raw URL),
  fed by the admin webhooks list, with a hint pointing to Settings → Webhooks.
- EN/DE strings; test asserts enqueue + dry-run no-op + inactive skip.
2026-06-25 18:44:27 +02:00
Paul Nothaft ea2852dcb0 fix(admin): stack publish-gallery dialog CTAs so the German label fits (#670)
Reporter @the-luap hit the German `Veröffentlichen & Kunden benachrichtigen`
button overflowing the modal footer in the publish dialog. Two failure modes
chained:

1. The footer was a `flex` row with two `flex-1` buttons inside a
   `max-w-md` (448 px) modal. Default `min-width: auto` on flex children
   meant the primary button kept its content width (~340 px including the
   paper-airplane icon + padding) and pushed the row past the modal frame.

2. Adding `min-w-0 whitespace-normal` doesn't help — the base `.btn` class
   has `@apply ... whitespace-nowrap` (`index.css:149`) which wins over a
   utility className via the Tailwind CSS cascade order. So the text won't
   wrap, the button silently extends past the modal frame, no overflow
   indicator. Verified with `getComputedStyle().whiteSpace = 'normal'` and
   the button still rendering as one ~340 px wide line at ~224 px allocated
   space.

Fix: stack both buttons vertically (`flex flex-col-reverse gap-3`). Primary
appears on top visually (col-reverse), cancel below — standard
confirmation-dialog pattern (Material, Headless UI, Radix all do this for
single-action dialogs). Works in every locale and viewport regardless of
label length. No side-by-side row to overflow.

Tried two prior shapes that didn't hold:
- `flex-col-reverse sm:flex-row` with `sm:flex-1 min-w-0 whitespace-normal`
  on the primary: still overflowed silently because of the
  whitespace-nowrap cascade above.
- `flex-col-reverse sm:flex-row sm:justify-end` with content-width buttons:
  `justify-end` doesn't constrain a row whose content sum exceeds the
  container; row just pushes left of the modal.

Bumping the modal to `max-w-lg` (or wider) was also considered and rejected:
matching modal width is asymmetric (every other admin dialog stays at
`max-w-md`), and any locale longer than German would re-hit the wall.
Stack-always is the only shape that handles every locale + every viewport
without per-language tuning.

Verified end-to-end against a dockerised dev backend:
- DE + EN × desktop (1280px) + mobile (375px) — all four show primary on
  top, cancel below, both inside the modal frame, no overflow.

Lint + tsc + full vitest suite (84/84) clean.

Closes #670.
2026-06-25 18:06:08 +02:00
Luca af7eea8b43 fix(workflows): wire a real, SSRF-guarded webhook action (was a silent no-op)
Second-review loose end: the `webhook` node type passed validation but had no
registered handler → engine dispatched to registry.getAction('webhook') →
undefined → every run silently skipped. An enabled webhook flow no-op'd.

Register a real `webhook` action (covers both the webhook node type and the
"Call a webhook" action). It POSTs the run context to config.url, guarded by
validateExternalUrl — the same NAT64/private-range SSRF protection the webhook
delivery worker uses (GHSA-wmjx-pc37-272r) — with no redirects and a timeout,
unless WEBHOOK_ALLOW_PRIVATE_URLS=true (local-dev opt-out). Missing URL /
rejected URL / network error record an observable skipped step, not a crash.
So the action is now implemented → it passes the enable guard legitimately.

Test covers dry-run, missing-url, and metadata-IP (169.254.169.254) rejection.
2026-06-25 18:00:12 +02:00
Luca 415c93a512 fix(event-types): renaming a type's slug cascades to events, quotes + reminder template
Renaming an event type's slug_prefix is editable in the UI but previously
orphaned everything keyed on the old slug: existing events/quotes (their
event_type) detached, and the authored per-type pre-event reminder template
(event_reminder_<slug>) was left behind → reminders fell back to default.

updateEventType now cascades atomically when the slug changes: re-points
events.event_type + quotes.event_type old→new and renames the
event_reminder_<old> template to <new> (guarded so it never clobbers an existing
target). So a photographer can rename a type to e.g. "concert" and the edited
subject/body follow. Column check resolved before the transaction (avoids the
SQLite global-read-in-trx deadlock). Tests cover the cascade + no-clobber.
2026-06-24 13:52:42 +02:00
Luca 10d091b55e feat(workflows): pre-event reminder picks the template GROUP on the block, type stays automatic
The reminder template family (prefix) is now chosen on the notify_pre_event
block via config.templateGroup (default 'event_reminder'); within that group the
exact template is still auto-resolved per event type:
  <group>_<eventType> if authored  →  else  <group>_default
So an admin can point a flow at a different reminder family, while wedding/
birthday/… routing and the catch-all fallback stay automatic. resolveTemplateKey
now takes (eventType, group) and tolerates a trailing "_" on the group.

Editor: notify_pre_event (+ the gallery notify actions) added to the action
dropdown, with a "Reminder template group" field and hint. Seed sets
templateGroup='event_reminder' on the built-in (v4). EN/DE strings. Tests cover
the per-type / group-default resolution.
2026-06-24 11:26:02 +02:00
Luca 5fbe514db6 fix(crm): pre-event reminder resolves recipient from the event row, not a non-existent column
The reminder query joined customer_accounts on events.customer_account_id — a
column the events table doesn't have (events store the recipient inline as
customer_email/host_email, like the gallery emails). So the query threw, the run
failed, and no pre-event email went out for an event that has an email but no
CRM customer account. Latent in the legacy pass (gated off by default); surfaced
the moment the pre_event_email flow ran notify_pre_event.

Both runEventReminderPass and sendReminderForEvent now read the recipient from
the event's own columns (customer_email || host_email, name from
customer_name || host_name) via SELECT events.* — no join, safe on installs
predating the customer_email column. Regression test covers an event with a
direct email and no customer account.
2026-06-24 01:30:35 +02:00
Luca dee8d40bb3 fix(workflows): matchFilter strict equality + accurate comment
Concern #2: switch eq/neq to ===/!== (drop the eslint-disable); a filter
{value:0} no longer matches false/''/null. Comment corrected — no implicit
type normalisation; filter authors match the payload type.
2026-06-23 23:36:56 +02:00
Luca 440b2b379f test(workflows): cover review fixes + update for disabled-by-default posture
- gate decision with no matching edge → run fails (not silent done)
- enabled-based mutex: legacy reminder pass stands down only when the flow is on
- built-ins now seeded disabled (v6/v2/v3); re-seed flips never-touched defaults
  but preserves an admin_toggled_at-owned flow
- route: rejects unknown node type; refuses enabling a flow with unimplemented actions
2026-06-23 23:36:17 +02:00
Luca c5f131cec3 feat(workflows): warn when disabling a built-in (reverts to legacy, not off)
Confirm dialog on the list page when toggling a built-in OFF, clarifying it
reverts to the previous built-in/legacy behaviour rather than turning the
automation off (review concern #4). The enable-refusal for unimplemented flows
surfaces via the existing toggle error toast (backend 409). EN + DE strings.
2026-06-23 23:36:17 +02:00
Luca d927464778 fix(workflows): harden graph validation + refuse enabling unimplemented flows
Review concerns #1/#2/#3/#5:
- validateGraph whitelists node types (rejects a typo'd 'actoin' that would
  no-op every cycle).
- Caps graph size: max 200 nodes / 500 edges / 16KB per-node config — a
  workflows.manage user can't DoS the DB with a giant graph.
- Refuses to enable (create/update/PATCH) a flow whose graph references
  unimplemented stub actions (the booking prepare_*/send_document), with a
  clear 409, so an admin can't enable a flow that silently drops the work.
- Stamps admin_toggled_at on admin enable/disable/edit (sentinel for the seeder).

matchFilter strict-equality fix lives in the engine commit.
2026-06-23 23:36:05 +02:00
Luca 5893ecb27a fix(workflows): ship built-ins disabled for first beta + enabled-based mutex + admin sentinel
Per review: the four cutover built-ins (dunning, gallery_expiring,
gallery_expired, pre_event_email) now ship enabled:false. The mutual-exclusion
guards revert to ENABLED-based (isBuiltinFlowActive, not existence) so the
legacy paths keep running until the admin enables a built-in — enabling cuts
over, disabling reverts to legacy (fixes concern #4's "disable = silent dark"
foot-gun; no automation goes dark on upgrade).

admin_toggled_at sentinel (migration 148) marks admin ownership; the boot
re-seeder applies a shipped default-flip (enabled→disabled) only to
never-touched built-ins and never overwrites an admin's enable/disable/edit
(nit #1). SEED_VERSIONs bumped so the disabled default propagates.

Nit: applyReminder unlinks the just-rendered Mahnung PDF if queueEmail throws
(no orphan file).
2026-06-23 23:35:56 +02:00
Luca 98ab717043 fix(workflows): close review blockers — prefetch-safe approvals + loud gate-edge failure
Blocker #1: GET /workflow-approvals/:token/:action no longer mutates. Email
clients + security scanners (Outlook Safe Links, Gmail, Proofpoint, AV
link-checkers) GET links before the human clicks, which previously advanced a
payment-confirm gate silently. GET now renders a confirm/deny interstitial via
a new read-only peekApproval(); only POST calls actByToken.

Blocker #2: a gate decision with no matching edge now failRun()s instead of
finishRun(). resumeRun matches the decision handle EXACTLY (no fall-back to
outEdge's sole-edge heuristic), so a 'deny' with only a 'confirm' edge fails
loudly in run history instead of taking the confirm path / a green 'done'.
2026-06-23 23:35:45 +02:00
Luca d14f1d850c feat(workflows): per-quote booking-workflow picker + quote→invoice (no gallery) built-in
A quote can now choose which flow runs on acceptance instead of every enabled
quote.accepted flow firing. Migration 147 adds quotes.booking_workflow_id; the
editor shows a "Booking workflow (on acceptance)" dropdown listing the
quote.accepted flows (workflow-engine flag only); emitQuoteEvent passes it as
the new emitWorkflowEvent targetWorkflowId so ONLY the picked flow runs (still
gated on enabled + trigger match → a disabled/None selection runs nothing).

Adds the booking_invoice_only built-in (quote.accepted → prepare invoice →
review gate → send; no event/gallery, no wait), the variant requested for
shoots billed without an online gallery. Disabled stub like the other booking
flows until the prepare_*/send_document cutover.

Tests: targetWorkflowId runs only the selected flow; invoice-only built-in has
no wait/prepare_event.
2026-06-23 23:15:31 +02:00
Paul Nothaft 13e948e2eb Merge pull request #666 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.70.0-beta.0
2026-06-23 18:46:23 +02:00
github-actions[bot] 488735d0cc chore(beta): release 3.70.0-beta.0 2026-06-23 16:42:29 +00:00
Paul Nothaft 83461fe5d4 Merge pull request #665 from the-luap/feat/pluggable-trackers-663
feat(analytics): pluggable trackers — Umami + Rybbit + Custom (#663 Phase 1)
2026-06-23 18:42:02 +02:00
Luca eec262b0a7 copy(crm): late-fee toggle reads "every reminder after the first"
The fee accumulates on every fee-bearing reminder (2nd onward), not just the
2nd — relabel the toggle (EN + DE + code fallback) to match the behaviour.
2026-06-23 18:22:18 +02:00
Paul Nothaft 162c2a98d8 Merge pull request #664 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.69.1-beta.0
2026-06-23 18:14:19 +02:00
Paul Nothaft ab501459a4 feat(analytics): pluggable trackers — Umami + Rybbit + Custom (#663 Phase 1)
Implements the hybrid scope agreed on in #663: two native adapters
(Umami + Rybbit) for trackers we'd keep maintained, plus a Custom
script-paste mode for everyone else (Plausible, Matomo, Pirsch, GA4,
GoatCounter, Fathom, Cloudflare Web Analytics). Phase 2 (Plausible
native, deeper metrics) explicitly deferred until someone asks.

## Architecture

**Backend `services/trackers/`**:
  - `TrackerAdapter` shape (single method): `fetchDeviceBreakdown` →
    `{ desktop, mobile, tablet } | null`. Null = route falls back to
    access_logs heuristic.
  - `umamiAdapter.js` — extracted from the `services/umamiClient.js`
    that landed in #662. Same 10 test contract preserved.
  - `rybbitAdapter.js` — new. Hits `/api/site/{id}/breakdown?dimension=
    device` with Bearer auth, accepts both bare-array and `{data:[...]}`
    envelope variants, tolerates `sessions`/`visitors`/`value`/`count`
    metric keys.
  - `customScriptSanitiser.js` — sanitize-html with a tracker-tight
    allowlist (`<script>` / `<noscript>` / `<link rel=preconnect|
    dns-prefetch>` / `<meta>`). Strips event-handler attributes,
    `javascript:` and `data:` URLs.
  - `index.js` factory: `resolveAdapter()` reads
    `analytics_tracker_provider` setting → dispatches. Back-compat:
    when provider is unset, infers `umami` from the legacy
    `analytics_umami_enabled` flag so #662 installs keep working
    without an admin touching settings.

**Backend routes**:
  - `adminDashboard.js /analytics`: now goes through `resolveAdapter()`.
    Old `fetchUmamiDeviceBreakdown` direct import removed; both `umamiClient.js`
    and its test file deleted (replaced by the adapter shape).
  - `adminSettings.js PUT /analytics`: validates the new
    `analytics_tracker_provider` enum, sanitises any incoming
    `analytics_custom_head_html` on save via the sanitiser. Masks
    the new `analytics_rybbit_api_key` on every GET — same pattern as
    Umami's API key and recaptcha secret.
  - `publicSettings.js`: emits `analytics_tracker_provider`,
    `rybbit_url`/`rybbit_website_id` (only when provider=rybbit), and
    the pre-sanitised `analytics_custom_head_html` (only when
    provider=custom). Legacy `umami_*` fields stay for back-compat.

**Frontend**:
  - `analytics.service.ts` reworked into a provider-aware shape.
    `initialize({provider, ...config})` dispatches to Umami /
    Rybbit / Custom / None. `track()` calls dispatch to
    `window.umami.track` / `window.rybbit.event` / no-op based on
    the loaded provider.
  - `App.tsx` `AnalyticsBootstrap` reads `analytics_tracker_provider`
    from public-settings and routes to the right `initialize` call.
    Legacy `umami_enabled`-based path preserved as fallback when the
    new field is missing.
  - `AnalyticsTab.tsx` (Settings → Analytics) reworked with a
    "Provider" dropdown switching between None / Umami / Rybbit /
    Custom panels. Each panel renders its own config fields; Custom
    panel surfaces an explicit CSP-reminder banner.
  - `useSettingsState.ts` shape extended with `tracker_provider`,
    `rybbit_url`/`rybbit_website_id`/`rybbit_api_key`,
    `custom_head_html`. Save mutation keeps `umami_enabled` in sync
    with `tracker_provider==='umami'` for back-compat with downstream
    consumers (publicSettings shape, embedded iframe).
  - `publicSettings.service.ts` type extended.

**i18n**: EN + DE for the provider heading + description + dropdown
options + Rybbit fields + Custom HTML field + CSP warning.

## Custom mode — script execution caveat

When the gallery `<head>` receives the custom HTML, simply assigning
innerHTML to a container element wouldn't execute the embedded
`<script>` tags (per the HTML spec, dynamically-inserted scripts via
innerHTML are non-running). `analytics.service.ts:120-130` re-creates
each `<script>` element manually so the browser actually evaluates
it. Non-script nodes (link, meta, noscript) move in directly.

## Tests

**Backend** (42 cases, all pass locally):
  - `umamiAdapter.test.js` (10) — pinned from the original
    `umamiClient.test.js`: missing-config / URL shape / encoding /
    payload normalisation / `laptop`→`desktop` / unknown buckets /
    empty / non-2xx / invalid JSON / network error.
  - `rybbitAdapter.test.js` (9) — same shape adapted for Rybbit:
    bare-array + envelope payload, `sessions`/`visitors`/`dimension`
    key tolerance, encoding, failure modes.
  - `trackerFactory.test.js` (6) — resolves null for `none`/`custom`,
    correct adapter for `umami`/`rybbit`, back-compat path via
    legacy `analytics_umami_enabled`, garbage-provider defensive null.
  - `customScriptSanitiser.test.js` (12) — Plausible-style passthrough,
    Umami-style passthrough, inline body passthrough, `<noscript>`
    allowed, `<link rel="preconnect|dns-prefetch">` allowed,
    `<link rel="stylesheet">` stripped, disallowed tags stripped,
    `javascript:`/`data:` URLs stripped, `on*` event handlers
    stripped, defensive on malformed input.
  - `analyticsDateMerge.test.js` (5) — preserved from #662.

**Frontend**: full 84-case vitest suite green; tsc + eslint clean
on changed files. Adapter changes are narrow refactors of code
covered by backend tests; no new analytics-page unit test added.

## End-to-end smoke (dockerised backend + my changes mounted)

```
test 1 (back-compat: no provider, umami_enabled=true)
  → factory returns umami adapter, /analytics returns
    devicesSource:access_logs (umami fetch to fake host fails
    gracefully). ✓

test 2 (invalid provider value)
  → 400 "analytics_tracker_provider must be one of: none, umami,
    rybbit, custom" ✓

test 3 (save custom HTML with XSS payload)
  → stored sanitised:
    `<script>alert(1)</script>evil<script async defer
     data-domain="x.com" src="https://plausible.io/js/script.js"></script>`
    (<div> stripped; script tags survive but CSP `script-src 'self'`
    still blocks inline + non-allowlisted external at runtime) ✓

test 4 (public-settings exposes the provider switch)
  → `analytics_tracker_provider: 'custom'`,
    `analytics_custom_head_html: '<sanitised>'` ✓
```

## Out of scope (next discussions)

- **Plausible native** — covered via Custom mode for now; native is
  Phase 2 if someone explicitly asks.
- **CSP "trusted domains" admin input** — Phase 1.5. For now operators
  add their tracker domain to nginx/proxy CSP manually; the new
  CSP-reminder banner in the Custom panel makes that clear.
- **Refactor `(window as any).umami.track(...)` direct calls** in
  PhotoLightbox/PhotoGrid to go through `analyticsService.track()`
  so events fire on the right tracker. Currently a no-op when Umami
  isn't loaded; functional but not optimal.

Closes #663 Phase 1.
2026-06-23 18:11:58 +02:00
Luca 23098127a8 fix(crm): quote→event fallback resolves an ACTIVE event type, never hardcoded 'wedding'
The last-resort fallback hardcoded 'wedding', which breaks when the admin has
disabled that type. resolveDefaultEventType now prefers the generic 'other'
catch-all when active, else the first active type by display order, and only
uses a literal as a final guard if the catalog is empty/unreadable. The chosen
quote type and the crm_default_event_type setting still take precedence.
2026-06-23 18:02:11 +02:00
Luca f78671fc6c feat(crm): event-type dropdown on quotes; quote→event uses it (no more hardcoded 'wedding')
Quotes now carry an event type (migration 146: quotes.event_type, the
event_types.slug_prefix), chosen from the active event-types catalog in the
quote editor's Event section. convertToEvent reads it instead of the
unconditional hardcoded 'wedding': quote.event_type → crm_default_event_type
setting → 'wedding' as last-resort seeded fallback. When the booking flow's
prepare_event is wired, it reads the same field.

Backend: createQuote/updateQuote persist event_type (hasColumn-guarded);
adminQuotes route accepts + returns eventType. Frontend: FormState + payload +
load + a catalog-sourced dropdown ("— Use default —"); EN/DE strings.
2026-06-23 17:52:50 +02:00
github-actions[bot] 9ae43fc991 chore(beta): release 3.69.1-beta.0 2026-06-23 15:44:38 +00:00
Paul Nothaft 349f566e87 Merge pull request #662 from the-luap/fix/analytics-dashboard-bugs-661
fix(analytics): admin dashboard reads correct fields + Umami device API (#661)
2026-06-23 17:44:10 +02:00
Paul Nothaft 7534447b6c fix(analytics): admin dashboard reads correct fields + Umami device API (#661)
Reporter @alexvaltchev hit three independent bugs on the Analytics
Dashboard. All three fixed in one PR; pluggable-tracker support
(Rybbit, Plausible, etc.) left for a separate discussion.

## Bug A — Summary cards showed 0

Two layers, both fixed.

**Frontend** (`AnalyticsPage.tsx:142-149`): the cards summed
`chartData[].views/uniqueVisitors/downloads`. The backend now (and
already) emits a dedicated `totals` object computed via separate
COUNT queries, which is what the cards should read. Postgres returns
counts as strings, so coerce via `Number()`.

**Backend** (`adminDashboard.js:268-282`): the chartData merge used
`dateObj.date === row.date`. On Postgres, pg's driver auto-converts
`DATE(timestamp)` to a JS Date object — the string-equality match
failed silently and `chartData` stayed all-zero on every Postgres
install with traffic. Added a `normaliseDateKey()` helper that
returns YYYY-MM-DD regardless of driver shape, plus `Number()`
coercion on the counts. SQLite path unchanged.

## Bug B — "Umami Not Configured" banner despite valid config

`AnalyticsPage.tsx:90` did `settings.reduce(...)` on the
`/admin/settings` response. That endpoint returns a
key/value **object** (verified at `adminSettings.js:108-149`), not
an array, so `.reduce` threw `data.reduce is not a function` and
the catch silently rendered the "Not Configured" banner even on
perfectly-configured installs. Read the umami keys directly off the
response object.

## Bug C — Device breakdown 0/0/0

Two-pronged fix.

**Primary path — Umami device API** (`services/umamiClient.js`,
wired into `adminDashboard.js`). When the admin provides an Umami
v2 API key (new setting `analytics_umami_api_key`), the backend
fetches the per-period device breakdown from Umami's
`/api/websites/:id/metrics?type=device` endpoint. Umami tracks
devices natively — far more accurate than our coarse user-agent
heuristic. The new `devicesSource` field in the response lets the
UI hint at where the numbers came from.

**Fallback hardening — local heuristic** (`adminDashboard.js:296-320`).
The existing access_logs `LIKE '%Mobile%' / '%Tablet%'` query stays
in place as a fallback for installs without Umami. Hardened with:
`whereNotNull('user_agent')` skips rows we never captured a UA on,
`Number()` coercion on COUNT results (pg returns strings), and a
guard against divide-by-zero when access_logs is empty.

## API key handling

Mirrors the existing recaptcha-secret pattern: stored plaintext in
`app_settings`, masked as `••••••••` on every GET via the existing
`adminSettings.js` GET handlers, and the frontend save mutation
silently drops the masked sentinel so re-saving without typing a
new key preserves the stored value.

## End-to-end smoke (dockerised backend with my fixes applied)

```
chartData total views: 27   ← previously 0 (date merge broken on PG)
totals: {'views': '27', 'downloads': '3', 'uniqueVisitors': '1'}
devices: {'desktop': 100, 'mobile': 0, 'tablet': 0}   ← was 0/0/0
devicesSource: access_logs   ← falls back correctly
analytics_umami_api_key (GET /settings/analytics): ••••••••
```

## Tests

**Backend** (15 new cases):
- `umamiClient.test.js` (10): missing-config → null, URL shape +
  `x-umami-api-key` header, websiteId URL-encoding, `{x,y}` →
  percentages, `laptop` → `desktop` mapping, unknown buckets
  dropped, empty payload → null, non-2xx → null, invalid JSON →
  null, network error → null.
- `analyticsDateMerge.test.js` (5): YYYY-MM-DD string pass-through,
  ISO timestamp slice, JS Date (pg shape) → YYYY-MM-DD, null/empty
  → null, coercion for unexpected types.

**Frontend**: full 84-case vitest suite still green (no analytics
unit tests existed before; not adding any here — the changes are
narrow and the unit-level confidence comes from the type system +
the backend smoke above).

Closes #661 (bugs A + B + C). Rybbit / pluggable tracker support is
the next conversation per the issue author's follow-up.
2026-06-23 17:37:41 +02:00
Luca 182e655fcf feat(workflows): invoice prepared+approved early, dispatch waits; daysBefore in editor; dashboard approvals
- Booking built-ins reordered: prepare the invoice EARLY (admin adjusts line
  items), admin approves at the review gate whenever, then the wait holds
  dispatch until the event date and it sends itself. prepInvoice → reviewInvoice
  → waitEvent → sendInvoice (both booking_full and booking_simple; v3).
- Flow editor now reads/edits/saves trigger_config; the pre-event "days before
  event" lead time is editable in the canvas toolbar (was only in settings,
  which the cutover removed — closing that gap).
- Dashboard: pending-approvals card under "Events Expiring Soon" (workflows flag
  + non-empty only), with inline Confirm/Deny.

Confirms the design: a gate's confirm edge can feed a wait, so an admin OK
before the event parks the run at the wait and the scheduler dispatches on the
date. New test covers confirm-early-then-wait-dispatches.
2026-06-23 16:11:18 +02:00
Luca 0b6c33e59a feat(workflows): hard cutover of gallery-expiry + dunning + pre-event to flows
Seed gallery_expiring / gallery_expired built-ins and make the live automations
flow-owned, with zero feature loss:

- New delegating actions (notify_gallery_expiring / notify_gallery_expired /
  notify_pre_event) call the EXISTING send functions, so the engine path is
  byte-identical to the legacy hourly checker/pass (same templates, recipients,
  variables, dedup, per-event overrides, sent_at idempotency).
- Cutover built-ins (invoice_dunning, gallery_expiring, gallery_expired,
  pre_event_email) now ship ENABLED; booking flows stay disabled (stubs).
- The legacy paths stand down via existence-based isBuiltinFlowPresent guards:
  once a built-in is seeded (flag on) the engine is the single switch — flow
  enabled = it sends, flow disabled = off — so no double-send and reminders/
  expiry emails can still be fully turned off.
- emitDueEventReminders now honours the per-event reminder controls
  (disabled / offset / sent_at) so pre-event timing is faithful; fixed a
  Number(null)===0 offset bug.

Settings UI cutover is gated on the `workflows` flag (default off): when the
engine is live, the dunning reminder schedule (CRM settings) and the pre-event
global toggle (Reminder emails) are replaced with a "now in Workflows" callout;
when it's off, the legacy controls stay so flag-off installs lose nothing. The
late-fee math and installment-trigger defaults stay (fee math / scheduler-owned).

Split/installment invoices intentionally remain scheduler-driven (no flow).
2026-06-23 15:58:19 +02:00
Luca fa7b1bae95 feat(workflows): admin review gates before sends + migrate lifecycle/time triggers
Booking built-ins now gate every outbound document on an explicit admin OK:
prepare_* drafts the doc, the admin adjusts line items/terms, confirms the
"Review … before sending" gate, and only then does send_document fire. Added to
booking_full (contract + invoice) and booking_simple (invoice); seed versions
bumped so the disabled built-ins self-heal.

Migrated the remaining time- and event-driven triggers into the engine, all
additive / best-effort / fail-closed (no behaviour change when the flag is off):
- gallery.published (event creation)
- gallery.expiring + gallery.expired (expiration checker, alongside the email)
- quote.sent (was queued but never emitted — gap closed)
- contract.sent + contract.signed (sent, fully-signed via counter-sign or wet upload)
- customer.created (direct add + invitation accept)
- invoice.overdue (status→overdue flip, deduped per invoice)

Editor trigger list extended to match. Tests assert the review gates wire
confirm→send on both booking flows.
2026-06-23 14:58:48 +02:00
Luca b38c216f22 test(workflows): raise beforeAll timeout for migration-heavy CRM suites
The workflow/dunning suites boot the full core-migration set in beforeAll via
bootCrmDb. In isolation that's ~1.3s, but under full-suite parallel load on a
small CI runner it can exceed Jest's 5s default, timing out beforeAll and
failing every test in the file (the CI flake). Match the existing pattern used
by the other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill)
and set jest.setTimeout(30000) on workflowEngine, workflowRoutes and
invoiceDunning.
2026-06-23 14:40:07 +02:00
Luca 62ba905464 feat(workflows): seed booking + pre-event built-ins, wire event.date_approaching
Three more editable built-in flows, seeded disabled like the dunning ladder:
- booking_full: quote.accepted → prepare/send contract → admin "signed?" gate
  → create event → wait to event date → prepare/send invoice
- booking_simple: the no-contract path (quote.accepted → event → invoice)
- pre_event_email: customer reminder + admin heads-up, fired daysBefore the
  event date

The booking document actions stay stubs (observable skipped steps) until the
booking cutover. pre_event_email uses the already-wired send_email action, so
it is functional once enabled — backed by a new scheduler emitter
(emitDueEventReminders) that fires event.date_approaching for events entering a
flow's lead window, deduped per event. Refactors the boot seeder to a built-in
registry so each flow self-heals on its own SEED_VERSION.
2026-06-23 14:11:11 +02:00
Luca e70ddd36b8 feat(workflows): test-fire — safe dry-run of any flow on demand
Engine testRun() walks the whole graph immediately: waits pass through,
gates auto-confirm, side-effecting actions short-circuit to {dryRun, would}
so no real emails go out. POST /admin/workflows/:id/test-run returns the
run status + per-node step log. Admin list gets a flask button that opens
a result modal with an optional entity id (e.g. invoice) for conditions.
2026-06-23 13:57:02 +02:00
Luca 192d2cbc06 feat(workflows): crash recovery — resume runs orphaned mid-flow
Closes the crash-safety gap: a run left in running/pending by a crash had
nothing to resume it (the scheduler only wakes 'waiting'). Adds a heartbeat
(workflow_runs.updated_at, stamped on every node advance + start/resume) and a
recoverStaleRuns() sweep that re-enters runs whose heartbeat has gone stale
(>10 min) from their persisted node. Runs on the scheduler tick AND the boot
tick, so a restart catches anything stranded during downtime.

Re-entry is at-least-once (the current node may re-execute) — loop counters +
the late-fee math are idempotent, so the only residual risk is a duplicate
reminder email. An attempts counter (migration 145, cap 5) marks a run failed
instead of recovering a node that reliably crashes the process (crash-loop
backstop). Flag-gated. Tests: orphan-resume + crash-loop cap.
2026-06-23 13:39:17 +02:00
Luca 83dc95a62b test(crm): dunning fee math, VAT-toggle gating + invoice immutability
Covers the tax-sensitive bits the dunning rework added (previously untested):
flat vs percent fee, the VAT toggle applying the org rate AND no-op'ing when
the org has no VAT rate, per-reminder accumulation (2nd=1x / 3rd=2x), the
invoice total staying immutable while the fee is tracked, and the 3-reminder
cap. Exports the fee resolvers + applyReminder for testing; PDF render stubbed
(flaky in CI, verified manually). 6/6 pass.
2026-06-23 13:30:48 +02:00
Luca 5ed2fec2fe feat(crm): Mahngebühr on a separate Mahnung document; invoice stays immutable
Corrected dunning model (Mara): a Mahnung is a reminder LETTER showing the new
total (original + Mahngebühr), NOT a separate invoice and NOT a mutation of the
issued invoice.

- The invoice PDF no longer shows the fee (buildInvoiceRenderContext reports
  lateFeeAmountMinor 0) and is NEVER re-rendered by a reminder — it stays
  immutable (§14/§11).
- applyReminder now: tracks the fee as dunning state on the row (gross
  late_fee_amount_minor + new late_fee_vat_minor for the VAT portion, migration
  144), renders a separate MAHNUNG PDF (pdfService 'mahnung' kind — reuses the
  invoice layout: same lines + Mahngebühr row + new total, 'Mahnung' title, no
  QR), stored under storage/business-docs/mahnung/, and attaches BOTH the
  unchanged original invoice + the Mahnung to the reminder email.
- Fee resolvers split into net + VAT-rate (toggle + org-rate gated); a gross
  wrapper feeds the payment-check preview. en + de PDF title.

Outstanding/collections still read late_fee_amount_minor (now dunning state).
P3 (tax-report/Banana booking of the Mahngebühr VAT) stays Treuhänder-gated.
Syntax + 17/17 workflow/invoice tests green.

NOTE: the Mahnung PDF render path isn't unit-tested (PDF rendering is flaky in
the test env) — eyeball on the dev box: fire a level-2 reminder, confirm the
Mahnung PDF shows the new total and the original invoice PDF is unchanged.
2026-06-23 13:08:19 +02:00
Luca eaceb7e71c feat(crm): toggle for VAT on late fees (jurisdiction-dependent)
Mahngebühr VAT differs by country (CH: liable; DE/AT: not), so it's now a
toggle (crm_invoices_late_fee_vat_enabled, seeded into migration 143 in place
since it isn't deployed yet — no compensation migration). When on, VAT is
added on top of the net fee at the org's default rate
(business_profile.vat_rate_default). Gated so it's a NO-OP when the org doesn't
charge VAT (default rate 0/unset) — i.e. enabling the toggle on a non-VAT org
adds nothing, as required. Settings UI: a self-documenting checkbox.

The fee is treated as net + VAT-on-top; the tax-report VAT breakdown for the
fee is part of the deferred dunning-document rework. tsc 0, build green,
9/9 workflow tests.
2026-06-23 12:41:44 +02:00
Luca b78bd979dc feat(workflows): collections-handoff block after dunning exhausts
New escalate_to_collections action: when the 3-reminder loop ends still
unpaid, consolidate ONE email to the admin — customer data, outstanding
(invoice + late fees − paid), and the invoice PDF attached — ready to forward
to an Inkasso agency / for Betreibung. Internal mail, sent immediately; does
NOT touch the invoice. New invoice_collections_handoff email template (en +
native de, seeded by the boot self-heal). Wired into the built-in dunning
flow: loop 'exit' → collections → end (seed v4, re-seeds the disabled
built-in). Selectable + labelled in the canvas editor. Tests 9/9, tsc 0,
build green.
2026-06-23 12:37:50 +02:00
Luca dcdbeb9cc5 feat(crm): 3-reminder dunning + flat/percent Mahngebühr on 2nd & 3rd + AGB notice
- Late fee can now be a FLAT amount OR a PERCENTAGE of the invoice gross
  (crm_invoices_late_fee_type/_percent, migration 143; defaults preserve the
  current flat behaviour).
- Fee is charged from the 2nd reminder onward and accumulates per fee-bearing
  reminder (2nd = 1×, 3rd = 2×), computed from the level so re-applying a level
  never stacks. New resolvePerReminderFeeMinor() shared by applyReminder + the
  payment-check fee preview.
- Reminder ladder extended to 3 levels (caps raised in sendReminder +
  recordPaymentCheckAction); the built-in dunning flow now loops 3× (seed v3,
  re-seeds the disabled built-in on boot).
- Settings UI: flat/percent toggle + percent field, and a prominent AGB
  callout — a late fee is only enforceable if the concrete amount is stated in
  the terms (Mara's wording), 'verify with your Treuhänder'. en + native de.

The fee math is examples-only / Treuhänder-verify; issued invoices stay
immutable (the fee is tracked in late_fee_amount_minor, not folded into the
original total). Tests 17/17, tsc 0, build green.
2026-06-23 12:26:32 +02:00
Luca 289568fd52 feat(workflows): advanced text mode — export/import the flow as JSON
A 'Text' toggle in the editor toolbar swaps the canvas for the whole flow as
pretty JSON ({name, trigger_type, enabled, nodes, edges}). Copy it to share or
hand to an LLM, or paste a flow and 'Load into editor' (validates parse + one
trigger; backend re-validates on Save). Imported nodes land at 0,0 — one
'Clean up layout' click arranges them. en + native de.
2026-06-23 12:09:18 +02:00
Luca 79607c597a feat(workflows): 'Clean up layout' auto-arrange button (dagre)
Adds a one-click tidy that re-lays the graph top-to-bottom with dagre
(@dagrejs/dagre) and fits the view — handles the loop-back cycle by breaking
it internally. en + de string.
2026-06-23 12:03:36 +02:00
Luca 1734aba39c fix(workflows): dark-mode canvas + readable nodes + structured config
Addresses editor UX feedback:
- Dark mode: pass React Flow's colorMode (admin isDark) so the zoom/lock
  controls, minimap and selection render dark instead of white-on-black.
- Readable nodes: show a human label derived from type+config (e.g. 'Invoice
  paid?', 'Send payment-check email', 'Repeat ≤ 2×', 'Wait until due date')
  instead of the raw node_key, and label each output handle on the node
  (yes/no, confirm/deny, loop/exit) so branching is self-explanatory.
- Structured config: replace the raw-JSON textarea with a per-node form
  (NodeConfigPanel) — dropdowns for action/condition/recipient/operator,
  typed wait/loop/gate fields, live-applied; an 'Advanced (JSON)' expander
  remains for anything the form doesn't cover.
- en + native de strings for all of it.

tsc 0 errors, build green.
2026-06-23 11:49:32 +02:00
Luca cede885b04 fix(workflows): Postgres-safe id capture on workflow inserts
On Postgres, knex .insert() without .returning() resolves to [], so ins[0]
was undefined → the child workflow_nodes inserts hit a NOT NULL violation and
the whole transaction rolled back. Result on PG: migration + tables present
but zero rows — the seeded dunning flow never persisted, and the 'New
workflow' button would 500. SQLite returns the row id, so the test harness
masked it.

Add .returning('id') and normalise the {id} (pg) vs bare-id (sqlite) shapes
(same pattern as the crmDb harness) in both the built-in seed and the admin
create route. Tests stay green on SQLite (17).
2026-06-23 11:33:14 +02:00
Luca 5259ee9705 feat(workflows): migrate the dunning ladder onto the engine (cutover)
Makes the built-in dunning flow a faithful replacement for the hardcoded
reminder ladder instead of a disabled representation:

- queue_payment_check action delegates to invoiceService.queuePaymentCheckEmail,
  so the proven confirm + reminder_level + Mahngebühr state machine
  (recordPaymentCheckAction) stays the single source of truth — the workflow
  only decides WHEN the payment-check email (the gate) fires.
- runScheduledTasks now SKIPS the hardcoded reminder batches when workflows is
  on AND the invoice_dunning built-in is enabled, so the two never double-send.
- The built-in graph is re-authored to the delegation model (wait→due, grace,
  loop: check-paid → payment-check → wait-gap), dropping the redundant gate +
  generic reminder emails. A SEED_VERSION re-seeds the disabled, never-activated
  built-in on boot but never touches an enabled/edited one.

Tests: delegation graph shape, re-seed-when-stale, enabled-protection (9 engine
+ 8 route = 17 passing).
2026-06-23 11:12:19 +02:00
Luca 88881d0428 i18n(workflows): native DE + EN strings for the workflow pages
Adds the workflows.* block (list, approvals inbox, canvas editor) to en.json
and de.json so the Workflows UI no longer renders English inline fallbacks
under a German UI. DE authored natively.
2026-06-23 11:12:19 +02:00
Luca 5c0396d0c1 feat(workflows): React Flow canvas editor + list + approvals UI
Adds the admin Workflows surface (top-level nav, gated by the workflows
flag + workflows.view): a list page (enable toggle, delete, new), a
pending-approvals inbox (confirm/deny), and a React Flow (@xyflow/react)
canvas editor — palette to add nodes, drag handle→handle to connect
(branch/gate/loop expose yes-no / confirm-deny / loop-exit handles), a
side-panel JSON config editor, and save (writes a new version). Routes +
sidebar entry + workflows.service. Build + tsc clean.

NOTE: the workflow page strings render via inline English fallbacks; DE
translations for the workflows.* block are still pending native review.
2026-06-23 02:51:40 +02:00
Luca 9b557efbf3 feat(workflows): seed invoice-dunning ladder as an editable built-in flow
Boot self-heal seeds the corrected gate-in-loop dunning graph (wait→due,
grace wait, invoice_paid check, confirm-no-payment gate, bounded reminder
loop with re-check, final notice) keyed on builtin_key='invoice_dunning',
sized from the reminder_first/second_days settings. Seeded DISABLED and
is_builtin: live reminder behaviour is UNCHANGED (the hardcoded scheduler
ladder still runs) — enabling it pre-cutover would double-send, so the
engine cutover is a deliberate follow-up. Idempotent (preserves admin edits).
Built-ins refuse delete (enforced in the CRUD route). Test covers seed shape
+ idempotency.
2026-06-23 02:44:05 +02:00
Luca 1a0d6de04d feat(workflows): admin CRUD + run-history + approvals-inbox API
GET/POST/PUT/PATCH/DELETE /api/admin/workflows with graph read/write (PUT
writes a fresh node/edge set under version+1 and bumps workflows.version so
in-flight runs keep their pinned version). Run-history (/:id/runs,
/runs/:runId/steps) and the pending-approval inbox (GET /approvals,
POST /approvals/:id/:action → actById) round it out. Gated by the workflows
flag + RBAC (view for reads, manage for writes); built-in flows refuse
delete; graph validated (exactly one trigger, unique keys, edges reference
known nodes). Route tests cover CRUD, validation, version bump, toggle,
inbox, and the 403 permission gate.
2026-06-23 02:40:29 +02:00
Luca b48d8c2eb8 feat(workflows): approval gates — email confirm/deny + token resume
gate_setup action creates a workflow_approvals row (single-use token stored
as SHA-256 hash) and emails the admin confirm/deny links immediately
(internal mail, no business-hours floor). actByToken / actById finalize the
approval and resume the run down the matching confirm/deny edge; both are
idempotent (a second click → 'already recorded') and respect expiry. Public
GET /api/public/workflow-approvals/:token/:action returns a small HTML
confirmation page (clickable from email, single-use so prefetch can't
double-act). listPending backs the webview inbox (wired in the CRUD phase).
Test covers gate→approval→email→token-confirm→resume + idempotency.
2026-06-23 02:37:20 +02:00
Luca cc0ba5347d feat(workflows): emit lifecycle events from invoice + quote services
Wires the workflow event bus into the hot paths, AFTER each commit:
- invoiceService.sendInvoice → invoice.sent (idempotent per invoice id, so
  overdue re-sends don't double-fire)
- invoiceService.markPaid → invoice.paid, only on the transition into paid
  (transaction result captured so the emit runs post-commit, never rolling
  back a recorded payment)
- quoteService.recordResponse / adminAcceptQuote / adminDeclineQuote →
  quote.accepted / quote.declined via a shared emitQuoteEvent helper that
  resolves the customer email for downstream send_email actions

All emits are best-effort and fail closed when the workflows flag is off.
Existing invoice/quote integration tests still green.
2026-06-23 02:33:14 +02:00
Luca 96fb44045e feat(workflows): data-touching action + condition handlers
Adds send_email (INTERNAL/admin = immediate, EXTERNAL/customer = business-
hours floor via queueEmail's respectBusinessHours) and the invoice_paid
condition (paid_at / status / cumulative paid_amount). Registers the
prepare_quote/contract/event/gallery/invoice + send_document + reserve_date
document actions as recognized-but-not-yet-wired (record an observable
skipped step rather than crashing a flow). index.js side-effect-imports the
handlers. Tests cover the customer-mail routing + the invoice_paid logic.
2026-06-23 02:28:02 +02:00
Luca 610a3dfd73 feat(workflows): scheduler resumes elapsed wait nodes
Adds engine.runDueWaits() — polls waiting runs whose wake_at has passed and
resumes the ones parked on a wait node (gate timeouts handled later by the
approvals layer). Flag-gated (fails closed when workflows is off). Wired into
the existing hourly invoiceScheduler tick in its own try/catch so a workflow
failure never suppresses the invoice/reminder jobs. Test covers not-due vs
elapsed resume.
2026-06-23 02:09:57 +02:00
Luca 1eaef67c36 feat(workflows): execution engine core + registry + tests
Graph executor that walks nodes/edges per run: trigger, condition/branch
(registered conditions → yes/no edge), bounded loop (counter in context +
maxIterations cap), wait (status=waiting + wake_at for the scheduler), gate
(status=waiting; resumed via confirm/deny edge), action/webhook (registered
handlers). emitWorkflowEvent creates one idempotent run per matching enabled
workflow (unique dedup_key) and fails CLOSED if the flag system is
unavailable; never throws into callers (safe to call after commit). Every
node records a workflow_run_steps row. Registry seeds primitive
conditions (always/never/expr) + actions (noop/log/set_context). Integration
test covers loop+wait resume, gate confirm, and dedup.
2026-06-23 02:05:34 +02:00
Luca c818c25cf2 feat(workflows): schema + permissions (migration 142)
Adds the workflow engine's graph data model — workflows, workflow_nodes,
workflow_edges (versioned so in-flight runs keep their version),
workflow_runs (status/current_node/context, wake_at for the scheduler,
unique dedup_key for idempotency), workflow_run_steps (per-node audit),
and workflow_approvals (hashed email confirm/deny token + webview inbox).
Seeds workflows.view / workflows.manage and grants them to super_admin +
admin. Loose-FK integers per the whatsapp_queue/expenses convention;
idempotent hasTable guards + reversible down().
2026-06-23 01:57:06 +02:00
Luca ff478619b5 feat(workflows): add workflows feature flag + Features-tab toggle
New opt-in 'workflows' master flag (default off) across the backend
KNOWN_FLAGS/DEFAULT_FLAGS and the frontend FeatureKey union, context
defaults, and a new Automation section card in the Features tab. Gates
the upcoming Workflows admin area and the engine runtime. en/de i18n
added (DE native).
2026-06-23 01:53:03 +02:00
Paul Nothaft a1f68bc081 Merge pull request #659 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.69.0-beta.0
2026-06-22 22:55:35 +02:00
github-actions[bot] f9690cb568 chore(beta): release 3.69.0-beta.0 2026-06-22 20:51:07 +00:00
Paul Nothaft 3ac70177ef Merge pull request #658 from the-luap/feat/feedback-per-guest-limits-655
feat(feedback): per-guest favorite + like caps with mobile-friendly limit modal (#655)
2026-06-22 22:50:45 +02:00
Paul Nothaft 98e97e3cf2 fix(i18n): replace ASCII quote with U+201D in DE perGuestLimitsDesc
CI's frontend test job failed with "Failed to parse JSON file, invalid
JSON syntax found at position 163854" on de.json:3041. The German
description used „…" — the opening „ (U+201E) was correct, but the
closing was an ASCII " (U+0022) which the JSON parser treated as the
string terminator, leaving "-Abläufe..." as garbage outside the string.

Replace with the proper German closing quote " (U+201D). 84/84 vitest
suite now passes locally. End-to-end smoke against a dev backend with
migration 141 applied confirms the modal renders correctly on desktop
(centered card) + mobile (bottom slide-up) and the backend returns the
structured 403 on the 11th-click cap hit.

Also flagging adjacent: origin/beta has a pre-existing duplicate `Mail`
import in frontend/src/pages/admin/SettingsPage.tsx (lines 20 + 58 from
commit 69367b45) that breaks Vite dev's Babel parser but passes prod
esbuild — out of scope for this PR, separate fix needed.
2026-06-22 22:40:13 +02:00
Paul Nothaft f2814e4a4c feat(feedback): per-guest favorite + like caps with mobile-friendly limit modal (#655)
Reporter @Duecki1 wants to stop telling guests "pick only 5 photos" by
hand. Per-event cap, enforced server-side, with a clear popup when the
11th click would exceed the limit. Per-guest scope matches the "every
couple picks their top 10" mental model; per-gallery aggregate is
explicitly NOT in scope (creates weird "first 10 visitors use up all
slots" race conditions).

## Schema (migration 141)

Two nullable columns on `event_feedback_settings`:
  - `max_favorites_per_guest`
  - `max_likes_per_guest`

null / 0 = unlimited (preserves current behaviour for every existing
install — operator must opt in). Both shipped together because the
code path is identical; photographers can cap either, both, or neither.

## Backend

- `feedbackService.submitFeedback` cap check on the INSERT branch only.
  Toggle-off (un-favoriting) is always allowed, so a guest at 10/10
  can free a slot by un-clicking an existing favorite.
- New `countGuestFeedback(eventId, type, guestId, guestIdentifier)` —
  matches the exact same guest-key shape the existing duplicate-check
  uses (guest_id when present, fallback to guest_identifier in simple
  identity mode).
- Limit reduction grandfathers: admin lowering 20→10 keeps existing
  rows in place; new adds blocked until the guest removes some.
- Route layer (`galleryFeedback.js` POST) translates a `limit_reached`
  service-return into a structured 403 with `code:
  'FAVORITE_LIMIT_REACHED'` / `'LIKE_LIMIT_REACHED'`, `limit`, and
  `current_count`. Stable UI contract.
- `feedback-settings` GET exposes the caps so the gallery UI can
  optionally render a counter near the heart icon (UI extension TBD;
  the modal alone is the contract this PR commits to).
- `feedbackValidation`: range guard `0..10000`, null allowed,
  per-field error messages.

## Frontend — the popup

New `FeedbackLimitReachedModal` component renders via a `createPortal`
to `document.body` so it escapes any lightbox / sticky parent stacking
context and reliably sits above everything else.

Mobile-first responsive:
  - `items-end sm:items-center` — slides up from the bottom on phones
    (native action-sheet feel), centers on desktop (familiar modal).
  - `w-full sm:max-w-md` — full-width on phones, clamps to 420px on
    desktop.
  - `rounded-2xl sm:rounded-xl` — more rounded on phones for the
    sheet feel.
  - `pb-[env(safe-area-inset-bottom)]` — respects the iOS home indicator
    and Android gesture bar.
  - `z-[60]` — above the lightbox's z-50.

Title + body + "8 of 10 used" pill + "Got it" button. Backdrop click +
Escape both dismiss. Focus management lands on the OK button so
keyboard / screen-reader users can dismiss immediately.

New `useFeedbackLimitModal()` hook is the shared API: components
on every submit-feedback site wire `onError: (err) => handleError(err)`
and render `{limitModal}` in their JSX. Returns `true` from
`handleError` when the error is a structured cap-reached 403 (so the
caller can skip its generic error toast). PhotoFavorites + PhotoLikes
+ PhotoLightbox all wire through the hook — every favorite/like submit
path is covered, including the lightbox's three different submit
sites (guest mode, simple mode, post-identity-modal-confirm).

## Admin UI

`FeedbackSettings` card gets a new "Per-guest limits" section that
only renders when at least one of `allow_favorites` / `allow_likes` is
on. Two numeric inputs (0 / empty = unlimited) side-by-side on
desktop, stacked on mobile. Hint text covers the limit-reduction
grandfathering semantics so admins aren't surprised.

## i18n

EN + DE for:
  - Modal title + body (parameterized with `{{limit}}`)
  - Counter pill (parameterized with `{{current}}` / `{{limit}}`)
  - OK button label
  - Admin field labels + hints + section header + grandfathering note

## Tests

**Backend** (`__tests__/utils/feedbackPerGuestLimit.test.js`, 8 cases):
  - null cap → unlimited (back-compat)
  - 0 cap → unlimited (UI convenience)
  - cap=10: rows 1-10 succeed, 11 returns limit_reached
  - toggle-off frees a slot at the cap
  - limit reduction grandfathers existing rows
  - per-guest scope: guest A's cap doesn't affect guest B
  - favorite cap doesn't block likes (per-type)
  - like cap returns LIKE_LIMIT_REACHED-shaped payload

**Frontend** (`__tests__/useFeedbackLimitModal.test.ts`, 7 cases):
  - Non-axios errors → null
  - Non-403 axios errors → null
  - 403 with wrong code → null
  - FAVORITE_LIMIT_REACHED parsed
  - LIKE_LIMIT_REACHED parsed
  - Falls back to code-implied type when feedback_type missing
  - Missing numeric fields → 0 (not NaN)

All 15 pass. tsc --noEmit clean. eslint clean on changed files.

Closes #655.
2026-06-22 22:02:13 +02:00
Paul Nothaft e1111b3848 Merge pull request #657 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.68.1-beta.0
2026-06-22 21:42:27 +02:00
github-actions[bot] 0b72751808 chore(beta): release 3.68.1-beta.0 2026-06-22 19:38:43 +00:00
Paul Nothaft 6193ab7f6a Merge pull request #656 from the-luap/fix/gallery-password-instagram-iab-654
fix(gallery): unbreak password entry in Instagram in-app browser (#654)
2026-06-22 21:38:20 +02:00
Paul Nothaft f4b6b8941a fix(test): raise bootCrmDb beforeAll timeout on slideshow suites
CI runners hit Jest's default 5s `beforeAll` timeout on
slideshowPublic.test.js's bootCrmDb call (~5.4s observed vs ~2s local —
runner-to-runner I/O variance, not a regression). Same hook shape on
slideshowAdmin.test.js is one slow runner away from the same failure.
Raise both to 30s so this stops blocking unrelated PRs branched off beta.

Adjacent to #654 — not strictly part of that fix but the only blocker
between #656 and a green CI right now.
2026-06-22 21:32:12 +02:00
Paul Nothaft b1bfd4838e fix(gallery): unbreak password entry in Instagram in-app browser (#654)
Reporter @Duecki1 hit "Incorrect Password" on byte-correct input from
Instagram's iOS/Android IAB. Backend bcrypt compare is fine — the
frontend was handing it a mangled byte sequence because the password
Input lacked the autocaps/autocorrect/spellcheck/autocomplete defenses
Instagram's WKWebView keyboard bridge needs (the standard `type="password"`
WebKit defaults that suppress autocaps get overridden inside the IAB).

Three layers of defense:

1. **Explicit input attributes** on the gallery password field —
   `autoCapitalize="none"`, `autoCorrect="off"`, `spellCheck={false}`,
   `autoComplete="current-password"`. Stops iOS autocaps turning
   `wedding2026` into `Wedding2026`, stops predictive-text rewrites,
   nudges password managers to autofill the right credential rather
   than the IAB's stale saved-password store.

2. **Silent `.trim()` on submit** — Android Instagram IAB's predictive
   keyboard often appends a trailing space when the user taps the
   submit button. Event-gallery passwords don't legitimately carry
   leading/trailing whitespace (they're set by photographers, usually
   generated short strings), so trimming here is safe.

3. **Instagram IAB detection banner** — `frontend/src/utils/inAppBrowser.ts`
   detects the `Instagram` UA tag and surfaces a one-time advisory at
   the top of the password card with the right platform-specific
   "Open in external browser" instructions (⋯ menu copy for iOS,
   ⋮ for Android). Self-rescue path for users who hit it before we
   can close every keyboard mangling vector.

Scope is strictly Instagram per #654. Facebook IAB (`FBAV`/`FBAN`)
behaves identically and would benefit, but expanding the matcher is
a separate scope decision — the detector + i18n shape leaves room for
it without further refactor.

EN + DE i18n for the banner; 8 vitest cases on `detectInAppBrowser`
(iOS / Android Instagram UAs, plain Safari / Chrome / desktop UAs,
case-insensitive match, word-boundary defense against substring
collisions, SSR-safety when `navigator` is undefined). Lint + tsc
clean; pre-push Playwright smoke still expected green.

Closes #654.
2026-06-22 21:09:17 +02:00
Paul Nothaft df41d149e5 Merge pull request #653 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.68.0-beta.0
2026-06-21 21:40:39 +02:00
github-actions[bot] b86d171a2a chore(beta): release 3.68.0-beta.0 2026-06-21 19:35:28 +00:00
Paul Nothaft 80e8ec5bc7 Merge pull request #650 from the-luap/fix/whatsapp-template-params-647-followup
feat(whatsapp): admin-selectable template parameters + reorder (#647 follow-up)
2026-06-21 21:35:09 +02:00
Paul Nothaft 2887db48d1 Merge pull request #652 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.67.1-beta.0
2026-06-21 21:34:05 +02:00
github-actions[bot] c978d4d0fd chore(beta): release 3.67.1-beta.0 2026-06-21 19:33:49 +00:00
Paul Nothaft cde028e919 Merge pull request #649 from the-luap/fix/branding-customcss-preset-drop-645
fix(branding+whatsapp): preserve customCss through preset switches (#645) + admin-pinned WhatsApp template language (#647)
2026-06-21 21:33:29 +02:00
Paul Nothaft 4f77c0cb5a Merge pull request #651 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.67.0-beta.0
2026-06-21 21:23:07 +02:00
github-actions[bot] 9fb6d5dbf9 chore(beta): release 3.67.0-beta.0 2026-06-21 19:20:01 +00:00
Paul Nothaft 4356393b44 Merge pull request #646 from Luca-Timo/feat/live-slideshow
feat: Live Slideshow ("Diashow") — fullscreen, auto-updating projector view for live events
2026-06-21 21:19:32 +02:00
Paul Nothaft 1f46a241d2 chore(whatsapp): renumber migration 138 → 140 (after PR #646's 138+139)
PR #646's review-round renumbered its slideshow migrations to 138 + 139
to slot in after PR #649's 137 (whatsapp_template_language). That now
collides with this PR's 138. Slide ours to 140 so all three land in
strict order: #649 (137) → #646 (138, 139) → this PR (140). Content
unchanged; pure rename + a one-line docstring tweak noting the slot.
2026-06-21 21:12:22 +02:00
Paul Nothaft 16055cdc41 feat(whatsapp): admin-selectable template parameters + reorder (#647 follow-up)
Reporter @Rekoo-PS confirmed the language fix unblocked sending, then
hit a second gap: their template uses only `{{1}} = event_name` +
`{{2}} = gallery_link`, but the legacy `buildComponents` hardcoded all
5 positional values from the `gallery_ready` shape (customer_name,
event_name, gallery_link, password_line, expiry_date). Meta rejected
with a parameter-count mismatch even after the language matched.

This adds a per-config slot list — which built-in values to send, and
in what positional order — so admins can match templates of any shape
without code changes.

## Schema (migration 138)

Additive `template_params` TEXT column on `whatsapp_configs` (default
empty string = legacy 5-slot behaviour for existing installs). Stored
as a JSON-serialized array of slot keys: `customer_name`, `event_name`,
`gallery_link`, `password_line`, `expiry_date`. Unknown / duplicate /
non-string entries are sanitized out at read time.

## Processor

- `parseTemplateParams(raw)` — defensive parser; falls back to the
  5-slot default on empty / malformed / all-invalid input.
- `buildComponents(data, metaLang, params)` — emits ONLY the listed
  slots in the listed order, computed via a small switch on slot key.
  The password line still receives the locale-specific 🔒 label and
  the empty-when-no-real-password sentinel handling.
- Processor reads `config.template_params` once per cycle and passes
  the parsed array to `buildComponents` per message.

## Admin route

- GET surfaces `template_params` as the parsed array (default 5-slot
  when null/empty).
- PUT round-trips the incoming array through `parseTemplateParams`
  before persisting, so the stored value is always the canonical
  sanitized JSON.
- Test send rebuilt to use the same `buildComponents` path so the
  admin's test message matches their configured slot shape — a
  reporter who configures 2 slots gets a 2-parameter test send, not
  the legacy 5-parameter payload.

## UI

- `WhatsAppTab` gets a checkbox + up/down list under the Template
  language field. Each slot shows its current `{{N}}` position when
  checked, an em-dash when unchecked. Live preview below the list:
  "Your template will receive: {{1}} = event_name, {{2}} = gallery_link".
- EN + DE i18n for the field labels, hint, preview, and per-slot
  human-readable names.

## Tests

- 17 unit tests in `__tests__/utils/whatsappBuildComponents.test.js`
  covering: parseTemplateParams sanitization (unknown keys, duplicates,
  non-strings, malformed JSON, all-invalid fallback, pre-parsed array
  acceptance) and buildComponents shape (reporter's 2-slot case,
  reorder, empty list, locale-specific password label, password
  sentinel handling, expiry omission).
- All 17 + the 34 existing networkValidation tests pass.

## Migration numbering

Sits at 138 on top of PR #649's migration 137. If #646 (Live Slideshow)
merges before this, #646's own 137 + 138 take precedence and this
needs renumbering to 139. Coordinated via PR #646's review thread.

## Honest caveat

Still no Meta Business API account on my side. Spec-built, sanitizer +
shape unit-tested, lint + tsc clean. End-to-end against Meta needs the
reporter (or a maintainer with an account) to verify. If a real
round-trip surfaces a mismatch, drop it in #647 and I'll iterate.
2026-06-21 20:51:20 +02:00
Luca e6655f613b chore(slideshow): renumber migrations to 138/139 (after whatsapp #649's 137)
PR #649 takes migration 137 (add_whatsapp_template_language). Renumber the
slideshow migrations to slot in after it:
- 137_add_slideshow_share.js  -> 138_add_slideshow_share.js
- 138_add_slideshow_styling.js -> 139_add_slideshow_styling.js
and update the slideshow migration-number references in comments/types. No
content change — both are additive + addColumnIfNotExists-guarded, so re-running
under the new filename on an already-migrated DB is a safe no-op.
2026-06-21 02:59:19 +02:00
Luca a995131f42 perf(slideshow): cache global settings to cut /state DB reads (PR #646 review)
Each /state poll fired ~10 getAppSetting reads to resolve the watermark/fit;
a leaked link x N tabs amplified that linearly (review concern 2). Add a
5s-TTL cached bundle (utils/slideshowGlobals) for the global slideshow_* +
branding-logo settings, invalidated on PUT /admin/settings/slideshow so admin
live-edit stays instant. slideshowSettings now does ~2 reads per poll (event
row + photo count) on a cache hit. Also documents the frontend
optimistic-default nit.
2026-06-21 02:53:34 +02:00
Luca e36b3309ca fix(slideshow): deny display-only token on download/upload/feedback (PR #646 review)
The slideshow JWT reuses type:'gallery', so verifyGalleryAccess accepts it on
every gallery route — a leaked projector link could download (single/all/
selected), upload (when allow_user_uploads), or post feedback for up to ~12h,
beyond its display-only contract. Add a `denySlideshowToken` middleware (403
when req.accessLevel==='slideshow') after verifyGalleryAccess on those 5 routes.
The photo-display routes (/photos, photo/thumbnail/preview/hero) stay open — the
kiosk needs them. +4 tests mint a real slideshow JWT and assert 403. Docs note
that Regenerate/Disable isn't instant revocation (~12h) and the feature flag is
the hard cut-off.
2026-06-21 02:46:29 +02:00
Paul Nothaft 4fd7709596 fix(whatsapp): admin-pinned template language + Arabic locale support (#647)
Reporter @Rekoo-PS hit three independent gaps trying to deliver an
Arabic Meta template. Bundled here because they fan out from the same
root cause (no first-class language config on the WhatsApp tab) and the
review surfaces are tightly coupled.

**1. Test send hardcoded `en_US` (`adminWhatsapp.js:141`).** Smoking gun
for "I can't make it work" — Meta returned template_not_found_in_language
(132001) on every test send for non-English templates, no matter what
else the admin configured. Replaced with `config.template_language ||
'en_US'`.

**2. No `template_language` field on `whatsapp_configs`.** The only
priors were per-message `data.language` (always null from our callers in
`adminEvents.js:854,1188`) and `app_settings.general_default_language`
(the *system UI* language, not the *template's* language registered with
Meta). Migration 137 adds the column; GET + PUT surface it; the
processor uses it as the highest-priority default when message_data
doesn't override.

Resolution order in `whatsappProcessor.processWhatsAppQueue` is now:
  1. message_data.language (per-event override — caller path TBD)
  2. config.template_language (admin-pinned template language)
  3. app_settings.general_default_language (system fallback)
  4. en_US (hardcoded last resort)

**3. `LANGUAGE_MAP` + `PASSWORD_LABELS` didn't cover Arabic.** Added
`ar` (Meta's single-code form per RFC; no region variant). For any
language we don't enumerate (e.g. Turkish `tr_TR`, Chinese `zh_CN`,
Hebrew `he_IL`), `resolveLanguageCode` now pass-throughs valid-shape
codes (lowercase-language + optional underscore + uppercase-region) and
forwards them to Meta as-is. If they don't match a registered template
Meta returns 132001, which the test route already surfaces back to the
admin via `error.message` — fail-loud, no silent fallback.

Validation:
- Unit smoke on `resolveLanguageCode` across 18 representative inputs
  (in-map, pass-through, canonicalization, rejection) — all behaviours
  correct.
- Lint clean on all 7 changed files.
- Frontend `tsc --noEmit` clean.
- Migration `node -c` syntax-checked; additive + `hasColumn`-guarded so
  re-running is safe.

Frontend: free-text input on the WhatsApp tab with EN + DE i18n.
Pointing at Meta's supported-languages docs via the hint text — Meta's
list grows; a hardcoded dropdown would rot.

Closes #647.
2026-06-20 23:29:08 +02:00
Paul Nothaft 7cf26795ec fix(branding): preserve customCss through preset switches + theme changes (#645)
Reporter @aemisrogers nailed the root cause: same #317 class of bug as
logoUrl. None of `GALLERY_THEME_PRESETS` (`theme.types.ts:125`) include
`customCss` in their `config` object, so any path that REPLACES
`currentTheme` with `preset.config` (or with a sparse `newTheme` that
came from `preset.config` upstream) silently dropped `customCss` from
React state. The persisted value in `theme_config` stayed correct (the
public gallery still rendered it), but the admin textarea showed
empty on reload — admin-UI display drift, not data loss.

Three surgical fixes, mirroring the #317 logoUrl pattern:

1. `BrandingPage.tsx` `handleThemeChange` — `customCss: newTheme.customCss
   ?? currentTheme.customCss` alongside the existing `logoUrl` fallback.
   Closes the propagation hole where the customizer's `handlePresetSelect`
   fires `onChange(preset.config)` (no customCss) and the parent wipes
   it from currentTheme.

2. `BrandingPage.tsx` `handlePresetChange` — preserve `customCss` from
   prev/currentTheme on preset switch, same shape as the existing
   `logoUrl: prev.logoUrl` preservation. Touches both the `setCurrentTheme`
   and the preview-mode `setTheme` paths.

3. `ThemeCustomizerEnhanced.tsx` `handlePresetSelect` — remove the
   `setCustomCss('')` that wiped the local textarea state on preset
   pick. The previous comment ("Clear custom CSS when selecting a preset")
   described the original intent but produced data drift across the
   preset round-trip. The sibling `ThemeCustomizer.tsx` already never
   cleared it; this aligns the two.

Verified against `v3.44.0` and `origin/beta`: identical code on both
branches, so the bug exists on stable + beta. Lint + tsc clean on the
two changed files.

Closes #645.
2026-06-20 23:17:34 +02:00
Luca 9dd353744e Update live-slideshow.md by removing metadata
Removed metadata section from live-slideshow documentation.
2026-06-20 13:55:39 +02:00
Luca 16013d1cf9 docs(slideshow): add Live Slideshow guide + README entries
- docs/live-slideshow.md: full feature guide (enable, generate link, run on a
  projector, global Settings -> Slideshow defaults, per-event overrides, how
  live updates work, security notes).
- README: Live Slideshow bullet under Key Features, a Live Events use case, and
  a Documentation quick link.
2026-06-20 13:30:44 +02:00
Luca 9fe9bd77fa test(slideshow): backend route tests for public + admin endpoints
25 tests over two files, using the integration test-DB helper (real sqlite,
all migrations):

- slideshowPublic: resolveSlideshow guards (feature-flag kill-switch -> 404,
  unknown/null token, expired/draft/archived), the watermark cascade (global
  look + per-event on/off + source->URL resolution + "null when no logo"),
  image fit, and /session minting (token + cookie). Regression-guards the
  app_settings reads (vs the nonexistent `settings` table bug).
- slideshowAdmin: generate/disable/regenerate, PATCH display + watermark mode,
  feature-flag 403, no-token 401, and PUT /admin/settings/slideshow validation
  + clamping. Both generate and PATCH assert success despite events having no
  `updated_at` column (the original 500).
2026-06-20 12:36:26 +02:00
Luca 5f1f4c2b3d refactor(slideshow): replace per-event-type preset with a picpeak-wide one
The slideshow display preset (transition / interval / speed / color filter) was
set PER EVENT TYPE in the Edit Event Type dialog. Replace it with a single
picpeak-wide default in Settings -> Slideshow ("Default style for new
slideshows"). New events seed their show_* columns from this global preset
(was: from the event type's slideshow_preset); the per-event override is
unchanged.

- Removed event_types.slideshow_preset usage everywhere (EventTypeModal section,
  eventTypes.service types, eventTypeService whitelist, adminEventTypes
  validators/POST). The DB column from migration 138 is left inert.
- Global preset stored in app_settings (slideshow_interval_ms/transition/
  transition_ms/colorfilter), saved via PUT /admin/settings/slideshow.
- adminEvents create-seeding now reads the global preset (getAppSetting) instead
  of the event type.
- en/de: presetTitle + presetHint.
2026-06-20 12:20:48 +02:00
Luca b5c73e05bd feat(slideshow): add image fit setting (fill vs black bars)
object-fit was hardcoded to 'cover', which crops portrait photos heavily. Add a
global `slideshow_fit` setting (Settings -> Slideshow): 'cover' fills + crops,
'contain' shows the whole image with black bars (no crop). Default 'cover'
(unchanged). Stored in app_settings (no migration), resolved server-side into
the slideshow settings + /state poll so a running projector picks it up live.
2026-06-20 12:06:34 +02:00
Luca 759784a4d1 fix(slideshow): feature flag is a master kill-switch, not just admin UI
Disabling the `slideshow` feature previously only hid the admin UI — the public
/show/:token route ignored the flag, so already-minted links kept working. Gate
resolveSlideshow on isFeatureEnabled('slideshow') so every /session and /state
404s when the feature is off: clicking Start shows "link not active" and a
running projector stops within one /state poll. Belt-and-braces: also gate the
admin generate + settings PATCH endpoints with requireFeatureFlag so links can't
be minted/changed while off (disable stays open so stale tokens can be cleared).
2026-06-20 11:59:46 +02:00
Luca 0166dc9658 refactor(slideshow): watermark look lives only in global settings (+ size)
The watermark look (logo / position / opacity / style) was configurable in three
places — the global Settings tab, the per-event-type preset, and the per-event
card. Consolidate it to ONE: the global Settings -> Slideshow tab. Per-event and
per-event-type now carry only the watermark MODE (inherit / on / off) — the
override structure — and render with the global look.

- New global "Size (% of screen)" control (slideshow_watermark_size, vmin-based)
  so the logo can be scaled; resolved server-side into the watermark payload and
  applied to the kiosk <img>.
- Backend slideshowSettings resolves the whole look from app_settings always;
  per-event show_watermark only toggles enabled. adminEvents PATCH + type-preset
  seeding no longer accept/seed per-event look fields; unused enums removed.
- Frontend SlideshowStyle drops the look fields (mode only); SlideshowStyleFields
  watermark section is a single mode select with a "configured under Settings"
  hint; SlideshowSettingsCard + Event type cleaned up.
- en/de: watermarkSizeLabel + watermarkModeHint.

(events.show_watermark_{source,position,opacity,style} columns from migration
138 are left in place but inert — the look is global now.)
2026-06-20 10:46:27 +02:00
Luca 69367b45be feat(slideshow): gate behind a feature flag + move globals to a Settings tab
- New `slideshow` feature flag (backend KNOWN_FLAGS/DEFAULT_FLAGS, frontend
  FeatureKey + context default, a toggle card under Settings -> Features -> Core).
  Default off; strictly opt-in.
- Move the global watermark defaults off the Event Types page into a dedicated
  Settings -> Slideshow tab (new SlideshowSettingsPage), shown only when the flag
  is on.
- Gate the per-event Live Slideshow card and the per-event-type preset section
  behind the flag too (and stop writing a type preset when it's off).
- en/de strings for the feature card + settings tab.
2026-06-20 03:08:04 +02:00
Luca db8388c79e fix(slideshow): dip-to-white/black no longer flickers the image
The flash overlay had no base opacity and the keyframe animation has fill-mode
none, so after the first dip it reverted to opacity 1 and stayed opaque between
slides — hiding the image, then briefly revealing it on each advance. Set base
opacity 0, and swap the image at the flash peak so the cut stays hidden.
2026-06-20 03:07:49 +02:00
Luca 6ec46de0e7 fix(slideshow): fill the viewport instead of black bars
The slide <img> used maxWidth/maxHeight:100% with no width/height, so it
rendered at the photo's intrinsic size (e.g. the 1920px preview) and never
scaled up to the projector, leaving black bars all around. Pin the image to
100% x 100% and use object-fit: cover so it fills the whole page.
2026-06-20 02:33:26 +02:00
Luca 0f4388d68a fix(slideshow): read globals from app_settings, not the missing settings table
slideshowSettings used settingsService.getSetting, which queries db('settings')
- a table that does not exist in this app (globals live in app_settings). Every
GET /gallery/:slug/show/:token/session and /state therefore threw and returned
500 INTERNAL_ERROR once a valid token resolved. Switch to getAppSetting
(utils/appSettings), which reads app_settings where the slideshow_watermark_*
and branding_* values are actually written.
2026-06-20 02:18:16 +02:00
Luca 056f9381de fix(slideshow): surface backend error in the live slideshow card
Log the failing request (status + body) to the console and show the backend
error message in the toast instead of a generic "Error", so failures are
diagnosable without server log access.
2026-06-20 01:49:30 +02:00
Luca 1e40f8296c fix(slideshow): drop updated_at from event writes
The events table has no updated_at column (only created_at, and no migration
adds one), so the slideshow generate/disable/settings endpoints 500'd with
'column "updated_at" does not exist'. Write only the show_* columns, and guard
the settings PATCH against an empty update.
2026-06-20 01:49:30 +02:00
Luca cb761ee621 feat(slideshow): en/de strings for live slideshow
Adds the slideshow.* block (transitions, color filters, watermark mode/style/
source, global defaults) and eventTypes.form.slideshowPreset labels in English
and German.
2026-06-19 22:11:17 +02:00
Luca 385b05adcf feat(slideshow): admin ui for live slideshow
- per-event Live Slideshow card on the event detail page: generate/copy/
  regenerate/disable the share link + live style (transition, timing, color
  filter, watermark).
- shared SlideshowStyleFields, reused by the per-event card and the per-event-
  type preset section in the Edit Event Type modal.
- global watermark default card on the Event Types page (Settings -> slideshow).
- WatermarkSourcePicker: visible logo tiles with previews (light logo / dark-mode
  logo / favicon / event logo) instead of a blind dropdown.
- watermark mode tri-state (inherit/on/off) + white-vs-original style.
- supporting service methods + Event/EventType types.
2026-06-19 22:11:12 +02:00
Luca fd02254f78 feat(slideshow): public fullscreen slideshow viewer
- /gallery/:slug/show/:token route + SlideshowPage: splash -> fullscreen kiosk,
  crossfade/cut/slide/kenburns/dip-to-white/dip-to-black transitions, color
  filters, white/original logo watermark overlay, contain/letterbox, cursor
  auto-hide, quiet-append of new uploads, live settings poll, and decode-ahead
  preload (first slide decoded before playback) so transitions do not struggle.
- slideshow.service for session/state + shared style types.
2026-06-19 22:11:02 +02:00
Luca dea5e0f8a6 feat(slideshow): backend api for live slideshow
- public GET /gallery/:slug/show/:token/session (validates token, mints a
  slideshow-scoped gallery JWT + sets the per-slug cookie so <img> requests
  authorize) and /state (cheap settings + photo-count poll). Reuse /photos for
  the list; skip the view-log for slideshow access so the kiosk does not pollute
  visitor analytics.
- admin slideshow link generate/disable + live style PATCH on events.
- event-type slideshow_preset whitelisted in CRUD; create-event seeds the new
  event's show_* columns from the type preset.
- global watermark defaults via PUT /admin/settings/slideshow; watermark cascade
  (global default -> per-event override) resolving the light/dark/favicon/event
  logo url.
2026-06-19 22:10:56 +02:00
Luca 1029dd05bd feat(slideshow): db columns for live slideshow
- 137: events.show_share_token + show_interval_ms/transition/transition_ms
- 138: per-event watermark (tri-state, nullable=inherit global), source/
  position/opacity/style + color filter columns, and event_types.slideshow_preset
  JSON so new events inherit a per-type default. Opt-in, no backfill.
2026-06-19 22:10:48 +02:00
Paul Nothaft 38514ce60f Merge pull request #644 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.66.1-beta.0
2026-06-19 09:58:16 +02:00
github-actions[bot] 5fde5be0d4 chore(beta): release 3.66.1-beta.0 2026-06-19 07:57:49 +00:00
Paul Nothaft 6f40db8597 Merge pull request #643 from the-luap/fix/security-ssrf-bola-trivy-sweep
fix(security): close NAT64 SSRF + photo-export BOLA + sweep Trivy alerts (GHSA-wmjx-pc37-272r, GHSA-9v4w-jrhx-g5wr)
2026-06-19 09:57:28 +02:00
Paul Nothaft d705059d3c fix(deps): bump qs/brace-expansion overrides + add uuid override for node-cron
Code-scanning Trivy alerts on the open beta (PR #641). Of the 10 open
alerts, 6 are stale (lockfile already past the fix) or live in
floating-tag base images (`nginx:1.28-alpine`, `node:22-alpine`) which
auto-update on the next CI rebuild — no code change needed for those.

The 3 actually present in the current `backend/package-lock.json`:

- `qs 6.15.0 → 6.15.2` (CVE-2026-8723, alert #266). Bump override from
  `>=6.14.2` to `>=6.15.2`.
- `brace-expansion 5.0.5 → 5.0.6` (CVE-2026-45149, alert #264). Bump
  override from `>=5.0.5` to `>=5.0.6`.
- `uuid 8.3.2` transitively via `node-cron@3.0.3` (CVE-2026-41907,
  alert #265). Add top-level `uuid: ^11.1.1` override so node-cron's
  nested resolution collapses into our root uuid version. node-cron
  uses only `uuid.v4()` — API-stable across v8 → v11. Verified the
  scheduler still constructs tasks under the override.

Lockfile regenerated; net -9 lines (one fewer uuid copy).

Stale alerts that will close on next code-scan rebuild:
- #205 postcss (frontend lockfile already at 8.5.14)
- #221 i18next-http-backend (backend lockfile already at 3.0.6)

Auto-resolved on next image rebuild (no Dockerfile change — floating
tags):
- #267 nginx (frontend `nginx:1.28-alpine`)
- #223 ip-address, #156/#155 picomatch, #140 brace-expansion (all in
  the npm CLI shipped inside `node:22-alpine`)

Refs: code-scanning alerts #264, #265, #266
2026-06-19 09:46:19 +02:00
Paul Nothaft b8211e9944 fix(security): close BOLA on photo-export + NAT64 SSRF in URL guard
Two security advisories landed against the open #641 branch — bundling
both because they touch independent surfaces and PR #641 is the next
beta ship vehicle.

**GHSA-9v4w-jrhx-g5wr (BOLA on /admin/photo-export/:eventId/*)** —
the three /:eventId-scoped routes in `adminPhotoExport.js` (filtered,
filter-summary, export) ran `adminAuth + requirePermission(...)` but
not `requireEventOwnership`, so any non-super-admin admin/editor with
photos.view (or photos.download) could enumerate + export the photos
of events created by other admins — leaking `original_filename`,
which routinely encodes client identity. Sibling `adminPhotos.js`
applies the middleware on every :eventId route; this file was the
single drift. Reporter: Wernerina.

**GHSA-wmjx-pc37-272r (NAT64 SSRF in `isPrivateIPv6`)** — the old
implementation did naive string-prefix checks (`startsWith('fc')`,
`startsWith('fe80')`) and had zero coverage for NAT64
(`64:ff9b::/96` per RFC 6052, `64:ff9b:1::/48` per RFC 8215). On
instances with NAT64/DNS64 egress, a webhook URL like
`http://[64:ff9b:1::a9fe:a9fe]/` translated through the gateway and
reached 169.254.169.254 — exfiltrating cloud metadata (IAM creds)
into `webhook_deliveries.response_body`. Rewrote `isPrivateIPv6` to
expand the address to its canonical 8-group form, block both NAT64
prefixes, decode embedded IPv4 from IPv4-mapped (`::ffff:0:0/96`) and
deprecated IPv4-compatible (`::/96`) forms and re-check via
`isPrivateIPv4`, and fail closed on any parse failure. Reporter:
tonghuaroot.

Added 34 unit tests covering: both NAT64 prefixes in hex + mixed
dotted-quad notation, IPv4-mapped IPv6 hex + mixed, deprecated
::IPv4 form, legacy fc00::/fd00::/fe80::/::1/:: cases stay blocked,
and public IPv6 (Google/Cloudflare/Google IPv6) negative controls
stay allowed.

Refs: GHSA-9v4w-jrhx-g5wr, GHSA-wmjx-pc37-272r
2026-06-19 09:46:19 +02:00
Paul Nothaft 0d02a0124e Merge pull request #642 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.66.0-beta.0
2026-06-19 09:45:01 +02:00
github-actions[bot] 0a91f1042f chore(beta): release 3.66.0-beta.0 2026-06-19 06:47:36 +00:00
Paul Nothaft 970e18ff33 Merge pull request #641 from the-luap/fix/backup-restore-large-archives
fix+feat: backups + per-category downloads + ConfirmDialog + WhatsApp + feedback-pivot (#640 A+B+C+D+E)
2026-06-19 08:47:15 +02:00
Paul Nothaft a8bb7b439f fix(i18n): wrap WhatsApp token show/hide aria-label through t()
i18n audit caught one straggler — the eye-icon toggle on the access-token
input had a bare `aria-label={showToken ? 'Hide' : 'Show'}` that wouldn't
translate for screen readers on non-English locales. Switched to
`t('common.hide')` / `t('common.show')`; added the matching `common.show`
key in EN + DE (common.hide already existed).

The two remaining `placeholder=` literals in the WhatsApp tab are sample
ID strings (`123456789012345`, `gallery_ready`, `+49123456789`) — those
are identifier/value examples, not translatable English.

Other PR-touched UI surfaces passed the audit clean: 30 new i18n keys
across categories (5), settings.whatsapp (16), settings.features.whatsapp
(2), feedback (3), and the activity-log + bell entries (4) all exist in
both EN and DE.
2026-06-19 08:42:22 +02:00
Paul Nothaft 49bfb45332 fix(settings): hoist tab-visibility useEffect above isLoading early return
Surfaced while exercising Part D (WhatsApp) end-to-end. Navigating to
Settings → WhatsApp triggered React error #310 ("Rendered more hooks
than during the previous render"). Root cause is pre-existing: the
SettingsPage redirect-to-visible-tab `useEffect` lived AFTER the
`if (isLoading) return <Loading />` early return, so on the
isLoading=true→false transition the hook count grew by one and React's
rules-of-hooks invariant blew up.

Move the effect above the early return so the hook count is stable
across renders. While here, switch the gating logic from "is the key in
the currently-visible nav list" (which the bundle couldn't reference
yet because the nav array is built lower down) to a small lookup keyed
by activeTab → matching dependency flag. That's an equivalent decision
for the four tabs we already gated (crm, contracts, reminderTemplates,
accounting) plus the new whatsapp tab.

Add `flagsLoading` from the FeatureFlags context to the deps so the
snap-back only fires once the server's actual flag values have arrived.
Without this, the initial render with the placeholder DEFAULT_FLAGS
would falsely snap away from any tab whose flag is "on" on the server
but absent from the placeholder.

Also add `whatsapp: false` to `DEFAULT_FLAGS` in FeatureFlagsContext
(was missing — TypeScript should have caught the Record<FeatureKey,
boolean> violation but the build pipeline didn't surface it). Without
this, `flags.whatsapp` is undefined on the placeholder, which had
secondary effects on tab visibility and the snap-back logic.

Verified via Chrome DevTools: Settings → WhatsApp now loads cleanly
with all 5 form fields, the saved config values prefilled, the Save
button, and the Send-test card.
2026-06-18 23:55:47 +02:00
Paul Nothaft fabd67aecd feat(feedback): export shape toggle — per-action vs per-guest pivot (#640 part E)
Ports 8digit/picpeak@ed7943b as a TOGGLE rather than a replacement. The
current per-action shape (one row per favourite/like/rating/comment) stays
the default for backward compat with any external scripts consuming the
export; the new pivot shape (one row per (photo, guest_identifier) with
boolean is_favorited/is_liked + star_rating + comment) is opt-in via a
?shape=pivot query param and a dropdown in the admin feedback page.

Pivot wins for "which guests engaged with which photos" analysis in
Sheets / Excel pivot tables. Long wins for engagement timeline analysis
and re-importing into another tool. Different products, both valid.

### Backend

- `feedbackService.exportEventFeedbackPivoted(eventId)`: new method.
  LEFT-of-Map approach, pure JS pivot so PG / SQLite behave identically.
  Key is `(filename, guest_identifier)` — anonymous guests with no
  identifier get a synthetic per-row key so two anonymous comments on the
  same photo don't collapse. Comments: most recent wins (history dropped
  in exchange for "current state" semantics). Hidden-by-moderator rows
  excluded — the pivot represents what we want to surface, not the raw
  event log.
- `adminFeedback.js` export route: accepts `?shape=pivot|long` (default
  `long`). CSV filename now carries the shape (e.g.
  `feedback-pivot-{id}.csv`) so repeated exports don't overwrite.
- `convertToCSV` helper in `adminFeedback.js` gains the three escaping
  improvements that 8digit's commit also shipped: booleans → `yes`/`no`,
  null/undefined → empty, escape strings containing newlines (\n/\r) as
  well as commas/quotes. Comments with line breaks were silently breaking
  CSV row counts before this. Improvements are pure wins regardless of
  shape; archives' own `convertToCSV` copy left untouched (separate
  surface, no behaviour drift risk).

### Frontend

- `feedback.service.ts` `exportEventFeedback()` gains optional `shape`
  parameter, default 'long'.
- `EventFeedbackPage.tsx`: new shape dropdown next to the CSV / JSON
  buttons (defaults to 'long'). Selected shape flows through to the API
  request AND the downloaded filename.

### i18n

3 new EN + DE entries (`feedback.exportShapeLabel`,
`feedback.exportShapeLong`, `feedback.exportShapePivot`).

### Notes

- Pivot shape is **per-guest current state**, not history. A guest who
  rated a photo, then changed their mind and removed the rating, would
  show the final state in the pivot but BOTH actions in the long form.
  Acceptable trade-off: pivot users care about the snapshot, long users
  want the trail.
- `latest_at` column in pivot gives a "most recent activity" timestamp
  per row, useful for sorting/filtering recent engagement.

### Test plan

- [x] Backend syntax + TS check + lint clean (no new warnings; existing
      `catch (error)` warning was pre-existing)
- [ ] Manual: feedback page → select Per-guest (pivot) → Export CSV →
      verify one row per (filename, guest) with is_favorited='yes'/'no',
      latest_at column populated
- [ ] Manual: long shape default still produces the same per-action
      output as before (no regression for existing consumers)
- [ ] Manual: comment containing a newline → pivot CSV escapes correctly,
      row count matches data length + 1 header
- [ ] Manual: archive a published event with feedback → archive's
      `feedback_data.csv` still uses the long shape (archive surface
      unchanged on purpose)
2026-06-18 23:08:55 +02:00
Paul Nothaft 78c8e9d9f9 feat(whatsapp): WhatsApp Business API notification channel (#640 part D)
Ports filpgame/picpeak's WhatsApp integration with substantial adaptation
to fit our codebase patterns. Deliver the gallery-ready notification via
Meta Graph API in addition to (or instead of) email — useful where the
customer base expects WhatsApp by default. Strictly opt-in behind the new
`whatsapp` feature flag.

### Backend

- **Migration 136** (`whatsapp_configs` + `whatsapp_queue`). Loose-FK on
  `event_id` matching our `inbound_documents` / `expenses` pattern (NOT
  filpgame's hard FK — deleting an event shouldn't RESTRICT on stale queue
  rows). Composite index on `(status, retry_count, created_at)` covers the
  poll path.
- **`whatsappService.js`**: thin Meta Graph client. Meta API version bumped
  v19 → v20 (filpgame's v19 deprecates Q3 2026); configurable via
  `WHATSAPP_META_API_VERSION` env var. Timeout dropped 10s → 8s for
  processor budget. Errors surface the Meta `error.code` so the processor
  can tell retryable from permanent.
- **`whatsappProcessor.js`**: queue processor polling every 30s (configurable
  via `WHATSAPP_QUEUE_POLL_MS`), 10 messages per cycle, 3 retries before
  marking `failed`. Default language sourced from
  `app_settings.general_default_language` (matches our email-language
  resolution pattern); replaces filpgame's hardcoded `pt_BR` fallback.
  Falls back to `en_US` if nothing is configured. No-ops gracefully when
  the `whatsapp` flag is off, the config row is missing, or the access
  token isn't set.
- **`adminWhatsapp.js`**: three routes (GET/PUT config, POST test). Gated
  by `requireFeatureFlag('whatsapp')` so operators who haven't enabled it
  can't see the surface. Access token masked as `'********'` on GET;
  masked values silently preserve the stored token on PUT. Enabling with
  no Phone Number ID, template name, or token (and none stored) fails at
  the validator.
- **Two hook points** in `adminEvents.js`:
  - **Create-and-publish-in-one-step**: queues immediately after the
    `gallery_created` email when `!isDraft && customerPhone &&
    waConfig.enabled`. Password from `req.body` is still in scope.
  - **Publish-from-draft** (`POST /:id/publish`): queues with the password
    the admin re-typed via PR #627's `PublishGalleryDialog`. When no
    password was typed (legacy API consumers without dialog), passes empty
    string so the password line renders blank rather than leaking the
    `(set at creation)` sentinel.
- **`server.js`**: starts `whatsappQueueProcessor` at boot. Non-fatal if it
  fails to start (logged as warning).
- **`feature_flags`**: new `whatsapp` flag in `KNOWN_FLAGS` and
  `DEFAULT_FLAGS` (default false).

### Frontend

- **`featureFlags.service.ts`**: `'whatsapp'` added to `FeatureKey` union.
- **`FeaturesTab.tsx`**: WhatsApp card in the Communication section
  (between Incoming mail and Messaging). Smartphone icon, "new" status,
  sidebar-hidden (no sidebar entry — config lives under Settings).
- **`whatsapp.service.ts`** (new): typed client for the three admin routes.
- **`WhatsAppTab.tsx`** (new): Settings tab. Form for Phone Number ID,
  WABA ID, access token (masked toggle), template name, and enabled flag.
  Separate card below for a static test send. Token masking matches the
  server's `'********'` sentinel — admin can edit other fields without
  re-entering the token.
- **`SettingsPage.tsx`**: WhatsApp tab nav item gated on `flags.whatsapp`
  (so it shows only when the feature is enabled); render block wires
  `<WhatsAppTab />`.

### i18n

22 new EN + 22 new DE entries covering the Settings tab form, the
Features-tab card, plus `admin.activities.whatsapp_config_updated` +
`admin.notificationMessages.whatsappConfigUpdated` for the bell /
dashboard surfaces from PR #637.

### Deliberately NOT included

- filpgame's **password-encryption-at-rest** layer
  (`password_encrypted`/`password_iv`/`password_key_version` columns).
  Our publish-from-draft password recovery uses the admin re-type flow
  from #627 (PublishGalleryDialog) — no plaintext at rest.

### Setup notes for operators

1. Create a Meta Business Account + WhatsApp Business App.
2. Register a phone number and obtain `phone_number_id` + `waba_id`.
3. Create a system-user access token (long-lived recommended).
4. Submit a message template for approval. The default `gallery_ready`
   expects 5 body parameters: customer name, event name, gallery link,
   password line, expiry date.
5. Enable the `whatsapp` feature flag.
6. Enter credentials under Settings → WhatsApp, send a test, then enable
   delivery.

### Test plan

- [x] Backend `node -c` on all new/changed files clean
- [x] `tsc --noEmit` on frontend clean
- [x] Backend dev container restart picks up new files, /health OK
- [ ] Manual: enable `whatsapp` flag → Settings → WhatsApp tab appears
- [ ] Manual: save config with masked-only token (existing token preserved)
- [ ] Manual: enable=true without phone_number_id rejected at PUT
- [ ] Manual: enable=true without stored or new token rejected at PUT
- [ ] Manual: create-and-publish event with customer_phone → queue row
      inserts with message_type='gallery_created'
- [ ] Manual: publish-from-draft via PublishGalleryDialog with password →
      queue row uses the admin-typed password in the {{4}} line
- [ ] Manual: test send to a real phone with valid Meta config + approved
      template → Meta returns messages[0].id, toast shows the id
- [ ] Manual: bell renders "WhatsApp configuration updated" in DE when
      the config_updated activity fires (via PR #637 smart default)
2026-06-18 22:57:22 +02:00
Paul Nothaft a3fcb5bc9e feat(common): generic Promise-based ConfirmDialog primitive (#640 part C)
Ports 8digit/picpeak@88bfde1 — replaces `window.confirm()` with a styled,
themed, accessible in-app modal. Usage:

    const confirm = useConfirm();
    const ok = await confirm({
      title: 'Delete event?',
      message: 'This will permanently remove the gallery and all photos.',
      variant: 'danger',
      confirmLabel: 'Delete',
    });
    if (ok) doDelete();

Three variants: 'primary' (default, no icon), 'danger' (red AlertCircle +
red confirm button), 'warning' (amber AlertTriangle). Keyboard support:
Escape cancels, Enter confirms (unless focus is in an input/textarea/select
so an open form doesn't get hijacked), backdrop click cancels. Cancel button
is focused by default — a stray Enter cannot accidentally confirm a
destructive action.

Wraps at App.tsx level, inside GlobalThemeProvider so the modal respects
the theme tokens, above the toast container so a confirm appearing under a
toast still gets the click. Provider exports through components/common
alongside the rest of the shared primitives.

This PR only lands the primitive. Existing window.confirm() call-sites are
left untouched — sweeping them is follow-up work that can land in any
cadence (each sweep is one component, no architectural risk). Existing
structured-input flows (PublishGalleryDialog, DuplicateEventDialog,
PasswordResetModal, etc.) stay as-is — they collect data, not yes/no.

No new i18n entries — uses common.cancel / common.confirm / common.close
which already exist in EN + DE.

### Test plan

- [x] tsc --noEmit clean
- [x] eslint clean on changed files
- [ ] Manual: pick any existing window.confirm() site (e.g. EventDetailsPage
      delete button), swap to useConfirm(), verify the modal renders with
      theme tokens, Escape cancels, Enter confirms, backdrop click cancels,
      focus lands on Cancel
- [ ] Manual: variant='danger' renders red confirm button + AlertCircle icon
- [ ] Manual: open the dialog from inside another modal (e.g. a settings
      panel) — z-[9999] keeps the confirm on top of any other overlay
2026-06-18 22:36:40 +02:00
Paul Nothaft 820f4835f1 feat(categories): per-category download permissions (#640 part B)
Adds an `allow_downloads` boolean to `photo_categories` so admins can
have different download policies per category — e.g. preview categories
public, originals client-only. AND's with the event-level `allow_downloads`,
so disabling at either level blocks downloads for that category's photos.

Defaults to true so categories created before migration 135 keep working
without admin intervention.

Credit: 8digit/picpeak@928164b + @751ec75.

### Backend

- **Migration 135**: additive `allow_downloads BOOLEAN NOT NULL DEFAULT true`
  on `photo_categories`, hasColumn-guarded + sane down.
- **`adminCategories.js`**: PUT /:id accepts optional `allow_downloads` patch.
- **`gallery.js`**:
  - `GET /:slug/photos` returns `allow_downloads` per category AND
    `category_allow_downloads` per photo.
  - `GET /:slug/download/:photoId` returns 403 when the photo's category
    disables downloads.
  - `GET /:slug/download-all` LEFT JOINs `photo_categories` and filters
    `whereNull(category_id) OR allow_downloads=true OR allow_downloads IS NULL`.
    The null check covers pre-migration-135 rows during the upgrade window.
  - `POST /:slug/download-selected` same filter pattern.

### Frontend

- **`categories.service.ts`**: `updateCategory()` gains an optional `patch`
  argument carrying `{ allow_downloads }`. PhotoCategory interface gains the
  optional field.
- **`EventCategoryManager.tsx`**: new toggle button next to the delete X.
  Green DownloadCloud icon when downloads are on, plain Download icon when
  off. Click toggles via the new mutation; toast confirms.
- **`PhotoLightbox.tsx`**: `photoAllowsDownload = allowDownloads && currentPhoto?.category_allow_downloads !== false`. Hides the download button +
  blocks the 'D' keyboard shortcut + early-returns from handleDownload.
- **Types**: Photo interface gains `category_allow_downloads`.
- **i18n**: 5 new EN + DE entries for the toggle button toast + tooltip.

No global-category surface change yet — global categories don't currently
have a UI for the toggle. Admins can still flip the column directly via SQL
or via a future global-categories editor.

### Test plan

- [x] Backend syntax + TS check clean
- [x] ESLint: no new warnings
- [ ] Manual: admin → event detail → categories panel → click DownloadCloud
      icon → category flips, toast confirms
- [ ] Manual: gallery (guest) → photo in disabled category → lightbox shows
      no download button, 'D' shortcut is a no-op
- [ ] Manual: download-all on a gallery with one disabled category →
      ZIP excludes that category's photos
- [ ] Manual: download-selected including a disabled-category photo → 404
      (filtered out) and the response carries only the allowed selection
- [ ] Manual: pre-migration-135 category (legacy row with NULL allow_downloads)
      → downloads still work (defaults true via fallback)
2026-06-18 22:29:35 +02:00
Paul Nothaft e4e79a0b3a fix(archives): stream-extract restore for >2 GiB + preserve original_filename via manifest (#640)
Two related backup-integrity fixes from 8digit's fork (issue #640 items
#3 + #4), bundled because they touch the same two files and ship better
together than apart.

### Stream-extract restore for >2 GiB archives

`adminArchives.js:170` was using `adm-zip`, which loads the entire ZIP
into a Node Buffer before extracting. Node has a hard 2 GiB Buffer cap,
so any restore over that limit fails with `ERR_FS_FILE_TOO_LARGE` — and
since the frontend `onError` toast is the generic "Something went wrong",
the cause stays invisible. Real-world wedding archives routinely cross
2 GiB; affected restores have likely been silent failures.

Swapped `adm-zip` for `node-stream-zip` which streams each entry to disk
as it's processed — no full-file Buffer, no 2 GiB ceiling. API shape:

```js
const zip = new StreamZip.async({ file: archivePath });
const entries = Object.values(await zip.entries());
await zip.extract(null, eventDir);
await zip.close();
```

Re-import logic (photos, categories, sizes) unchanged; only field rename
`entry.entryName` → `entry.name`. Credit: 8digit/picpeak@69033c6.

### Preserve `original_filename` via photos manifest

Archive → restore round-trip currently loses `original_filename` (the
post-#508 column tracking the camera-side name) because the gallery
filenames are renamed on upload and can't be derived from the extracted
files. This matters now that the Lightroom export (#623) depends on
`original_filename` — a restored event lost that signal.

- **`archiveService.js`**: writes `photos_manifest.json` into the archive
  containing per-photo `{filename, original_filename, type, uploaded_at,
  category_name}`. Non-fatal: a manifest write failure falls through to
  legacy behaviour (filename used as original_filename, same as before).
- **`adminArchives.js`**: reads the manifest on restore, builds a
  `Map<filename → manifest>`, and assigns
  `original_filename = manifest?.original_filename || filename`.
  Archives produced before this lands have no manifest — restore logs a
  one-shot notice and falls back to filename, preserving backward compat.

Credit: 8digit/picpeak@eb018aa.

### Deps

- Removed `adm-zip ^0.5.16`
- Added `node-stream-zip ^1.15.0`

### What's NOT in this PR

8digit's commit also fixed the production compose healthcheck (`curl`
isn't in our Alpine image); that's already been addressed upstream in
the meantime. The frontend `onError` swallow on the restore toast is a
separate small follow-up.

### Test plan

- [x] `node -c` on both files clean
- [x] `node-stream-zip` async API verified at load time
- [ ] Manual: archive a multi-GB event → restore → confirm photos
      re-import with original_filename preserved
- [ ] Manual: restore an archive produced before this lands → confirm
      fallback to filename works (no manifest path crashes)
- [ ] Manual: confirm the new photos_manifest.json is inside the
      generated archive (`unzip -l <archive>.zip | grep manifest`)
2026-06-18 22:18:54 +02:00
Paul Nothaft 8212a647c7 Merge pull request #639 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.65.1-beta.0
2026-06-18 21:31:39 +02:00
github-actions[bot] c07fb09fe2 chore(beta): release 3.65.1-beta.0 2026-06-18 19:31:14 +00:00
Paul Nothaft f17c654e14 Merge pull request #637 from the-luap/fix/i18n-activity-types-comprehensive
fix(i18n): sweep activity-type translations + Events / API Tokens / Webhooks settings tabs
2026-06-18 21:30:44 +02:00
Paul Nothaft eaed00fcca Merge remote-tracking branch 'origin/beta' into fix/i18n-activity-types-comprehensive
# Conflicts:
#	frontend/src/i18n/locales/de.json
#	frontend/src/i18n/locales/en.json
2026-06-18 21:28:18 +02:00
Paul Nothaft 1239ec1273 Merge pull request #638 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.65.0-beta.0
2026-06-18 21:24:51 +02:00
github-actions[bot] 0156dd4296 chore(beta): release 3.65.0-beta.0 2026-06-18 19:24:25 +00:00
Paul Nothaft b5279155ea Merge pull request #636 from Luca-Timo/feat/accounting-inbound-invoices
feat(accounting): incoming-invoice workflow v2 + VAT/financial settings consolidation
2026-06-18 21:23:51 +02:00
Paul Nothaft 997a41293e fix(i18n): sweep Events / API Tokens / Webhooks settings tabs
Continuing the activity-type i18n sweep from this PR: three settings
tabs still had hardcoded English strings (or referenced i18n keys that
didn't exist in either locale).

EventsTab (Settings → Event Creation):
- defaultFeedbackEnabled + defaultFeedbackEnabledHelp were referenced
  by the component but missing from both locales. The inline-default
  English text leaked through to German users.

ApiTokensTab (Settings → API Tokens):
- "Preview" table-header column was a bare string literal; now wraps
  through t('settings.apiTokens.preview').
- confirmRevoke called t() with a backtick template-literal default
  ("Revoke \"${token.name}\"…"). The interpolation happened at the
  default-string level, so the actual translated string never received
  the name and shipped without it. Switched to the i18next {{name}}
  parameter pattern with the matching value in en+de.

WebhooksTab (Settings → Webhooks):
- Half the tab was still hardcoded English. Wired everything through
  t(): toast messages (createError, updateError, deletedToast,
  deleteError, copied, copyFailed), Just-Created Secret card buttons
  (Copy, Dismiss), form placeholders (name, URL, template), advanced
  toggle label, filter and template help paragraphs, the filterError
  setter, all six table headers, the eventsSubscribed count (with
  proper {{count}} pluralisation), the status badge (Active/Disabled),
  the active/inactive title tooltips, the Deliveries link, the Delete
  button, and the delete-confirm dialog (proper {{name}} interpolation
  instead of the broken template-literal-in-default-string pattern).

Added 34 new key/value pairs to each locale; counts now symmetric at
events=28, apiTokens=23, webhooks=43 in both EN and DE.

DE wording authored natively; tone matches the existing maintainer-
voice style.
2026-06-18 21:16:30 +02:00
Paul Nothaft bc8d3330bb fix(i18n): sweep activity-type translations + smart notification fallback
The admin notification bell and dashboard "Recent Activities" panel were
showing raw snake_case keys ("event_published") or the generic
"Systemaktivität: <type>" fallback for ~65 activity types — most of them
from the CRM and Accounting modules added since #555. Users with German
locale saw the gap most visibly because the English placeholder leaked
through.

Three pieces:

1. notifications.service.ts — smart `default:` branch. Instead of falling
   straight to the systemActivity template, derive the camelCase i18n key
   from the snake_case type, try resolving `admin.notificationMessages.<camelCase>`
   directly with the full metadata spread as params, and only drop to the
   legacy template when no specific translation exists. This means every
   future activity type just needs an i18n entry — no per-type switch
   case to add.

2. en.json + de.json — added 65 missing `admin.notificationMessages.*`
   bell entries and 58 missing `admin.activities.*` dashboard entries
   across both locales. Covers Contracts (13), Quotes (7), Invoices /
   Storno (12), Monthly billing (5), Expenses (4), Hours (5), Incoming
   invoices (6), Customers (1), Admin user mgmt (3), and 9 misc /
   legacy types (bulk_archive_completed, email_resent, email_queue_flushed,
   email_template_created, event_duplicated, feedback_deleted,
   feedback_moderated, feedback_settings_updated, word_filter_added).
   Both locales finish symmetrical (149 activities / 136 notifications
   each, vs. 91 / 71 before).

3. admin.service.ts `formatActivityMessage` messages dict — added the
   same 58 English-only entries as a last-resort fallback for the
   dashboard when i18n itself fails to load. Keeps the surface
   resilient against bundle-load issues.

Metadata field names in the new translations match what the backend
writes via `logActivity()` — `{{contractNumber}}`, `{{quoteNumber}}`,
`{{invoiceNumber}}`, `{{username}}`, `{{template_key}}`,
`{{source_event_name}}`, `{{word}}` — verified against the call sites
in contractService, quoteService, invoiceService, userManagementService,
expenseService, adminEvents, adminEmail, adminFeedback.

DE wording authored natively; tone matches the existing terse,
maintainer-voice style of the rest of the file.
2026-06-18 21:10:12 +02:00
Luca e9b297c162 fix(crm): editor totals box computed VAT 100× too small
LineItemsTable's live preview did `Math.round(subtotal * vatRate) / 100` where
subtotal is in major units and vatRate is a fraction (0.081) — rounding to whole
units before the /100 divided the VAT by 100 (CHF 0.63 instead of 63.18). Add the
missing *100 inside the round so it rounds to cents. Backend computeTotals + the
PDF + the tax report were always correct; this preview-only bug just surfaced now
that new invoices seed a non-zero default VAT code instead of 0%.
2026-06-18 19:17:12 +02:00
Luca db9e41d198 fix(accounting): tax-report storno totals + hours-line date on Postgres
Two pre-existing HIGH bugs surfaced by the codebase audit (accounting surface):

- taxReportService: income totals excluded only `status='cancelled'`, never
  `kind='storno'`. A Storno (status='sent', amounts stored negative) netted into
  the totals on top of the already-excluded cancelled original → double-subtract,
  so a cancel-and-reissue read as 0 income instead of the reissued amount.
  Now exclude storno rows from grandTotal*/byRate (kept visible in the row list).
  Regression test reproduces the real cancel→storno→reissue 3-row flow.
- customerHoursService.buildLineItemFromEntry: `String(entry.entry_date).slice(0,10)`
  on a `date` column → Postgres returns a JS Date, baking "Wed Apr 06" into the
  invoice line + PDF (SQLite returns the bare string, so SQLite-only tests pass).
  Normalise via the Date branch like every other date read.
2026-06-18 18:40:22 +02:00
Luca 707c5d0277 fix(accounting): address the-luap PR #636 review
- #1 resolveTaxTreatment: an unconfigured (empty) reclaim-countries list no
  longer auto-classifies every supplier — incl. the admin's own domestic one —
  as foreign; defer auto-classification until the setting is set (+ test).
- #2 pending re-bills on customer erase: eraseCustomer now returns the
  customer's not-yet-billed inbound docs to the inbox (null customer + unsorted)
  so they aren't billable to an anonymized account. (NB: picpeak has no hard
  customer delete — erase anonymizes in place — so the orphan/404 premise can't
  occur; this is hardening.)
- #4 VatRateSelect: when >1 configured code shares the same rate, fall through
  to the legacy "(not configured)" option instead of silently picking the first.
- #5 unwindBilledLine: delete the (mutable, never-issued) invoice when the
  unwound re-bill was its only line, instead of leaving a net-zero survivor.
- #6 isInvoiceMutable: clarify in a comment that invoices have no 'draft' status
  (the editable state is 'scheduled' w/o send-at) — no behaviour change.
- nit: collapse normalizeCurrency's tautological ternary.
- Fix VAT picker i18n: t('vat.legacyRate') → 'ledger.vat.legacyRate' (the key's
  real home), so the legacy label localizes instead of always showing English.
- Remove dead i18n keys left by the settings refactor (businessProfile.field VAT
  /hourly + profileFields.title/savedToast).
2026-06-18 18:40:11 +02:00
Luca 33d5408977 refactor(accounting): consolidate the Accounting tab into two cards + one Save
- Box 1 "Default rates": mileage, daily allowance, hourly rate, require-proof.
  Hints now make the cost-vs-billing split explicit (daily allowance = expense,
  hourly = billing fallback).
- Box 2 retitled "VAT": registration, reclaim, default invoice VAT code, and
  the VAT label (moved out of its own card).
- Drop the third card (AccountingProfileFields deleted); the two Save buttons
  become one — it persists both the app_settings and the two business_profile
  fields (VAT label + hourly rate) together.
- Rename "Per-diem" → "Daily allowance" (EN) for clarity; German keeps the
  established "Spesenpauschale".
2026-06-18 15:56:52 +02:00
Luca 267b121d66 feat(accounting): supplier-country tax default + configurable default output VAT code
VAT supplier-country reclaim default:
- Migration 134 adds inbound_documents.supplier_country.
- categorizeInbound auto-derives tax_treatment via resolveTaxTreatment:
  explicit treatment wins; else country in the reclaim list → domestic,
  outside it → foreign_vat_non_reclaimable, unknown → domestic. Consumes the
  previously-stored-but-unused accounting_vat_reclaim_countries.
- Triage modal gains a Supplier country dropdown (saved via updateInbound).
  +5 unit tests for resolveTaxTreatment.

Configurable default output VAT code for new invoices:
- New accounting_default_output_vat_code setting (PUT wired; getSettings/type).
- Settings → Accounting dropdown to pick it.
- Invoice + quote editors seed their VAT picker (rate + code) from it on a
  blank new document — skipping edits/conversions, never clobbering a touched
  value. New docs no longer silently start at 0%.

i18n en + de.
2026-06-18 15:35:58 +02:00
Luca 348955b261 fix(hours): move logActivity out of the entry transactions (SQLite deadlock)
logActivity writes via the global db; called inside a db.transaction it
deadlocks against the held write lock on a SQLite-backed install (a second
write connection blocks). Stage the audit info inside each transaction and
fire it AFTER commit in createEntry / updateEntry / deleteEntry /
billUnbilledEntries — same fix already applied to expenseService. Return
shapes unchanged. (The monthly/billing paths still route through
createInvoice, whose own internal logActivity remains the shared root
limitation — tracked in feedback_sqlite_global_write_in_transaction.)
2026-06-18 15:35:48 +02:00
Luca 51837c3a88 feat(accounting): invoices force-enable the Accounting master
Invoice VAT config (codes + label) and the hourly rate now live under
Settings → Accounting, so an install with Invoices must have Accounting
available.

- applyDependencyRules (backend adminFeatureFlags.js + frontend
  FeatureFlagsContext.tsx): bills on → accounting on, before the
  accounting→children rule so the sub-features keep their own state.
- Migration 133 corrects existing installs: set the STORED accounting=true
  where bills is on. requireFeatureFlag('accounting') reads the raw row, so
  without this an upgraded install (invoices on, accounting off) would show
  the tab but 403 its endpoints. Idempotent; only flips on; no down.
- Features tab: the Accounting card shows locked-on (disabled + hint) while
  Invoices is enabled.

Also includes the i18n keys (en/de) for the VAT/financial settings move.
2026-06-18 15:11:11 +02:00
Luca dc7b87bb87 feat(accounting): consolidate VAT/financial config into Settings → Accounting
- Remove the orphaned "Default VAT rate %" from Business profile; the rates
  are the Accounting VAT codes. The invoice/quote VAT picker (VatRateSelect)
  is now code-only — options are exactly the Accounting output codes, no
  free-text custom rate. Off-list legacy values on existing invoices are
  preserved as a read-only "(not configured)" option so issued documents
  aren't silently changed.
- Move VAT label + default hourly rate to the Accounting tab (new
  AccountingProfileFields card; storage stays on business_profile, own save).
  Wire vat_label onto the PDF VAT-line label via the issuer block (covers
  invoices + quotes), falling back to the locale default when blank.
- Default currency stays on Business profile but becomes a normalizing
  dropdown (an old free-text "chf" auto-selects "CHF"; unknown values
  preserved). Add a moved-note callout. Strip the moved fields from the
  Business-profile save so it can't clobber an Accounting-tab edit.
2026-06-18 15:10:44 +02:00
Luca 315d15afd4 test(accounting): incoming-invoice integration test + fix vat_code reload & SQLite logActivity deadlock
- Add backend/__tests__/integration/incomingInvoiceRebill.test.js (8 tests):
  disposition state machine, per-event PENDING pool, passthrough-no-markup,
  unwindBilledLine recompute, INVOICE_LOCKED on an issued invoice, and
  re-categorisation transitions. The invoice-MINTING paths can't run inside an
  outer transaction on SQLite (createInvoice's sequence claim deadlocks on the
  held write lock) — covered by buildInboundLineItem unit tests + discountLineItems
  instead; documented in the test.

- Move logActivity out of the categorize/rebill/bundle transactions. It writes
  via the global db; inside a transaction a second write connection deadlocks on
  a SQLite-backed install (also affected SQLite-prod, not just tests).

- Fix bill-editor vat_code reload: transformInvoice (adminInvoices.js) dropped
  vatCode, so the editor fell back to rate-matching and lost a custom-rate code
  on edit. Now returns vatCode: i.vat_code.

- Rewrite docs/accounting-inbound-invoices.md to the current implementation
  (IR-vs-Expenses split, re-categorise + unwind, cadence-aware re-bill / pending
  pool, passthrough-at-cost, migrations 122-132, rasterised preview, tax/ledger/VAT).
2026-06-18 14:11:08 +02:00
Luca 9a023c0197 feat(accounting): explain dispositions inline, drop markup from pass-through
- Add a per-disposition info line under the Disposition dropdown so re-bill
  vs pass-through vs company expense is clear in-context (en + de).
- Markup is a re-bill concept only: the control now renders solely for
  rebill, and a pass-through always bills at cost. Enforced server-side too
  (categorizeInbound applies markup only when disposition === 'rebill').
- Clarify "Book to" with a hint — it attributes the supplier cost to an
  event in the tax report / ledger export, separate from who you re-bill to.
2026-06-18 12:44:18 +02:00
Luca 36a8e42f90 feat(accounting): re-categorize incoming invoices, note field, pending re-bill pool
Address three incoming-invoice issues:

1. Re-categorization: a categorized invoice can now be changed again (e.g.
   passthrough → company expense). New "Re-categorize" button pre-fills the
   triage modal from the existing disposition/customer/markup/note.
   categorizeInbound is re-runnable — it unwinds any prior re-bill line
   (removes the invoice line + recomputes totals) before applying the new
   disposition, and refuses (INVOICE_LOCKED) when the re-bill is on an
   already-issued invoice.

2. Note field: new `note` column (migration 132 — 126 is already on beta)
   captured in triage and shown in the read-only view.

3. Re-bill like hours: rebill/passthrough now persist customer_account_id.
   Per-event customers accumulate as PENDING items, surfaced in a new
   "Pending re-bills" card and bundled into one invoice via "Bill these"
   (mirrors unbilled-hours billing). Monthly/manual customers keep
   auto-consolidating onto their running draft. Passthrough (durchlaufend)
   can now also attach to a customer with optional markup.

Adds backend unit tests for buildInboundLineItem + isInvoiceMutable and
en/de translations (other locales fall back to English defaults).
2026-06-18 12:23:13 +02:00
Paul Nothaft 9df2502547 Merge pull request #635 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.64.0-beta.0
2026-06-18 07:20:01 +02:00
github-actions[bot] f6ad6b71dd chore(beta): release 3.64.0-beta.0 2026-06-18 05:18:49 +00:00
Paul Nothaft fc5c1ae93f Merge pull request #633 from the-luap/feat/export-preview-modal-631
feat(admin/exports): inline preview modal with copy-to-clipboard (#631)
2026-06-18 07:18:27 +02:00
Paul Nothaft 27b5f7e4b6 feat(admin/exports): inline preview modal with copy-to-clipboard (#631)
Follow-up to #623. The Lightroom TXT export now shows the filename list
in a modal with a "Copy to clipboard" button instead of triggering a
.txt file download — saves the "open file → select all → copy" dance
admins were doing anyway. CSV export takes the same path (paste straight
into Sheets / Excel).

The modal keeps a "Download as file" button so admins who want the file
(sharing with colleagues, archiving, post-processing tooling) aren't
worse off than before — fully additive.

XMP (ZIP archive) and JSON exports keep their direct download path. A
textarea preview is the wrong UI for a binary archive, and JSON is
structured tool input where the file form is the natural mode.

Implementation:

- ExportPreviewModal — readonly textarea, copy + download buttons,
  monospace font for filename lists, click-to-select-all on the textarea
  for browsers that block clipboard writes (older Safari, hardened
  sandboxes — the catch falls through to a "select and copy manually"
  toast instead of silent failure).
- photosService.exportPhotosAsText — same backend endpoint as
  exportPhotos but resolves the blob.text() and returns
  { content, filename } instead of triggering a download. Preserves
  the existing exportPhotos for the XMP / JSON paths.
- PhotoExportMenu — PREVIEW_FORMATS = ['txt', 'csv']; non-preview
  formats keep the direct-download flow unchanged.
- EN + DE i18n entries.

No backend changes. No new endpoints. No breaking changes for callers
of photosService.exportPhotos.
2026-06-17 23:35:19 +02:00
Paul Nothaft 9d3daa1f93 Merge pull request #632 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.63.0-beta.0
2026-06-17 23:28:59 +02:00
github-actions[bot] d49a4d425d chore(beta): release 3.63.0-beta.0 2026-06-17 21:28:24 +00:00
Paul Nothaft 06b95a2ee7 Merge pull request #630 from the-luap/fix/lightroom-export-623
fix+feat: bundled bugfixes — Lightroom (#623), hero gap (#624), stale cache (#625), publish password (#627), low-memory OOM (#628), duplicate gallery (#626)
2026-06-17 23:27:55 +02:00
Paul Nothaft e985d25207 feat(events): duplicate-gallery action (#626)
Daniel asked for a way to re-use a good gallery configuration without
re-entering every setting. Two of his three suggested workflows are
covered by this PR; the third (per-event-type behaviour defaults) is
partially shipped already via event_types.theme_preset + theme_config
and is left as a follow-up if the duplicate workflow doesn't cover it.

Backend — POST /admin/events/:id/duplicate. Validates a new event_name
(required) + event_date (optional) + customer_name/email (optional);
copies branding (color_theme, css_template_id, header/hero/divider/anchor),
behaviour toggles (allow_downloads, watermark_*, allow_user_uploads,
require_password, etc.), photo_cap, welcome_message, default_photo_sort,
admin_email, and feedback settings + per-event photo categories. Mints a
fresh slug + share_token + random-placeholder password_hash (admin sets
the real one via the publish dialog shipped in #627). Recomputes
expires_at = new_event_date + (source.expires_at - source.event_date) so
the duplicate keeps the same active window; defaults to 30 days if either
source field was null. is_draft is always true.

Deliberately NOT carried over: photos, hero_photo_id, client_access
secrets, og_image_share opt-in, customer_phone, sent_at flags, archive
state, customer-account assignments.

Frontend — new DuplicateEventDialog (matches the PublishGalleryDialog
pattern), wired into the Actions card on EventDetailsPage. Visible in
both draft and live mode since admins typically duplicate from a
published gallery. On success the page navigates to the new draft so the
admin can finish customising + publish.

I18n: EN + DE entries for the dialog + button label. Backend logs an
event_duplicated activity with the source event id/name so the trail is
auditable.

Frontend service: eventsService.duplicateEvent(eventId, data).
2026-06-17 23:16:12 +02:00
Paul Nothaft 714a9f6fb1 fix(upload): auto-throttle on low-memory hosts + correct documented RAM minimum (#628)
The README claimed 2GB RAM as the minimum, but two background-processor
worker loops × sharp.concurrency(2) means up to four libvips threads can
decode full-resolution images in parallel — peak RSS lands at 1.5GB+ on
a batch of 20MP+ photos. Add Postgres + Redis + Node baseline and one
heavy batch on a 2GB VPS OOM-kills the backend, surfacing as 503s on
thumbnails until restart:unless-stopped brings it back. Reported in #602,
filed as #628.

Three changes, smallest-surface-area each:

1. backgroundProcessor.js — on startup, when UPLOAD_PROCESSOR_CONCURRENCY
   is NOT set and os.totalmem() reports < 3GB, default to 1 instead of 2
   and log a one-shot warning naming the override env var. Explicit env-var
   setters keep their value. os.totalmem() reports container memory under
   cgroup v2 so this works in Docker / k8s as well as bare metal.

2. README.md — bumped the documented minimum from 2GB to 4GB, kept 2GB
   only as a "Low-memory hosts" recipe pointing at UPLOAD_PROCESSOR_CONCURRENCY=1
   with the throughput trade-off spelled out. Added the 503-on-OOM symptom
   so the next reporter finds it via search.

3. docker-compose.production.yml — commented mem_limit / memswap_limit
   example on the backend service. Off by default (don't surprise existing
   deployments) but visible to operators thinking about shared/multi-tenant
   hosts. restart:unless-stopped already on every service.

No code path for memory-aware runtime throttling (Luca's option 4) — out
of scope for a bug fix; tracked separately if #1-#3 don't close the case.
2026-06-17 23:04:30 +02:00
Paul Nothaft 83b568ee2d fix(events): publish-from-draft email carries the real password (#627)
Previously, publishing a password-protected DRAFT gallery sent the
gallery_created email with the literal sentinel "(set at creation)",
which the email processor localised to "The password you set when
creating the gallery" / "Das bei der Erstellung der Galerie gesetzte
Passwort". Root cause: at draft creation only the bcrypt hash is stored
(no plaintext column, by design); the publish endpoint had nowhere to
pull the actual password from. Create-and-publish-in-one-step worked
because the plaintext is still in memory at email-queue time.

Fix: the Publish action now opens a small PublishGalleryDialog that
prompts the admin to (re-)type the gallery password. The publish
endpoint accepts an optional `password` body, re-hashes + writes
`password_hash` so the stored hash matches what was just emailed (admins
who mistype at creation get a self-healing publish flow), and puts the
plaintext into the gallery_password email field. When the publish call
is made without a password (API-only consumers), behaviour falls back
to the legacy sentinel — no breaking change.

The window.confirm() publish flow is gone; the dialog handles the no-
password case too (plain confirm + Publish button).

I18n: EN + DE entries for the dialog. Other locales fall through to
the EN defaults via the t() default-value pattern.

No schema changes. No plaintext at rest.
2026-06-17 22:58:17 +02:00
Paul Nothaft ea6245cfde fix(gallery): admin edits to welcome_message land for returning guests (#625)
GalleryAuthContext cached the event in sessionStorage on first visit and
then SKIPPED the server fetch on returning visits (`if (!storedEvent)`),
so a guest who'd already opened the gallery would never see admin edits
to welcome_message / event_name / hero_logo / colour theme — sessionStorage
survives Cmd+Shift+R, so the only escape was closing the tab or wiping
site data manually.

The cached event is still shown above as an instant placeholder for
perceived perf, but the server fetch is no longer gated: on every mount
the fresh row overwrites both React state and the sessionStorage entry.
Cost is one extra /gallery/:slug/photos request per gallery navigation
when the session is already authenticated; benefit is admin edits
propagating on next page load for everyone.
2026-06-17 22:47:48 +02:00
Paul Nothaft 6d6718abe2 Merge pull request #629 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.62.0-beta.0
2026-06-17 22:46:46 +02:00
Paul Nothaft 178d6dafb1 fix(gallery): leave a visible gap between filter bar and hero header (#624)
When a gallery uses the 'hero' header_style AND the admin enables the
filter bar (search + sort), the search/sort row glued itself to the top
of the hero image. Root cause: HeroHeader carries a decorative `-mt-6`
on its outer div (so it can bleed flush against the page header when
nothing else is above), and that exactly cancelled the wrapper's `mt-6`
between PhotoFilterBar and PhotoGridWithLayouts.

Fix: when the filter bar is shown above a hero header, the grid wrapper
uses `mt-12` instead of `mt-6` so the hero's bleed leaves a 24px net gap
rather than zero. The no-filter-bar case keeps the original flush bleed.

Also tidied up: extract the filter-bar-shown predicate to a named const
so the two reads (conditional render + wrapper class) can't drift apart.
2026-06-17 22:31:04 +02:00
Paul Nothaft a239fec9d7 fix(admin/exports): Lightroom TXT export joins with comma + drops extension (#623)
The PhotoExportMenu's TXT format advertises "Simple text list for Lightroom
search" but emitted newline-separated filenames WITH `.jpg`. Lightroom's
filename search wants a comma-separated one-liner, and the gallery JPEGs may
correspond to RAW files in the catalog — so the search has to match on the
stem only.

The frontend now passes `separator: 'comma'` + `include_extension: false` for
the TXT format specifically. The backend gains an `include_extension` option
(defaulting to true so direct API consumers don't break), and the comma case
joins without a trailing space (the form Lightroom expects). Unit test pins
the Lightroom-mode output AND the backward-compatible default for any direct
API caller.

CSV / XMP / JSON exports are unchanged.
2026-06-17 22:25:12 +02:00
github-actions[bot] ccdb5b9823 chore(beta): release 3.62.0-beta.0 2026-06-17 20:18:03 +00:00
Paul Nothaft f279771ee8 Merge pull request #622 from Luca-Timo/feat/accounting-inbound-invoices
Accounting suite + CRM hardening
2026-06-17 22:17:22 +02:00
Luca 8deb7e0741 fix(accounting): tidy the tax-export scope selector styling
The scope <select> inherited `w-full` from the shared selectClassName, so it
stretched the whole row on its own line (the ledger-format select overrides it
with w-auto; this one didn't). Give it `w-auto min-w-[140px]` and wrap it in an
inline "Scope" label so the Report row reads compactly as
"Scope [Complete ▾] [Export CSV] [Export PDF]", consistent with the journal row.
2026-06-16 19:13:25 +02:00
Luca 116743ba43 docs(readme): add CRM + accounting to features, tax disclaimer, update contributor
- New "For Studios — CRM & Accounting (Beta)" subsection under Key Features
  (quotes→contracts→invoices+Storno, hours/calendar, inbound supplier invoices +
  expenses, tax report + Treuhänder/Banana export, VAT) and updated the Roadmap
  beta-table row to "CRM & Accounting Module".
- Broadened the disclaimers section to CRM & Accounting and added a Tax/VAT
  bullet: figures are guidance only + jurisdiction-specific (e.g. the LI 20%
  Gewinnungskosten flat rate), and every operator must verify their own tax/VAT
  regulations with their accountant/Treuhänder/tax authority before relying on
  any figure or export.
- Updated the @Luca-Timo contributor entry with a concise CRM + accounting credit.
2026-06-16 19:05:38 +02:00
Luca 86dff75898 test(accounting): cover export scope, unique-violation detector, PDF page cap
Closes the test gaps from the PR #622 work + the export-scope feature:
- export scope: scopeLedger/normalizeScope (exported via _internal) unit tests +
  renderTaxReportCsv income/cost/all output assertions (income drops supplier
  rows, cost drops invoice rows, filename gets the scope tag).
- isUniqueViolation: Postgres 23505 / SQLITE_CONSTRAINT / "UNIQUE constraint
  failed" message, false for FK + nullish (the IMAP claim-first race detector).
- getRenderedPagePath: out-of-range pages reject with PAGE_OUT_OF_RANGE before
  touching pdftoppm/disk (the per-file resource bound).
2026-06-16 18:54:55 +02:00
Luca 9f3b28684f feat(accounting): scope the tax-report export to income-only or cost-only
Adds a Complete / Income only / Cost only selector to the readable PDF + CSV
export (the on-screen report stays complete). Income-only emits just the
outgoing rows + the income summary line (+ the per-rate breakdown in the PDF);
cost-only emits the incoming-invoice + expense rows + the cost line and drops
the income-by-rate breakdown. Useful in Liechtenstein where, under the income
threshold, a flat 20% Gewinnungskosten deduction is sometimes better than actual
costs — handing the Treuhänder just the income (or just the cost) basis is
cleaner.

Backend: renderTaxReportPdf/Csv take a `scope` param (all|income|cost) that
filters report.ledger by row.type + the summary lines; the /pdf + /csv routes
accept & validate `?scope=`; filenames get an income_/cost_ tag. Frontend:
scope <select> beside the export buttons, threaded through buildQueryString.
i18n en/de. The 20% calculation itself is intentionally NOT in-app (applied by
the Treuhänder) per the scoping decision.
2026-06-16 18:51:11 +02:00
Luca d6da89f48a chore(accounting): PR #622 nits — stray artifact, dedupe requireFlag, IMAP poll backoff
1. Remove the committed test artifact backend/storage/business-docs/quote/2026/
   Q-2026-0001.pdf and gitignore backend/storage/business-docs/ so generated CRM
   docs can't be committed again.
2. adminLedger + adminExpenses dropped their local requireFlag copies and now
   import the shared (now cached) requireFeatureFlag middleware.
4. roundTripTest polls IMAP with ×1.5 backoff (cap 8s) instead of a flat 3s, so a
   30s test takes ~5 SELECT/SEARCH locks not ~10 (some servers throttle).

Nit 3 (dashboard + events pages still on the gallery-theme vars, not dark-mode-
swapped) is left as a documented follow-up per the review.
2026-06-16 18:36:35 +02:00
Luca a93b6dc232 fix(accounting): PR #622 concerns — flag-cache, customer master gate, VAT-unconfigured, helpers, page cap
1. requireFeatureFlag now caches each flag for 10s (the accounting area is 10+
   gated endpoints); PUT /admin/feature-flags invalidates the cache so toggles
   still take effect immediately.
2. Customer routes (/quotes, /invoices, /contracts + their PDFs) now gate via
   getEffectiveFeaturesForCustomer — the global MASTER flag AND the per-customer
   override — instead of the per-customer column alone, via a shared
   customerFeatureAllowed() helper. Admin disabling a feature globally is now
   honoured for customers too.
4. Tax-report VAT-payable: when accounting_vat_registered is UNSET, stop guessing
   from grandTotalVat>0 (a zero-output-VAT quarter silently flipped to "not
   registered" and hid the reclaim). Treat null as "not configured":
   vatPayableMinor=null + vatRegistrationConfigured=false; the UI renders "—" and
   a "configure VAT registration" warning. Tests updated.
5. Shared upsertAppSetting() in utils/appSettings — the two adminSettings upsert
   loops use it, so the app_settings created_at class can't be re-introduced.
6. PDF rasterise per-file bound: getRenderedPagePath refuses pages beyond
   MAX_RENDERABLE_PAGES (200); page_count is capped to match at ingest, so a
   hostile high-page PDF can't drive an unbounded pager.
7. (no code) original_filename is only rendered via auto-escaped JSX; the two
   dangerouslySetInnerHTML sites are admin-authored content — paranoia pass clean.

Concerns 3 (foreign-VAT reclaim-country) and 8 (imap_pass plaintext) are PR-reply
/ doc items, addressed in the PR response, not code.
2026-06-16 18:33:47 +02:00
Luca cd6d57839b fix(accounting): PR #622 blockers — CSV formula injection + IMAP double-ingest race
Blocker 1 — CSV/Banana formula injection. Neither csvEscape (ledgerService) nor
the tax-report CSV escape nor the unquoted tab-separated Banana cell formatter
prefixed risky leading chars, so an admin-/sender-controlled cell beginning with
= + - @ TAB CR executes as a formula when the Treuhänder opens the export. New
shared util neutralizeSpreadsheetFormula() prepends a single quote; wired into
all three sinks (quoted CSV + unquoted Banana). Unit test pins one of each char.

Blocker 2 — IMAP intake double-ingest race. received_emails.message_id was
INDEX, not UNIQUE, and the poller ingested attachments BEFORE writing the audit
row, so a second replica / rolling-deploy overlap double-ingested the same mail.
Migration 128 makes message_id UNIQUE (nulls stay distinct); the intake now
CLAIMS the message row (status='processing') BEFORE ingesting — a concurrent
claim hits the unique constraint and skips cleanly (shared isUniqueViolation
helper). Stale 'processing' rows (worker crashed mid-ingest) are reclaimed after
10 min so no attachment is orphaned.

NOT done (deliberate): the suggested UNIQUE on inbound_documents.file_sha256 —
that column is a SOFT dedup key by design (manual re-uploads are kept as flagged
'duplicate' rows + duplicate_of_id for the Duplikat disposition); a unique index
would break that feature. The file race only yields an extra 'unsorted' row (a
data-quality nit, caught by the existing manual Duplikat backstop), not a
double-count. Rationale to be added to the PR reply.
2026-06-16 18:21:02 +02:00
Luca a7c19135bb fix(branding): force lock = light/dark only; Branding stays the full preset, galleries hide color+mode
Reworks the previous force-mode UX per the intended model:
- Branding page IS the global preset — keep presets, colors, fonts and style
  fully visible. The only change under a force lock: hide the redundant
  per-theme Color Mode picker (light/dark is the Force control), with a hint.
- Force only locks light/dark again: reverted applyForceColorMode to swap the
  surface palette only — it no longer resets typography/style, so the branding
  fonts/style always apply.
- Per-event GALLERY theme editors now receive the global force value and, when
  a lock is active, hide the colour pickers AND the light/dark picker (a gallery
  can't override the site-wide lock). Presets/fonts/layout stay. Force off →
  everything returns.

Wiring: CreateEventPage + EventDetailsPage pass
forceColorMode={publicSettings?.branding_force_color_mode} (value only, no Force
control). Branding keeps both the value and the onForceColorModeChange handler,
which is how the component tells the two contexts apart. i18n en/de.
2026-06-16 15:54:59 +02:00
Luca 1ac653ad1b fix(branding): when a force lock is active, collapse the theme customizer to just the Force control
Follow-up to the force-mode change: hide ALL gallery theme customization while
a force light/dark lock is on, not only colors + typography. Theme presets,
gallery layout, header style, controls style, the colour pickers (incl. accent),
Typography & Style, CSS templates, the PDF-typography slot and event custom CSS
are all hidden — only the Force color mode control (with an explanatory note)
and the Reset/Apply actions remain. Accent brand colours still apply to the
gallery; their picker is just hidden while the lock is on. Turning the lock off
restores the full customizer. Note text + i18n (en/de) updated.
2026-06-16 15:42:10 +02:00
Luca 4749e222dc feat(branding): force color mode = standard look; hide overridden theme controls
When a force light/dark lock is active it now means "use the clean standard
look": applyForceColorMode also resets typography & style (fonts, size, corner
radius, shadow, background pattern) to defaults — on top of the surface/text
palette it already swapped — so those settings genuinely don't apply while the
lock is on. Accent brand colours and the structural cards (header/controls/
gallery layout/hero divider) are preserved. Override-only: the saved theme keeps
the admin's custom values, so turning the lock off restores them.

In the theme customizer, when a force mode is active, hide the now-dead controls
to avoid confusion — the per-theme Color Mode picker, the Surfaces + Text colour
pickers, and the whole Typography & Style card — and show an explanatory note.
The Force picker itself and the Accent pickers stay visible. i18n en/de.

This pairs with the admin dark-mode fix: admin surfaces follow the `.dark` class
which AdminDarkModeContext drives from the force lock, so force is respected
end-to-end.
2026-06-16 15:08:14 +02:00
Luca d3266a0d1c fix(crm): admin surfaces follow the admin light/dark toggle, not the gallery theme (#620)
CRM + accounting admin surfaces read gallery-theme tokens — the
`text-theme`/`text-muted-theme` utility classes and raw `var(--color-surface|
text|surface-border|elevated)` inline styles — which `ThemeContext` writes as
inline `--color-*` on <html> for every route. Those inline vars beat the admin
`.dark` toggle, so e.g. the "Create passive customer" modal (#620) renders dark
while the admin is in light mode (and the reverse).

Repoint every CRM/accounting admin surface to Tailwind `dark:` classes so it
tracks the admin toggle deterministically:
- text-theme → text-neutral-900 dark:text-neutral-100; text-muted-theme →
  text-neutral-500 dark:text-neutral-400 (across the CRM list/detail/editor
  pages, hours, calendar, lineage, installments, CRM settings).
- modal/dropdown/chip/divider inline `var(--color-surface*)` → bg-white
  dark:bg-neutral-900 / border-neutral-200 dark:border-neutral-700 etc.
  (CustomerManagement + CustomerDetail modals = the #620 fix).
- toggle off-track surface-border → bg-neutral-300 dark:bg-neutral-600; brand
  accent ON-state kept (var(--color-accent)).
- CalendarPage FullCalendar chrome: scope local --cal-* vars under .fc / .dark
  .fc so the calendar follows the admin toggle (was reading gallery vars).
- PasswordResetModal bare neutrals + TaxReport Storno/Reissue badges gain dark
  pairings.

Brand accent/primary tokens and the branded admin login are intentionally left
on the gallery theme. The same leak exists in non-CRM admin areas (dashboard,
events) — out of scope here.
2026-06-16 14:54:25 +02:00
Luca 03fa3d8296 fix(flags): close CRM/accounting feature-gating gaps from the audit
A sweep of every CRM/accounting toggle found surfaces still reachable
with their flag OFF. Adds a shared requireFeatureFlag middleware (the two
existing per-file copies predate it) and closes the gaps:

- Hours logging: only createEntry checked the flag — edit/delete/bill and
  the list/summary routes were permission-only. Gate all six
  /hour-entries routes on the hoursLogging master so a disabled feature
  can't be read, mutated, or invoiced via a direct API hit.
- Installment plans: PUT /deals/:uuid/installment-plan mutates invoices
  but wasn't bills-gated; add requireFeatureFlag('bills').
- Customer invoice PDF: /invoices/:id/pdf lacked the feature_bills check
  the list + quotes routes have. Also fixes the quotes-PDF gate, which
  read req.customer.feature_quotes (never populated → silent no-op).
- Customer contracts: /contracts + /contracts/:id/pdf were gated by
  neither the master nor a per-customer column.

Per-customer contracts override (the missing counterpart):
- Migration 131 adds customer_accounts.feature_contracts, default TRUE so
  existing customers keep their Contracts tab (preserve-visuals).
- Effective resolver now contractsMaster AND feature_contracts; admin
  detail page gains the toggle; service/validator/serializer wired.

Cleanups:
- Drop stale `taxReport` from the sidebar's Clients-reveal list (Tax moved
  to Accounting); add the missing `projects` so it mirrors the context
  derivation.
- SettingsPage tab-snap effect now depends on flags.accounting.
- Fix stale taxReport "forced off when bills off" comment (it's accounting).
2026-06-16 13:12:26 +02:00
Luca 873be910a5 feat(accounting): data-driven revenue-rate VAT map (multi-country)
The "VAT code by revenue rate" rows were hardcoded to the Swiss/LI rates
(8.1/2.6/3.8/0), so a code at any other rate (e.g. DE 19%/7%) had no row
to map. Derive the rows from the distinct rates of the OUTPUT VAT codes
instead — retype a code to a local rate and its row appears automatically;
remove the last code at a rate and the row drops. The CH/LI seeds are
unchanged and still produce the same four rows.

Frontend rateKey() mirrors backend ledgerService.rateKey so the saved map
keys keep matching the export-time lookup. Each rate's dropdown is scoped
to output codes at that rate. Empty state when no output codes exist.
2026-06-16 12:43:36 +02:00
Luca 8621338c48 fix(settings): don't insert non-existent created_at into app_settings
app_settings has no created_at column (src/database/db.js defines only
setting_key/value/type + updated_at), so inserting one threw — which
broke saving any FIRST-TIME setting key. Existing keys took the UPDATE
path and worked, hiding the bug; it surfaced on the new VAT-registration
toggle + reclaim-countries keys ("Failed to save accounting settings").
Also fixes the same latent failure on the customer-surface settings route.
2026-06-16 01:23:54 +02:00
Luca 97795f6d1e feat(accounting): move Chart of accounts into Settings → Accounting
Consolidate all accounting configuration in one place. The Chart of
accounts (accounts table + category/default-account mappings) becomes a
self-contained ChartOfAccountsManager rendered in Settings → Accounting,
next to the VAT codes that already moved there. The /admin/accounting
section is now purely operational (Incoming invoices · Expenses · Tax).

The old /admin/accounting/ledger route redirects to the settings tab so
bookmarks keep working; the Tax page "Configure" link points there too.
ChartOfAccountsManager saves only the account keys (partial-merge safe,
same as VatCodesManager), so the two never revert each other's edits.
2026-06-16 01:11:12 +02:00
Luca 4ff5b84cb6 feat(accounting): relocate VAT codes + rate maps into Settings → Accounting
Move VAT-code CRUD and the rate→code / treatment→code maps off the
Chart-of-accounts page into a self-contained VatCodesManager rendered in
Settings → Accounting, so all VAT config lives in one place. CoA keeps
the accounts table, default/system accounts, and expense-category maps.

Both pages save disjoint key sets through the partial-merge updateSettings
(CoA → account keys only; VatCodesManager → ledger_vat_map +
ledger_output_vat_map only), so neither reverts the other's edits.
2026-06-16 00:45:50 +02:00
Luca d7107aaf0a feat(accounting): tax report VAT-payable honours registration + reclaim
The report's vatPayable is now: 0 when not VAT-registered; otherwise output VAT
minus the RECLAIMABLE input VAT only (costs with tax_treatment
foreign_vat_non_reclaimable are excluded from the deduction). Registration reads
accounting_vat_registered; when unset it falls back to a behaviour-preserving
heuristic (charged output VAT this period ⇒ registered), so existing reports are
unchanged and non-VAT installs correctly show 0. loadCosts now tracks
reclaimableVat. Tests updated; 32 pass.
2026-06-16 00:33:10 +02:00
Luca 4d87684882 feat(accounting): VAT registration + reclaim-country settings in the Accounting tab
Adds the 'VAT registration & reclaim' section to Settings → Accounting: a
'VAT-registered' toggle (charge output + reclaim input VAT) and a multi-select
of countries whose input VAT is reclaimable (default domestic CH/LI). Wires
accounting.service + the backend keys added earlier (accounting_vat_registered,
accounting_vat_reclaim_countries). i18n en/de. The report VAT-payable math that
consumes these is the next slice.
2026-06-16 00:27:17 +02:00
Luca 2479d87afc feat(accounting): bill editor VAT dropdown + GET returns vat_code snapshot
Slice 2 + 1b:
- Bill editor: VAT-rate field → VatRateSelect dropdown (mirrors the quote
  editor); snapshots vatCode on create + carries it from a source quote.
- getQuoteById + the invoice serializer now return vat_code, so re-editing a
  saved document preserves the snapshot instead of falling back to the
  rate→code map. Payload types (quotes + bills) carry vatCode.

72 tests pass; build green.
2026-06-16 00:20:59 +02:00
Luca 6e1924bae8 feat(accounting): VAT-code dropdown in the quote editor (+ reusable VatRateSelect)
Slice 3a — replaces the free-typed VAT rate in the quote editor with a dropdown
of configured output VAT codes (+ 'Other (custom rate)'), reading the un-gated
/admin/vat-codes endpoint. Selecting a code sends vatCode → the backend snapshots
it (migration 130) and the export emits it. New VatRateSelect component + a
read-only vatCodes.service. Create flow snapshots correctly; loading a saved code
into the editor (serialization return) + the bill editor are the next slices.
Build green.
2026-06-16 00:08:52 +02:00
Luca fbbbb8ab73 feat(accounting): VAT registration/reclaim settings + un-gated VAT-codes read
Slice 1 of the VAT consolidation backend:
- PUT /admin/settings/accounting accepts accounting_vat_registered (bool) +
  accounting_vat_reclaim_countries (ISO-2 list); GET /:type already returns
  them parsed, so no GET change needed.
- New read-only GET /api/admin/vat-codes (adminAuth, NOT accounting-gated) so
  the invoice/quote editors can populate their VAT dropdown even when the
  accounting layer is off. Management CRUD stays under /admin/ledger.
2026-06-16 00:02:59 +02:00
Luca 5b52969e36 feat(accounting): snapshot the chosen VAT code on quote/invoice create + storno
Wires the vat_code snapshot (migration 130) through the write paths: quote
create/update, the main invoice create, and the Storno carry-over (so a
cancellation exports the same code as the invoice it reverses). Guarded with
hasColumnCached; reads payload.vatCode (sent by the editor dropdown, coming in a
later slice — inert until then, falls back to the rate→code map). 72 tests pass.
2026-06-15 23:57:23 +02:00
Luca 0a7dc1cf5d feat(accounting): snapshot vat_code on quotes/invoices + export prefers it (foundation)
First slice of the VAT-consolidation: migration 130 adds a nullable vat_code
snapshot column to quotes + invoices, and the Treuhänder export now prefers the
invoice's snapshotted code over the (mutable) rate→code map, so a historical
invoice's VatCode never changes when codes are re-mapped. Schema-drift guarded;
behaviour-neutral until the editors start writing the snapshot (next slices).

Part of: VAT registry → Settings→Accounting, invoice VAT dropdown, registration/
reclaim toggle.
2026-06-15 23:53:32 +02:00
Luca 53a16f9f6f fix(accounting): Banana I&E export uses the 'Category' column (not 'ContraAccount')
Real Banana Income & Expense files name the category column 'Category', not
'ContraAccount' (which the doc listed but is a double-entry concept) — so the
income/expense account never landed and Banana warned 'ContraAccount column not
found'. Use 'Category'. VatCode stays (it only warns on a non-VAT-enabled file;
amounts are gross). Test updated.
2026-06-15 23:17:57 +02:00
Luca 0c0fb29770 fix(accounting): emit ISO dates in exports (Postgres returns Date objects)
The Date column imported empty into Banana because dateOnly() did
String(d).slice(0,10) — on Postgres the date columns come back as JS Date
objects, so that yields "Thu Jan 15" instead of "2026-01-15", which Banana
rejects. (SQLite returns strings, so the tests never caught it — the
pg-date-serialisation trap.)

- ledgerService.dateOnly + taxReportService CSV now format Date objects to
  yyyy-mm-dd via local calendar parts (DATE columns are local-midnight).
- Regression test added with a real Date object (the existing tests all used
  string dates).
2026-06-15 23:01:20 +02:00
Luca 445d6d7b6d feat(accounting): add a Banana "Income & Expense" (cash-book) export format
The Banana export assumed a double-entry file; a user importing into an Income
& Expense (Einnahmen-Ausgaben) file got "AccountDebit/AccountCredit/Amount/
VatCode column not found", since those columns only exist in double-entry.

Add a second Banana format alongside the double-entry one:
- ledgerService: new `banana_ie` format → Banana I&E columns Date, Doc,
  Description, Income, Expenses, ContraAccount (the income/expense account),
  VatCode (banana.ch doc 9946). Revenue → gross in Income + revenue account;
  cost → gross in Expenses + expense account. Same tab-separated .txt shape.
- Frontend: ExportFormat + dropdown gain `banana_ie`; .txt extension covers
  both Banana variants. Labels relabelled: "Banana — double-entry" and
  "Banana — income & expense" (de equivalents). Hint de-"double-entry"-fied.
- Test added for the I&E format.

Pairs with the prior UTF-8 BOM fix (the "·" mojibake). Tests + build green.
2026-06-15 22:48:06 +02:00
Luca 74144da45f fix(accounting): UTF-8 BOM on the ledger export so Banana reads it correctly
The /ledger/export route sent the file without a BOM, so Banana (and Excel)
decoded it as the local charset — the '·' description separator and any umlauts
imported as mojibake ('·'). Prepend the EF BB BF BOM like the tax-report CSV
route already does.
2026-06-15 22:39:32 +02:00
Luca a19506749a fix(accounting): Banana export is now a tab-separated .txt (actually importable)
Banana's "Text file with column headers" import (Actions → Import into
accounting) requires a TAB-separated .txt with unquoted values — picpeak was
emitting a comma-separated, quoted .csv, which won't even show in Banana's
*.txt file picker, let alone parse into columns.

- ledgerService.exportPostings: the `banana` format now serialises TAB-separated
  with no quoting, .txt extension, text/plain content-type. generic + bexio stay
  comma-CSV (RFC 4180). Tab/newline chars in a cell are collapsed to spaces.
- Frontend ledger.service: download filename uses .txt for banana.
- Tests updated for the new banana shape (tab header, .txt, text/plain).

The column names already matched Banana's NameXml; only the serialisation was
wrong. bexio left as comma-CSV (verify against bexio's import spec separately).
2026-06-15 22:28:10 +02:00
Luca b584aaf7a6 test(accounting): update tax-report CSV tests for the unified ledger format
The CSV rework (unified, typed ledger) replaced the 'Rechnung' column with
'Referenz' (+ a 'Typ' column) and dropped the separate cancelled 0/1 column in
favour of a localised '(Cancelled)' suffix on the Reference cell. Update the
two assertions in taxReportPdf.test.js accordingly. All 11 cases pass.
2026-06-15 19:54:18 +02:00
Luca 7e586cd3a1 style(accounting): align tax-export buttons + solid divider between groups
- Give all four export controls (CSV / PDF / format select / Accountant export)
  a matching min-width so the two rows form a tidy right-aligned button grid
  (CSV over format select, Export PDF over Accountant export).
- Replace the dashed sub-divider between the Report and Accounting journal
  groups with a solid line so the separation reads clearly.
2026-06-15 19:30:22 +02:00
Luca 3edd832103 feat(accounting): clearer tax-export window + gate journal export on accounting flag
- Restructure the export area into two labelled groups: 'Report' (PDF/CSV,
  for you) and 'Accounting journal' (for your accountant), each with a
  one-line caption — instead of two unlabelled button rows.
- i18n: the English label was the German 'Treuhänder export' → now 'Accountant
  export' (de stays 'Treuhänder-Export'); hint reworded.
- Feature flags: the journal export is an accounting-layer feature (needs the
  Chart-of-accounts mapping), so gate it on the 'accounting' master — the
  group only renders when accounting is on, and the backend /export route no
  longer requires the 'taxReport' sub-flag (the router already requires accounting).

Build + node --check + JSON parse green.
2026-06-15 19:18:36 +02:00
Luca b1f73c1df9 feat(accounting): move Treuhänder export onto the Tax page
The standalone 'Treuhänder export' tab duplicated the Tax page's period/
currency filters over the same data. Fold the collective-journal export into
the Tax page as a third export action (target-tool format picker: generic /
Banana / bexio), beside Export CSV/PDF, with a link to its Chart-of-accounts
config. Removes the Accounting sub-nav 'export' tab (old /export route now
redirects to the Tax page); keeps Chart of accounts as its own setup tab.
Deletes the now-orphaned LedgerExportPage.

Build + JSON parse green.
2026-06-15 18:57:43 +02:00
Luca f3e77e7807 fix(accounting): label the outgoing-invoice totals block in the tax summary
The summary card's top block (Total net/VAT/gross) is the outgoing-invoice
totals but had no section header, unlike the 'Income / costs' block below.
Add an 'Outgoing invoices' (de: 'Ausgangsrechnungen') header to match.
2026-06-15 17:48:22 +02:00
Luca fd1dd81e8d feat(accounting): unify tax report into one signed, typed, sortable ledger
Replaces the separate revenue + costs tables with a single ledger across the
screen, CSV and PDF. Every row is typed (outgoing invoice / incoming invoice /
expense) and signed — outgoing positive, incoming + expenses negative — so
sorting by value runs income → costs and the column nets toward the Result.

- getTaxReport now returns a `ledger` array (signed, typed, date-sorted);
  legacy rows/costs/summary kept for back-compat.
- Frontend: one sortable table (click Type/Date/Party/Net/VAT/Gross), coloured
  type badges, cancelled rows greyed with lineage badges; Income/Costs/Result
  summary box unchanged.
- CSV + PDF reworked to the same unified, signed layout; PDF totals show
  Income / Costs (negative) / Result.
- i18n: en/de (frontend) + pdf-i18n (en/de real; fr/nl/pt/ru English-fallback,
  flagged for native review).

Build + node --check + JSON parse green.
2026-06-15 17:30:55 +02:00
Luca ab65a470a0 fix(accounting): tax report cost side queried a non-existent column
The tax-report cost query selected inbound_documents.description, but that
column only exists on the 'expenses' table — inbound_documents has none. On
Postgres this threw 'column inbound_documents.description does not exist',
so the whole cost side failed with 'Costs could not be loaded'.

Use inbound_documents.invoice_number (an existing column, same descriptor
ledgerService surfaces) as the cost-row label instead. Expense rows still
use their real expenses.description column.
2026-06-15 16:58:44 +02:00
Luca 402dbde0a1 Merge origin/beta into feat/accounting-inbound-invoices
Resolves the 7 feature-flag / i18n conflicts (accounting flags vs upstream's
Project Overview 'projects' flag, both registered in the same files) as
additive unions — accounting + incomingInvoices + expenses AND projects all
coexist. Migrations slot cleanly: projects 117-121, accounting 122-129, no
collisions. Frontend build + backend node --check pass.
2026-06-15 16:37:23 +02:00
Paul Nothaft 539f93551a Merge pull request #618 from Luca-Timo/fix/maintenance-locks-out-admin-login
Enabling maintenance mode locks every admin out of the panel
2026-06-14 00:06:06 +02:00
Paul Nothaft b757235b1a Merge pull request #619 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.61.0-beta.0
2026-06-14 00:05:54 +02:00
github-actions[bot] bd07fc474d chore(beta): release 3.61.0-beta.0 2026-06-13 21:47:18 +00:00
Paul Nothaft 58f93ae713 Merge pull request #616 from Luca-Timo/feat/crm-improvements
feat(projects): Project Overview cockpit — link (multiple) quotes/contracts/hours into projects
2026-06-13 23:46:51 +02:00
Luca fdde4696e7 fix(maintenance): never block /admin/* with the maintenance screen
The previous wrapper gated /admin/* on a /auth/session check and only showed
the panel when an admin session was detected. Two failures:

1. The session check effect depended on `isAdminRoute` (a boolean), so the
   client-side login → dashboard navigation (both /admin/*) never re-ran it.
   hasAdminSession stayed stale-false from the logged-out /admin/login render,
   so a freshly logged-in admin landed on the maintenance screen anyway.
2. It also hid /admin/login itself (the catch-22).

Fix: the maintenance screen only blocks customer/gallery/public routes —
/admin/* is never blocked. The admin auth layer already handles access
(AdminLayout redirects a logged-out admin to /admin/login), so no session
probe is needed here. Removes the fragile /auth/session dependency entirely.

Backend skipPaths (/api/auth/admin/login + /api/auth/session) stays: login and
AdminAuthContext's token validation must still work during maintenance.
2026-06-13 14:18:02 +02:00
Luca 249313072b fix(maintenance): enabling maintenance mode no longer locks admins out
Turning on maintenance mode locked out every admin — including ones already
logged in — with no way back in from the browser. Two causes:

1. Backend (middleware/maintenance.js): the skipPaths allow-list pointed at
   /api/admin/login and /api/admin/auth/login, but the real admin auth routes
   live under /api/auth (POST /api/auth/admin/login, GET /api/auth/session).
   So during maintenance both the login POST and the session check 503'd. The
   503 on /auth/session made the frontend read every admin as logged-out, and
   also tripped the axios interceptor that force-enables maintenance globally.
   Fixed the allow-list to the actual endpoints.

2. Frontend (MaintenanceWrapper.tsx): the maintenance screen rendered over
   every /admin/* route unless an admin session already existed — covering the
   /admin/login page itself. A logged-out admin could never reach the form to
   get a session (catch-22). /admin/login is now always allowed through.

With both: a logged-in admin keeps working (session check passes), and a
logged-out admin can reach /admin/login and sign back in, all while
maintenance mode correctly blocks customers.
2026-06-13 13:57:21 +02:00
Luca f74d8d4e8c fix(projects): "one customer matches" rule for deal-lineage attach
Single-customer projects, but content is addable whenever ONE of its customers
is the project's customer (not only when the first lineage customer equals it):

- linkDealToProject: collect ALL customers across the deal's quote/contract/
  invoice lineage and reject only when none is the project's customer. Mirrors
  the events path, where a multi-customer event already attaches if any of its
  customers matches. Adoption onto an empty project unchanged.
- assignDocument: a document carries one customer, so equality stays correct;
  message aligned with the lineage check.
2026-06-13 13:12:58 +02:00
Luca 4b1e85c855 fix(projects): enforce single-customer projects (guard event attach + re-label)
A project must stay tied to one customer. The quote/contract/hours attach
paths already rejected a foreign customer (equality on project.customer_account_id);
the two remaining holes are closed here:

- assignEvent: an event may only join a project that shares its customer. The
  event's customer(s) come from event_customer_assignments; a customer-assigned
  project rejects an event for a different customer (PROJECT_CUSTOMER_MISMATCH),
  and an empty project ADOPTS a single-customer event's customer. This is why
  a foreign-customer event could previously be attached.

- updateProject: re-labelling a project to a customer that conflicts with the
  events/quotes/contracts it already holds is rejected (clearing to null is
  still allowed), so the customer can't be swapped out from under existing
  content.

Frontend: the cockpit attach-event action surfaces the translated mismatch
message; projects.error.customerMismatch reworded to read for both documents
and events (de + en).
2026-06-13 13:09:55 +02:00
Luca 9d13880f2b fix(projects): address review — cross-customer guards + email/queue hardening
Resolves the two blockers and the actionable concerns/nits from review.

Blockers (cross-customer leak):
- linkDealToProject: collect the deal's customer + events BEFORE any write,
  then reject a cross-customer link with PROJECT_CUSTOMER_MISMATCH (422) before
  re-pointing events/quotes/contracts or adopting a customer. The editors set
  project_id via quoteService/contractService → linkDealToProject (not
  assignDocument), so the guard lives at that chokepoint. Null-project adoption
  ("first deal wins") preserved as intended.
- assignDocument: boundary guard mirroring customerHoursService, defense-in-depth
  ahead of the cascade.
- Frontend: translated PROJECT_CUSTOMER_MISMATCH (projects.error.customerMismatch,
  de+en) wired into HoursSection + quote/contract editor onError (concern 5).

Concerns:
- 1: processEmailQueue gains an onlyId option; cockpit "send now" scopes the
  flush to the single row so it can't force-retry other dead-lettered emails.
- 2: resendEmail re-stringifies email_data when PG returns a parsed object,
  matching the canonical enqueue — no jsonb double-encode.
- 3: cockpit email feed scoped to the project's own document numbers (event_id
  for gallery mails; email_data doc-number match for CRM mails) instead of the
  recipient string — a shared inbox no longer leaks another customer's mail.
- 4: migration 117 backfill wrapped in a transaction (adds atomicity on SQLite,
  where the runner does not wrap; PG already wraps the whole migration).
- 6: resend/cancel/retry/sendNow now logActivity uniformly (project_email_*),
  adminId threaded from the route.
- 8: validator optional({ values: 'null' }) → optional({ nullable: true }).
- 9: pre-121 list valuation falls back to customer-scoped quotes so the list
  isn't all-zero during the upgrade window.

Nits:
- milestone selection uses Array.at(-1); removed redundant in-loop require in
  emailProcessor; clarifying comments for the list/detail perms split and the
  count-vs-value (0 vs em-dash) convention.
2026-06-13 11:57:32 +02:00
Luca 3b70a09773 fix(accounting): 'Save & mark paid' actually pays; incoming invoices appear in tax/export
#1 Triage 'Save & mark paid' now marks the invoice paid directly (categorize +
   markInboundPaid with the entered reference) instead of opening the pay dialog
   and leaving it unpaid. Removed the PayModal chain.
#2 Cost side missed captured incoming invoices: the query required
   currency='CHF', but email/upload invoices often have a null currency →
   silently excluded. Now include null-currency rows (treated as the report
   currency). Also replaced COALESCE(invoice_date, created_at) with a split
   date filter (invoice_date BETWEEN, else created_at range) to avoid the
   mixed date/timestamp comparison risk on Postgres. Same fix in the ledger
   export (buildPostings).
en/de: categorizedPaidToast.
2026-06-12 18:20:55 +02:00
Luca 663daf50ff fix(accounting): always show Income/Costs/Result summary on tax page (even with zero costs)
The Einnahmen-Ausgaben summary only rendered when costs existed, so a period
with no incoming invoices/expenses looked revenue-only. Now it shows whenever
the cost side loaded successfully (costs default to 0 → Result = Income), so
the income/result is always visible. Still hidden when the cost side errored
(the amber banner covers that case).
2026-06-12 17:57:54 +02:00
Luca bbceac6cb0 ci: make GHA cache export non-fatal (ignore-error=true)
The frontend/backend image builds + pushes succeed, then the final
'exporting to GitHub Actions Cache' step intermittently fails with
'error writing layer blob: not_found' (a known flaky type=gha cache backend
issue), failing the whole job. Add ignore-error=true to every cache-to so a
cache-write hiccup can't break an otherwise-successful, already-pushed build.
2026-06-12 17:56:06 +02:00
Luca 9f8511114a fix(accounting): tax report degrades gracefully if cost side fails (+ surface the error)
The cost side is supplementary — it must never 500 the core revenue report.
getTaxReport now wraps loadCosts in try/catch: on failure it returns empty
costs + a costsError string and logs the real error. The tax page shows the
revenue report plus a non-fatal amber banner with the cost-side error message,
so the actual cause is visible in the UI instead of an opaque 500.
2026-06-12 17:43:42 +02:00
Luca ea8f6bc88a fix(accounting): tax report 500 on Postgres — drop SQL date() from cost queries
The #4 cost side used 'date(COALESCE(invoice_date, created_at)) BETWEEN ...'
and 'date(created_at) BETWEEN ...'. The mocked unit tests never execute the
SQL, so the Postgres failure (date()/COALESCE(date,timestamp)) slipped through
and surfaced as a 500 on the live tax report. Replaced with plain range
comparisons (col >= from AND col <= '<to> 23:59:59.999') — valid on both PG and
SQLite, inclusive of the whole end day. Same fix applied to ledgerService
buildPostings (the Treuhänder export would have 500'd identically).
2026-06-12 16:57:34 +02:00
Luca 9514f5cb8e fix(accounting): distinguish Categorized (purple) from Paid (green)
Both badges were green; recolor the 'categorized' status to purple so the
status (categorized) and payment state (paid) read distinctly.
2026-06-12 16:41:59 +02:00
Luca 5fcb96c723 fix(accounting): PDF pager always shown, click categorized→pay, drop duplicate Paid chip
#1 DocumentPreview renders the page pager for every PDF (disabled at the ends),
   not only multi-page ones — so the control is visible on single-page invoices.
#2 Clicking a categorized (unpaid) invoice opens the Mark-paid dialog; new →
   categorize, paid/declined/duplicate → view.
#3 A paid row no longer shows two 'Paid' chips — the front badge is the status,
   and the right action becomes a quiet 'Mark unpaid' (revert).
2026-06-12 16:41:06 +02:00
Luca 72b784c9d7 feat(accounting): incoming-invoice triage refinements (paid badge, click-to-categorize, reference, categorize+pay)
#1 Row status reads 'Paid' (green) once supplierPaid — no longer the stale
   'categorized' badge.
#2 Clicking a new (unsorted) invoice opens the Categorize modal; sorted ones
   still open the read-only view.
#3 Triage gains a Payment reference field (persisted via updateInbound →
   payment_reference).
#5 Triage has two actions: 'Save' (categorize only) and 'Save & mark paid'
   (categorize, then chain into the mark-paid dialog with the reference
   prefilled).
#4 (mark-paid PDF nav) was already present via DocumentPreview — no change.

en/de strings added.
2026-06-12 16:27:29 +02:00
Luca c36797db2d fix(email): log all received mail, not just unseen (90-day lookback + dedup)
Cause of 'not all received emails listed': the poller fetched {seen:false}
only, so any message already read in another client was never pulled or logged.

Now the poller scans a LOOKBACK_DAYS (90) window regardless of \Seen via a
cheap envelope-only pass, dedups by message-id against received_emails, and only
downloads + processes (fetchOne source) messages not yet logged — so the
Received tab is complete while each poll stays light. Marks processed messages
seen; re-checks the parsed message-id before insert.
2026-06-12 16:06:40 +02:00
Luca cee692c10a fix(accounting): lock company-expense to company, first-page categorise preview, auto-refresh inbox
#1 Incoming-invoice triage: 'Company expense' (eigener_aufwand) no longer shows
   the event picker — it always books to the company (removed from
   BOOKING_DISPOSITIONS, so categorize sends event_id null).
#2 Auto-refresh: AccountingInboxPage + ReceivedEmailsPanel poll every 30s
   (refetchInterval) so background IMAP ingests appear without a manual reload.
#3 DocumentPreview defaults to the FIRST page (invoice header) for triage/view;
   PayModal opts into the LAST page (Swiss QR-bill) via initialPage='last'.
2026-06-12 15:43:21 +02:00
Luca 9c18dcf377 fix(email): always log incoming mail to received_emails (was lost on insert error)
Symptom: an emailed attachment landed in Incoming invoices but the message
never appeared under Received emails. The attachment is saved BEFORE the
received_emails insert, so any throw there left the audit row unwritten and
silently swallowed.

- coerce a malformed Date: header (Invalid Date) to now — it would otherwise
  throw on the Postgres timestamp insert (most likely root cause)
- isolate each attachment in its own try so one bad file can't skip the audit
- truncate from_address to the column width; persist attachment errors + an
  'error' status so partial failures are visible
- log loudly when the received_emails insert itself fails (no more silent loss)

Self-healing: the stuck message was never marked \Seen, so the next poll
re-processes it and writes the row.
2026-06-12 15:14:16 +02:00
Luca 8a54c6f6b1 fix(email): fail-fast IMAP timeouts + manual 'Check now' poll
- Root cause of the 502s: ImapFlow had no connect timeout, so a wrong host/port
  (e.g. IMAP on an SMTP port) hung the request until the proxy returned 502 with
  no message. Added connectionTimeout/greetingTimeout/socketTimeout + a hard
  connectWithTimeout() race on every IMAP client (detect/test/roundtrip/poll).
- Error routes now return 422 with the underlying reason (was 502, which
  collided with the proxy's own 502 and hid the message).
- New 'Check now' button + POST /incoming-config/poll runs the poller on demand
  (respects the incomingMail flag) and reports disabled/unconfigured/busy or N
  ingested — so 'nothing in Received' is diagnosable without waiting 60s.
- en/de strings
2026-06-12 15:08:12 +02:00
Luca e258472391 fix(email): guard round-trip test when IMAP username isn't an email
The round-trip recipient is imap_user (not hardcoded). Some hosts use a
non-email IMAP login — guard against silently sending to a bogus address:
return a clear 'recipient_not_email' error explaining to use a mailbox whose
username is its email, or test connection + manual send instead.
2026-06-12 14:54:18 +02:00
Luca 04be51a008 feat(email): round-trip test — send via SMTP to the IMAP mailbox and confirm arrival
- emailIntakeService.roundTripTest(): sends a uniquely-tagged email through the
  saved SMTP config to the IMAP mailbox (imap_user), then polls IMAP up to 30s
  for that subject token; deletes the test message on arrival so it never hits
  the accounting inbox. Returns {ok, seconds, recipient} or a typed reason.
- route POST /admin/email/incoming-config/roundtrip (email.send)
- IMAP card: 'Round-trip test' button beside 'Test connection' + Save; toast
  reports recipient + delivery time. Distinct reasons mapped (smtp/imap
  unconfigured, send_failed, not_received→504).
- en/de strings
2026-06-12 14:52:14 +02:00
Luca f017649bd5 feat(email): add 'Test connection' to incoming mail + tidy IMAP label
- emailIntakeService.testConnection(): logs in, opens the configured folder,
  reports message/unread counts (non-destructive). Accepts current form creds
  so it works before saving; masked password falls back to stored.
- route POST /admin/email/incoming-config/test
- IMAP card: 'Test connection' button beside Save; toast shows folder + counts
- capitalize 'IMAP Host' label to match 'SMTP Host'

Note: incoming uses IMAP (receiving) vs outgoing SMTP (sending) — genuinely
different servers/credentials, hence the distinct field set (Folder; no From).
2026-06-12 14:47:14 +02:00
Luca d04a6978e9 fix(email): IMAP Security dropdown matches outgoing — no port in label, manual port
Reverts the auto-fill; drops the (993)/(143) from the Security option labels so
incoming behaves exactly like the outgoing SMTP card (plain SSL/TLS vs
STARTTLS, port set manually).
2026-06-12 14:41:49 +02:00
Luca bd402d2e89 fix(email): IMAP Security dropdown auto-fills the conventional port
Selecting SSL/TLS sets port 993 and STARTTLS/none sets 143, so the port in
the dropdown label is no longer just decoration. A non-standard custom port
(anything other than 993/143/empty) is left untouched.
2026-06-12 14:39:09 +02:00
Luca fb48ba4cb4 fix(email): mark required fields on incoming mail to match outgoing SMTP
The IMAP card was restyled to match SMTP but didn't carry the required-field
markers. Aligned the required set (protocol differences kept):
- red asterisks on Host *, Port *, Username * (SMTP marks Host/Port/From-Email;
  IMAP has no From-Email but always needs a login)
- client-side guard mirroring handleSaveSmtp (block save without host/port/user)
- backend POST /incoming-config now requires imap_user (the poller's
  getImapConfig returns null without it)
- en/de requiredFields string
2026-06-12 12:33:04 +02:00
Luca abb23f01c7 fix(email): match IMAP card to SMTP styling + auto-detect mailbox folders
- IncomingMailConfigCard rebuilt to mirror the outgoing SMTP card: Card
  padding=md, icon inputs (Server/User/Lock), password eye toggle, stacked
  full-width fields, full-width primary Save button
- Folder is now a dropdown auto-populated by a 'Detect' button instead of a
  free-text path: backend emailIntakeService.listFolders() lists IMAP
  mailboxes (POST /admin/email/incoming-config/folders, accepts current form
  creds, masked password falls back to stored); UI auto-selects the inbox
  (special-use) folder
- en/de strings added
2026-06-12 01:11:31 +02:00
Luca 7e0098edcd feat(accounting): Layer A frontend — chart of accounts CRUD + Treuhänder export UI
- ledger.service.ts: accounts/VAT-codes/mappings CRUD + export client
- ChartOfAccountsPage: full CRUD for the Swiss/LI KMU chart + MWST codes,
  category→account mapping, default/system accounts + tax-treatment/rate→VAT
  maps, with a 'guideline only' note
- LedgerExportPage: period + currency + target tool (generic/Banana/bexio)
  → collective-journal CSV download, with accrual-only + Treuhänder disclaimer
- AccountingLayout: 'Treuhänder export' (taxReport flag) + 'Chart of accounts'
  (accounting flag) sub-nav entries; App routes wired
- en/de translations (accounting.taxTreatment.* enum + ledger.* namespace)

de/en authored natively; no machine-translated locales touched here.
2026-06-12 00:48:39 +02:00
Luca 03cc250b47 feat(accounting): Layer A backend — chart of accounts, VAT codes, Treuhänder export
Prepares picpeak to feed a Treuhänder's double-entry software once a user
crosses the CHF ~500k threshold (LI PGR Art. 1045), without becoming an ERP.

- migration 129: ledger_accounts (seeded Swiss/LI KMU-Kontenrahmen) +
  vat_codes (CH/LI MWST 8.1/2.6/3.8/0 + reverse charge), expense_categories
  gains ledger_account_id, app_settings default-account + VAT-map seeds
- ledgerService: full CRUD for accounts + VAT codes + mappings; buildPostings()
  turns revenue invoices + incoming invoices + expenses into accrual
  Buchungssätze (Dr/Cr + VAT code); generic/banana/bexio CSV export
- routes /api/admin/ledger/* (accounting master gated; export also requires
  taxReport); 12 unit tests (posting engine + formatters)

Accrual basis only — payment/bank postings are Layer B. Output is a guideline
(Treuhänder caveat on the UI).
2026-06-11 21:37:48 +02:00
Luca 727bdab6e0 feat(accounting): re-viewable incoming invoices + expense invoiced/paid lifecycle UI
#1 Incoming invoices are re-viewable: extracted a reusable rasterised
DocumentPreview (last page = QR-bill), added a click-to-view ViewModal on
every row, and embedded the preview in the mark-paid dialog.

#2/#3 Expenses ledger:
- invoiced badge (links to the client invoice) + paid toggle (manual,
  independent of invoiced)
- edit until invoiced (ExpenseFormModal now does create + edit; locked
  rows show a Lock chip instead of edit/add-to-invoice)
- 'Add to invoice' action (re-bill via customer picker + markup) and a
  'Mark paid' dialog
- service: Expense gains invoiced/billedInvoiceId/paid/paidAt fields +
  invoiceExpense() and markExpensePaid()

en/de translations added.
2026-06-11 21:19:06 +02:00
Luca 545ef334f4 feat(accounting): tax window shows all costs (incoming invoices + expenses) alongside revenue
Einnahmen-Ausgaben view for the Milchbüchlein/simple-accounting case:
- taxReportService.getTaxReport now returns a cost side (loadCosts:
  incoming invoices + internal expenses, company- or event-booked,
  schema-guarded) plus a summary (income / costs / result, VAT payable)
- declined/duplicate costs excluded; re-billed costs kept (matching
  re-bill revenue is counted, so the net is correct)
- CSV + PDF exports gain a Costs section and an income/costs/result
  summary; pdf-i18n keys added for all 6 locales (fr/nl/pt/ru machine —
  flag for native review)
- frontend tax page renders the summary card, a costs table (company
  vs event), and a 'verify with Treuhänder' disclaimer
- tax-report tests cover the cost aggregation + zeroed summary when the
  accounting tables are absent; adminCrmAuth test enables the accounting
  master flag the route now requires

fr/nl/pt/ru strings are machine-generated and need native review.
2026-06-11 21:09:13 +02:00
Luca 2e8e4a0f86 feat(accounting): expense invoiced/paid lifecycle + edit-until-invoiced; decouple tax report from bills flag
- transformExpense surfaces invoiced (billed_invoice_id), paid
  (supplier_paid), paidAt, paymentMethod, customerAccountId
- updateExpense throws EXPENSE_LOCKED once invoiced (edit until then)
- rebillExpense mints a client invoice line + locks the expense
- markExpensePaid toggles manual paid state
- adminExpenses: POST /:id/invoice (rebill) + POST /:id/paid
- adminTaxReport now gated by accounting master + taxReport sub-flag
  (independent of bills; tax export moved out of CRM into Accounting)
2026-06-11 17:33:35 +02:00
Luca 31867efcb9 fix(accounting): migration 127 must not insert created_at/updated_at into app_settings
The app_settings table (per its migration schema) has no created_at/updated_at
columns — the canonical seed pattern (migration 103) inserts only
setting_key/setting_value/setting_type. Migration 127 wrongly added timestamps,
so the insert threw `SQLITE_ERROR: table app_settings has no column named
created_at` on every run of the migration suite. That broke the backend test
job (cascading through every suite that builds the schema) and the
Postgres-based fresh-install + schema-drift jobs.

Fix: drop the timestamp columns from the insert, matching migration 103.

Verified: full backend jest suite green (67 suites, 736 passed); migration
harness still green.
2026-06-11 16:57:48 +02:00
Luca 31280e1f7a feat(email): incoming mail UI - IMAP config block + Received emails tab
Frontend for the incoming-mail feature.

- Settings -> Email: an "Incoming mail (IMAP)" block under the outgoing SMTP
  settings (same field shape: host/port/security/user/pass/folder), shown only
  when the incomingMail flag is on (IncomingMailConfigCard, self-contained
  load/save).
- A "Received emails" tab next to "Sent emails" (ReceivedEmailsPanel) listing
  the received_emails log with from/subject/received/status + attachment count
  and a link to the incoming-invoices inbox.
- `incomingMail` flag in the frontend (type + context default, standalone) +
  a Communication-section Features card.
- email.service: getIncomingConfig / updateIncomingConfig / listReceived.
- i18n: settings.features.incomingMail, email.incoming, email.received (EN+DE).

Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
2026-06-11 16:19:55 +02:00
Luca 5645c304ab feat(email): incoming mail (IMAP) intake - backend + standalone flag
Adds a second mail config (incoming/IMAP) alongside the outgoing SMTP one, a
1-minute poller, and a received-emails log. Standalone `incomingMail` feature
flag (default off).

- deps: imapflow + mailparser (receive-side; picpeak only had nodemailer).
- migration 128: email_configs gains imap_* columns (same shape as smtp_*);
  seed incomingMail flag; new received_emails audit table.
- emailIntakeService: polls the mailbox every 60s when the flag is on AND a
  mailbox is configured (no-op otherwise); parses each unseen message
  (mailparser flattens forwarded/nested attachments), drops PDF/JPEG/PNG into
  the incoming-invoices inbox (inbound_documents, source='email'), logs each
  message in received_emails (dedupe by message-id; duplicate attachments
  caught by the existing SHA-256 guard), marks it \Seen.
- adminEmail: GET/POST /incoming-config (mirrors SMTP config, masks imap_pass,
  SSRF host guard) + GET /received (paginated log).
- server.js starts the poller at boot.

Verified: node -c, require-graph, migration-128 harness (imap columns, flag,
received_emails). Frontend (IMAP block under SMTP + Received tab + flag card)
follows.
2026-06-11 15:54:43 +02:00
Luca 81af4453e7 feat(accounting): event booking via dropdown (Company or an event)
Replaces the Company/Event toggle + numeric Event-ID input with a single
EventBookingSelect dropdown (Company = null, else a specific event, fetched via
eventsService). Used by both the incoming-invoice triage and the expense add
form. Projects stay a separate aggregation of events and are intentionally not
a booking target here.

Verified: tsc --noEmit clean; npm run build green.
2026-06-11 15:17:43 +02:00
Luca 2b7495e4dc feat(accounting): Accounting settings tab (km / per-diem rate, require-proof)
New Settings -> Accounting tab (gated by the accounting flag) to edit the km
rate, per-diem rate and the "require proof for expense" toggle (reads GET /
writes PUT /admin/settings/accounting). Rates are CHF, stored as integer minor
units; carries the "verify with your Treuhaender" disclaimer. Wired into
SettingsPage (TabType, keys, flag-gated nav item, render) + the features barrel.

i18n: settings.accounting.* (EN + DE, DE native).
Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
2026-06-11 12:59:36 +02:00
Luca f305541f90 feat(accounting): frontend rework - separate Incoming invoices vs Expenses (stage 2)
Matches the backend split. Incoming invoices and Expenses are now distinct
surfaces with no shared rows.

Incoming invoices (AccountingInboxPage): triage sets the disposition + booking
(event or company) ON the document; "Mark paid" / "Paid" toggle records
supplier payment HERE with the outstanding total shown; re-bill via the
customer picker + markup. PDF preview still rasterised (last page = QR-bill).

Expenses (ExpensesLedgerPage): internal own-costs only. Add form has a Type
dropdown (amount / mileage(km) / per-diem); km/per-diem switch the input to a
quantity + rate (default from accounting settings, per-entry override) with a
live computed amount; optional proof upload (required when the setting says so);
localized category; booked to an event or the company. Proof viewable per row.

Service: reworked to the new endpoints/shapes; categoryLabel() localizes seed
categories (custom stay free-text). i18n: accounting.booking / incoming /
expense / expenseKind / category (EN + DE, DE native).

Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
2026-06-11 12:47:55 +02:00
Luca 5e78fb6475 feat(accounting): backend rework - incoming invoices vs internal expenses (stage 2)
Implements the split decided in review:

Incoming invoices (external) - the inbound_documents row IS the payable:
- categorizeInbound now UPDATES the document (disposition + tax_treatment +
  booking event_id (null=company) + category), no derived expense row, so a
  supplier invoice appears only in the incoming-invoices surface.
- rebillInbound mints the client invoice from the document (base = invoice
  total + markup) and links it on the doc.
- markInboundSupplierPayment records supplier payment ON the incoming invoice
  (mark-paid lives here now).

Expenses (internal) - own costs only:
- createExpense: kind = amount|mileage|per_diem; amount = quantity x rate
  (rate from accounting settings, per-entry override; snapshotted); optional
  proof file; booked to an event or the company; require-proof enforced from
  settings. No supplier payment, always own-cost.
- listExpenses returns internal rows only (inbound_document_id IS NULL).

Routes: per-flag gating (incomingInvoices vs expenses; categories on the
accounting master); supplier-payment + re-bill moved under /inbound/:id/*;
POST/PATCH expenses accept a multipart proof upload; GET /:id/proof streams it
(PDF download-only, image inline). getAccountingSettings reads app_settings.

Verified: node -c, require-graph, 12 unit tests (markup + expense amount/build).
Frontend rework (service + the two UIs + settings tab + category i18n) follows.
2026-06-11 12:38:23 +02:00
Luca c59df52d40 feat(accounting): split Incoming invoices vs Expenses - flags, schema, settings (stage 1)
Foundation for separating external supplier invoices from internal expenses,
per design review. This stage is additive + buildable; the service/route/UI
data rework follows in stage 2.

- Migration 126: incoming invoices own their payable on inbound_documents
  (supplier_paid/at/method/ref + disposition + tax_treatment + booking event_id
  + category_id + re-bill markup/linkage); expenses gain kind (amount/mileage/
  per_diem) + quantity + snapshotted rate_minor. Additive, hasColumn-guarded.
- Migration 127: seed `expenses` feature flag (default off) + accounting
  app_settings (accounting_km_rate_minor=70, accounting_per_diem_rate_minor=0,
  accounting_require_proof=false).
- Backend: `expenses` added to feature-flag known/defaults/dependency (forced
  off when the accounting master is off); new PUT /admin/settings/accounting
  (read via the generic GET /:type).
- Frontend: `expenses` flag (type + context + dependency); Features tab gets an
  Expenses sub-card; the Expenses sub-nav + route now gate on `expenses` (not
  incomingInvoices); AccountingIndex prefers inbox -> expenses -> tax.
- i18n: settings.features.expenses.* (EN + DE).

Verified: node -c; migration 124->126->127 harness (new columns, flag, settings
+ idempotency); en/de JSON valid; npm run build green.
2026-06-11 12:24:53 +02:00
Luca 413a200592 test(accounting): unit tests for expense markup / disposition logic
Covers the silently-regressable money + classification bits of the re-bill
flow (the maintainer's "thin CRM test coverage" concern). Pure functions via a
new expenseService._internal export — no DB, no date-harness pitfalls:

- computeMarkupMinor: percent rounding, flat, none/null.
- resolveMarkup precedence: override > expense clause > none.
- buildExpenseInsert: bad-disposition guard, tax_treatment/status defaults,
  declined -> status+reason, markup field matches type, parked -> status.

11 tests, all green (npx jest expenseService.markup).
2026-06-11 01:04:14 +02:00
Luca 703f72742d feat(accounting): manual "add expense" (no document) on the ledger
Adds an "Add expense" action to the expenses ledger for costs with no inbound
document — mileage, per-diem, a cash receipt, etc.

- accounting.service: createExpense() -> POST /admin/expenses
  (createManualExpense); CategorizePayload gains `description`.
- ExpensesLedgerPage: AddExpenseModal with supplier / description / amount /
  currency / disposition (company expense / pass-through / re-bill — no
  duplicate, there's no document to dedupe). Company-expense picks a category;
  re-bill uses the customer picker + markup and chains createExpense -> rebill
  into an editable scheduled invoice, same as inbox triage. "Add expense"
  button in the filter row.
- i18n: accounting.ledger.{addExpense,addTitle,description,descriptionHint,
  createdToast} (EN + DE); shared field labels reuse accounting.inbox.field.*.

Verified: en/de JSON valid; npm run build green.
2026-06-11 01:01:40 +02:00
Luca e111522415 feat(accounting): rasterise inbound PDFs server-side (never serve raw to browser)
Security hardening for inbound supplier-invoice previews. The admin UI no
longer renders raw PDFs — a malicious inbound PDF could otherwise run embedded
JS or phone home in the admin's session. Instead PDFs are rasterised to flat
PNGs server-side and only those images are shown.

- backend: new rasterizeService shells out to poppler `pdftoppm` (added to the
  Docker image via apk poppler-utils — an OS package, NOT a Node PDF lib, so it
  respects the pdfkit+pdf-lib "no third PDF lib" rule). pdftoppm executes no JS
  and fetches no remote resources, so it doubles as the SSRF/phone-home guard.
  Rendered pages cached under storage/business-docs/inbound/rendered/<id>/.
  - GET /inbound/:id/page/:n streams the rasterised PNG (CSP default-src 'none'
    + nosniff). GET /inbound/:id/file now serves PDFs as a DOWNLOAD only
    (Content-Disposition: attachment) — never inline; images still inline.
- frontend: triage preview switched from a raw-PDF <iframe> to rasterised page
  images (getInboundPageBlob), defaulting to the LAST page (QR-bill) with
  prev/next nav for multi-page PDFs; images stream as before.
- i18n: previewError / prevPage / nextPage / pageOf (EN + DE).

REQUIRES A BACKEND IMAGE REBUILD (Dockerfile adds poppler-utils) — a plain
`docker compose pull` of a stale image won't have pdftoppm; the route then
returns 503 RASTERIZER_UNAVAILABLE and the UI shows "preview unavailable".

Verified: node -c, a pdfkit->pdftoppm rasterise smoke test (renders + caches),
en/de JSON valid, npm run build green.
2026-06-11 00:51:10 +02:00
Luca 0c35ac43e6 feat(accounting): expenses ledger + supplier-payment toggle
Adds Accounting → Expenses, the view of everything triaged out of the inbox:

- ExpensesLedgerPage: filter by status / disposition; each row shows the
  disposition + status badge, CHF amount, created date, and a link to the
  client invoice for re-billed items. Supplier-payment toggle ("Mark paid" ->
  method + date + reference modal; "Paid" -> click to revert) wired to
  /:id/supplier-payment. Payment status is decoupled from categorisation, per
  the locked design; declined/duplicate rows skip the toggle.
- AccountingLayout: "Expenses" sub-nav item (gated by incomingInvoices).
- App.tsx: /admin/accounting/expenses route.
- i18n: accounting.subnav.expenses, accounting.ledger/expenseStatus/
  paymentMethod (EN + DE, DE authored natively).

Verified: en/de JSON valid; npm run build green.
2026-06-11 00:42:46 +02:00
Luca 502fbad5a8 feat(accounting): PDF/image preview in triage, opened at the QR-bill (no OCR)
Instead of OCR, let the admin read the payment slip directly: the triage modal
now embeds the captured document and, for PDFs, opens at the LAST page scrolled
to the Swiss QR-bill area so IBAN/amount/reference are visible while typing.

- backend: capture PDF page count at upload via pdf-lib (new
  inbound_documents.page_count, added to in-flight migration 124); new
  GET /api/admin/expenses/inbound/:id/file streams the stored file inline
  (safePath-guarded, nosniff). Raw-serve is acceptable here (admin views own
  uploads); the hardened rasterise-in-isolated-worker path stays a follow-up.
- frontend: getInboundFileBlob fetches the file with Bearer auth as a blob;
  the triage modal renders it (iframe for PDF with #page=<last>&view=FitH,300,
  <img> for camera photos) in a two-column layout next to the form.
- i18n: accounting.inbox.previewLoading / qrHint (EN + DE).

Verified: node -c, require-graph, migration-124 harness (page_count), npm run
build green.
2026-06-11 00:38:37 +02:00
Luca 2b5efebaff feat(accounting): incoming-invoices inbox with camera capture + triage/re-bill
Adds the Accounting → Incoming invoices frontend on top of the existing
/api/admin/expenses backend:

- accounting.service.ts: typed client (inbound upload/list/get/update/
  categorize, expense list, re-bill, supplier-payment, categories).
- AccountingInboxPage: capture a supplier invoice via the device CAMERA
  (<input accept="image/*" capture="environment">) or a PDF/image upload;
  inbox list with status badges + parsed summary; a triage modal to confirm
  fields and pick a disposition (re-bill / pass-through / company expense /
  duplicate / declined). Re-bill uses the customer picker and mints an
  editable scheduled invoice (chains categorize -> rebill).
- AccountingLayout: "Incoming invoices" sub-nav item + AccountingIndex that
  redirects /admin/accounting to the first enabled sub-feature.
- App.tsx: /admin/accounting/inbox route (gated by incomingInvoices).
- i18n: accounting.inbox/disposition/markup + subnav.incomingInvoices +
  common.saving (EN + DE, DE authored natively).

Camera capture needs no native app — the mobile web input drives the device
camera straight into the upload endpoint. OCR/QR auto-extraction is still a
backend follow-up (extractionService is a no-op), so fields are confirmed
manually in the triage modal for now.

Verified: npm run build green; en/de JSON valid.
2026-06-11 00:31:05 +02:00
Luca 2c351bf0c9 refactor(accounting): make Accounting a master flag with sub-toggles
Replaces the earlier peer-`accounting` flag (which only *conditionally*
relocated Tax) with a cleaner top-level master + sub-toggle model, per design
discussion:

- `accounting` = explicit top-level MASTER (Settings -> Features). Off hides
  the whole Accounting section.
- Sub-toggles, gated under the master:
  - `taxReport` ("Tax export") moves PERMANENTLY out of CRM. Removed from the
    Clients sub-nav and from the derived `clients` flag. Now INDEPENDENT of
    Bills (per decision). Old /admin/clients/tax-report -> redirect to
    /admin/accounting/tax-report.
  - `incomingInvoices` (new) gates the supplier-invoice capture / expenses /
    re-bill feature; the /api/admin/expenses router now checks it.
- Dependency rules (backend + frontend): accounting off forces taxReport +
  incomingInvoices off; taxReport dropped from the clients derivation; the
  bills->taxReport rule removed.
- Preserve visuals: migration 122 rewritten to auto-enable `accounting` on
  installs that already had Tax on (so the tab doesn't vanish), and to seed
  `incomingInvoices` off. Verified with a SQLite harness (taxReport on ->
  accounting on; off -> off).
- Settings -> Features: new "Accounting" section with the master card + Tax
  export + Incoming invoices sub-cards (disabled until the master is on).
- i18n: navigation.accounting, accounting.*, settings.features.{accounting,
  incomingInvoices,taxReport.requiresAccounting}, sections.accounting (EN + DE,
  DE authored natively); Tax report relabelled "Tax export"/"Steuerexport".

Verified: node -c, migration-122 harness, en/de JSON valid, npm run build green.
2026-06-11 00:17:44 +02:00
Luca 30c0007f40 feat(accounting): Accounting nav section + relocate Tax report out of CRM
Adds the `accounting` feature flag to the frontend (type, context default) and
a Settings -> Features toggle card. When enabled:

- A new top-level "Accounting" sidebar entry appears (gated by `accounting` +
  accounting.view), with an AccountingLayout sub-nav mirroring ClientsLayout.
- The Tax report relocates: it is HIDDEN from the CRM (Clients) sub-nav and
  shown under Accounting instead, at /admin/accounting/tax-report. When
  accounting is OFF, Tax stays under CRM exactly as before.

Tax visibility still depends on `taxReport` (which depends on `bills`), so the
relocation only changes WHERE the menu item lives, not whether it exists.

Files: featureFlags.service.ts (+'accounting'), FeatureFlagsContext default,
AdminSidebar entry, new AccountingLayout, ClientsLayout filter, App.tsx route,
FeaturesTab card, en/de i18n (navigation.accounting, accounting.*,
settings.features.accounting; DE authored natively).

Verified: `npm run build` green; en/de JSON valid.
2026-06-11 00:04:40 +02:00
Luca c305492845 feat(accounting): inbound supplier-invoice capture + expense re-bill (backend)
New top-level Accounting area (gated by an `accounting` feature flag, default
OFF, + accounting.view/manage permissions), separate from CRM. Lets an admin
capture a received supplier invoice (upload OR phone/tablet camera), give it a
disposition, and re-bill the cost to a client onto the relevant event's
invoice with a contract-driven markup. Mirrors the billable-hours model.

Backend foundation only — frontend pages (inbox / expenses UI + camera widget)
and the heavy extractors (Tesseract OCR / Swiss-QR decode / isolated rasterise
worker) are follow-ups; extractionService is scaffolded so the upload path is
already wired.

Migrations 122-125 (numbered above the in-flight feat/crm 117-121):
- 122 seed `accounting` flag (default OFF, idempotent)
- 123 seed accounting.view/manage permissions + grant super_admin/admin
- 124 inbound_documents + expenses + expense_categories (+ seed categories)
- 125 contracts Spesen-Zuschlag clause (expense_markup_type/_percent/_flat_minor)

API: /api/admin/expenses — inbound capture/list/confirm/categorize, expense
CRUD, /:id/rebill (event-scoped; markup = expense override -> contract clause
-> 0%; mints an editable scheduled invoice), /:id/supplier-payment, categories.
adminFeatureFlags KNOWN_FLAGS/DEFAULT_FLAGS gain `accounting`.

Conventions: idempotent hasTable/hasColumn-guarded migrations; money in integer
*_minor; QR amount stored separately + untrusted; requirePermission guards;
camelCase API <-> snake_case columns; multer + 15MB cap for PDF/JPEG/PNG.
VAT/tax handling is v1 capture-only — verify with a Treuhaender before relying.

Verified: node -c all files, require-graph smoke test, and a SQLite migration
harness (schema + seeds + idempotency + defaults assert green).
2026-06-11 00:04:16 +02:00
Paul Nothaft c1c5ac726c Merge pull request #615 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.6-beta.0
2026-06-10 18:43:48 +02:00
github-actions[bot] 3aaddaf210 chore(beta): release 3.60.6-beta.0 2026-06-10 16:38:01 +00:00
Paul Nothaft 40a4aa2d85 Merge pull request #614 from the-luap/fix/guest-upload-limits-613
fix(gallery): guest upload honours general_max_files_per_upload + i18n placeholder interpolates (#613)
2026-06-10 18:37:30 +02:00
Paul Nothaft 69b5186582 fix(gallery): guest upload honours general_max_files_per_upload + i18n placeholder interpolates (#613)
Zszywany reported on v3.44.0 (Ubuntu, Postgres 15): with Settings →
General → "Max Files per Upload" set to 10, an event configured with
allow_user_uploads + a guest selecting 16 files in the gallery's
"Upload Photos" modal succeeded silently — admin uploads to the same
event correctly refused with "Upload limit reached". On top of that,
the gallery modal's "fileRequirements" hint literally rendered
`{{limit}}` instead of the configured number.

Two separate misses for the guest path, both fixed here:

1. **Backend enforcement** — `backend/src/routes/gallery.js:1641` had
   `limits: { fileSize: 50MB, files: 10 }` and `.array('photos', 10)`
   hardcoded. The admin path at adminPhotos.js:131 has always resolved
   files-per-batch via `getMaxFilesPerUpload()` (cached 60s read of
   `general_max_files_per_upload`); guest path just never used it.
   Mirror the admin: `const maxFilesPerUpload = await getMaxFilesPerUpload()`
   and feed multer both `limits.files` AND the `.array(...)` cap. The
   50MB per-file size is a separate concern from this issue and stays
   as-is for now.

2. **i18n interpolation missing on the guest modal** —
   `UserPhotoUpload.tsx:203` called `t('upload.fileRequirements')` with
   no arguments. The translation string at `en.json:160` is
   "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)"
   — `{{limit}}` is unbound, so i18next emits it literally. The admin
   variant `PhotoUpload.tsx:414` correctly passes
   `{ limit: maxFilesPerUpload }`.

   Also wired up the same client-side count guard the admin component
   uses: addFiles refuses additions past the limit (`upload.limitReached`)
   and warns on partial-truncate (`upload.someFilesSkipped`). Backend
   enforces too, but the client guard saves a 4MB+ multipart POST when
   the user is clearly over.

To surface the setting on the guest side, `general_max_files_per_upload`
joins the public-settings whitelist + projection (publicSettings.js)
and the `PublicSettings` TS interface gets the new field. Default
fallback (500, matching `uploadSettings.js` DEFAULT_MAX_FILES_PER_UPLOAD)
in both backend projection and frontend reader so an install that's
never set the value renders a sensible number rather than "undefined".
2026-06-10 18:22:34 +02:00
Paul Nothaft 1d03a670e4 Merge pull request #612 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.5-beta.0
2026-06-09 18:11:43 +02:00
github-actions[bot] a3e5ba4e75 chore(beta): release 3.60.5-beta.0 2026-06-09 16:11:18 +00:00
Paul Nothaft 284680e035 Merge pull request #611 from the-luap/fix/delete-cascade-orphan-folders
fix(admin/events): delete cascade orphaned photo folders because it read a non-existent column (#608)
2026-06-09 18:10:52 +02:00
Paul Nothaft 457c956386 fix(admin/events): delete cascade orphaned photo folders because it read a non-existent column (#608)
jodrmx reported on v3.44.0 (Pi Lite, Docker compose): admin-UI event
delete removes the DB row but leaves `storage/events/active/<event>/`
intact on disk.

Root cause: `deleteEventCascade` in adminEvents.js read
`event.folder_path` and gated the `fs.rm` on it. That column is NEVER
WRITTEN anywhere in the codebase — grep confirms two reads in this one
function, zero writes elsewhere. So `event.folder_path` was always
undefined, `if (event.folder_path)` always false, and the per-folder
cleanup silently no-op'd for every delete. The DB-cascade transaction
ran fine, so the symptom was always "row gone, files stay" — exactly
what jodrmx hit.

The actual on-disk location is `events/active/{slug}` everywhere else
in the codebase:
  - adminPhotos.js:260 — `path.posix.join('events/active', event.slug)`
  - adminEvents.js:610, events.js:155, adminThumbnails.js:153 — read
    from `events/active/{slug}`
  - adminArchives.js:171 — reads from same root
  - photoResolver.js:14-15 — documents the layout
The delete cascade was the only path looking at the non-existent column.

Cure: drop the `if (event.folder_path)` guard, read `event.slug`
instead, and remove from both `events/active/{slug}` (active gallery
folder) and `events/archived/{slug}` (the post-archive copy that
survives the archive flow). `event.slug` is NOT NULL and slugify-
sanitized (lower-case ASCII + dashes only via utils/slug.js), so the
path is well-formed and path-traversal-safe. Best-effort `fs.rm`
semantics + try/catch unchanged — failures still log a warning rather
than unwinding the DB transaction, since orphan files are recoverable
noise compared to a half-deleted DB row.

Forward fix only — does not retroactively clean up the orphans that
have accumulated on existing installs. Admins can `rm -rf
storage/events/active/<old-slug>` manually for those; not worth a
migration script for a one-time deploy ritual.
2026-06-08 23:01:32 +02:00
Paul Nothaft fe0d369836 Merge pull request #610 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.4-beta.0
2026-06-08 22:53:54 +02:00
github-actions[bot] 6ccac348ac chore(beta): release 3.60.4-beta.0 2026-06-08 20:52:58 +00:00
Paul Nothaft fcd3ca36c6 Merge pull request #609 from the-luap/fix/admin-header-img-fallback-perm-skeleton
fix(admin): logo-img fallback + sidebar perm hydration + filename NFD transliteration (#523 follow-up 2, #607)
2026-06-08 22:52:37 +02:00
Paul Nothaft 620163f2db fix(downloads): transliterate accented characters in filename via NFD instead of dropping them (#607)
patchingfailed reported on v3.44 stable: a gallery named `Ägypten` with
photo `Ägypten_individual_0050.jpg` downloads as `gypten_...` —
the leading umlaut is dropped entirely. Their hypothesis was a
Content-Disposition encoding issue, but the actual root cause sits
one layer earlier: at UPLOAD time when `generatePhotoFilename` calls
`sanitizeFilename`.

`sanitizeFilename` did:
  String(str).trim()
    .replace(/\s+/g, '_')
    .replace(/[^a-zA-Z0-9_\-\.]/g, '')   // ← drops `Ä` outright
    .replace(/[_\-]{2,}/g, '_')
    .replace(/^[_\-]+|[_\-]+$/g, '');    // ← would strip a leading _ too

For `Ägypten`: alphanumeric-strip → `gypten` (Ä gone, no underscore
left behind because the regex used '' as the replacement, not '_'). The
result is stored in `photos.filename` and that's what downloads serve.
By that point `buildContentDisposition` is doing the right thing
(emits both `filename="..."` ASCII fallback AND RFC 5987
`filename*=UTF-8''…` — Chrome correctly picks the UTF-8 form), but the
string it's encoding has already lost the umlaut at the DB layer.

Cure: NFD-normalize + strip combining marks BEFORE the alphanumeric
strip. Same pipeline `utils/slug.js` (#525) already uses for URL
slugs:

  sanitized = sanitized
    .normalize('NFD')
    .replace(/[̀-ͯ]/g, '');

Now `Ägypten` → NFD-decomposed `A` + combining diaeresis → strip
combining mark → `Agypten` survives the alphanumeric pass. Filename
and URL slug stay in sync (the URL was already `Agypten`, per
patchingfailed's report — the filename now matches).

Test surface: new `filenameSanitizer.test.js` pins:
- the headline #607 contract for German / Portuguese / French / Spanish
  accented inputs (with a counter-example using the pre-fix pipeline so
  a future edit can't quietly regress it)
- ASCII-input parity — pre-#607 byte-identical output for every
  pre-existing ASCII case
- `generatePhotoFilename` composed round-trip
- `sanitizeForContentDisposition` + `buildContentDisposition` RFC 6266
  dual-form output (since the helper sits next to this function and is
  the next thing to break if a refactor goes sideways)
- `sanitizeForZipEntry` path-traversal blocking

31 cases total, all pass.

Bundled into PR #609 since it's a small targeted fix and that PR is
already an admin-UI polish branch with low review weight.
2026-06-08 18:05:46 +02:00
Paul Nothaft f51b9cf8df fix(admin): graceful logo-img fallback + show sidebar widgets during perm hydration (#523 follow-up 2)
Two issues Rekoo-PS hit immediately after upgrading to v3.60.3-beta.0:

1. **Broken logo URL rendered the browser's broken-image icon + alt
   text.** Their `<img src={resolvedLogoUrl}>` had no `onError` handler,
   so a 404 / slow logo URL produced the default broken-image rendering
   — which uses the `alt` attribute (`companyName`) as text. Visually it
   looked like the wordmark span had unexpectedly re-appeared on phone,
   even though the actual `<span>` was correctly hidden by the existing
   `wordmarkVisibilityClass` logic.

   Fix:
   - `useState` tracks `logoLoadError` (first failure) and
     `fallbackLoadError` (second failure). On a configured-URL miss the
     `<img>` swaps to the bundled `/picpeak-kamera-transparent.png`; on
     a second miss the `<img>` is removed from the DOM entirely.
   - `useEffect([resolvedLogoUrl])` resets both flags when the URL
     changes, so a dark-mode toggle that flips `lightLogo ↔ darkLogo`
     gets a fresh attempt instead of being permanently sad.
   - `wordmarkVisibilityClass` now derives from `logoEffectivelyVisible`
     (showLogo && !fallbackLoadError) — when both the configured URL
     AND the bundled fallback have failed, the wordmark un-hides on <sm
     so the phone header isn't completely empty.

2. **Sidebar VersionInfo + StorageInfo vanished during the
   permission-hydration window.** The bottom block was gated on
   `hasPermission('settings.view')` directly, which returns `false`
   while `PermissionsContext.isLoading` is still resolving (a few
   hundred ms right after a deploy when the auth context bootstraps).
   Net effect: the whole "Version / Storage" block was absent on first
   paint, then re-appeared once permissions hydrated — Rekoo-PS read
   that flash as "backend version + storage missing".

   Fix: gate on `permissionsLoading || hasPermission('settings.view')`.
   Optimistic render during hydration; permitted users see the widgets
   immediately (with each widget's own internal loading state), denied
   users still see nothing once the permission state lands as `false`.

Side benefit: the `<img>` fallback chain also covers the broader "logo
hosted on a flaky CDN" case for self-hosters, not just the one-time
post-upgrade asset-cache hiccup. Pure resilience polish — no behaviour
change when everything works.
2026-06-08 17:38:01 +02:00
Luca a702f33004 feat(projects): linking a quote/contract cascades the whole deal into the project
linkDealToProject(dealUuid, projectId): links every quote + contract sharing
the deal_uuid, re-points the events the deal converted into (so their
invoices/emails/gallery roll up), and adopts the deal's customer onto an
empty project. Invoked from the assign endpoints AND the quote/contract
editors' project picker (create + update). Drop a quote on an empty project
and its linked contract, event and invoices populate the cockpit automatically.

Verified on a booted DB: assignQuote on an empty project propagates project_id
to the contract + event, adopts the customer, and the overview rolls up all
four document types.
2026-06-07 01:03:27 +02:00
Luca 89bfb6c519 fix(projects): scope 'book to project' to the current customer
A project has at most one customer, so the hours picker shouldn't offer
other customers' projects. HoursSection now passes customerAccountId to
ProjectSelect (shows this customer's projects + still-unassigned ones).
Backend createEntry rejects a projectId owned by a different customer
(422 PROJECT_CUSTOMER_MISMATCH) as defence-in-depth behind the picker.
2026-06-07 00:58:44 +02:00
Luca 0cc52f3693 fix(projects): wrap long URLs in email preview (no horizontal scroll)
A long unbreakable token (e.g. the gallery link) overflowed the email
container and forced the admin to side-scroll. The preview HTML prep now
also injects overflow-wrap:break-word so long words/URLs wrap within the
container. break-word only triggers on overflow, so table layout is
unaffected. (Renamed neutralizeLinks → preparePreviewHtml.)
2026-06-07 00:21:26 +02:00
Luca f71243e388 fix(projects): use real events.edit permission for project writes
The project create/update/assign routes required 'events.manage', which is
not a real permission (the event perms are view/create/edit/delete/archive).
Since it's absent from the permissions table, even super_admin's all-perms
set excluded it, so every write 403'd with 'Insufficient permissions'.
Switched the write routes to the existing 'events.edit'. (Reads keep
events.view; cockpit doc gating + email actions already use real keys.)
2026-06-07 00:10:07 +02:00
Luca fa622cf2f8 fix(projects): make email preview fully read-only (no clickable links)
Inside the preview iframe the Accept button navigated but other links didn't
— inconsistent, and worse, clicking Accept/Decline would hit the live action
URLs and change the quote state. Sandbox the iframe (no popups/scripts/forms)
and force all anchors to target=_blank so every link is inert. Now nothing in
the preview is clickable (consistent + safe); scrolling and brand colors are
unaffected.
2026-06-06 23:45:54 +02:00
Luca b9a9c018c0 fix(projects): render email preview with its own brand colors, not forced light
The email wrapper already sets body/container/text backgrounds from the
brand email-theme settings (email_body_bg_color etc.), so a dark preview is
the configured design — forcing it light was wrong. Render the email as-is;
set the iframe color-scheme to 'normal' only so the admin's dark app theme
doesn't leak into the iframe's UA defaults. The brand's light/dark choice is
respected.
2026-06-06 23:25:29 +02:00
Luca 84b4a5f049 fix(projects): make the whole email row clickable (opens preview)
Document rows navigate on click, but email rows only had clickable action
buttons — the row itself was dead, which read as inconsistent. The whole
email row now opens the preview; the action buttons stopPropagation so
Resend/Cancel/Retry/Send-now still fire without also opening the preview.
Every actionable feed row is now uniformly clickable.
2026-06-06 23:17:29 +02:00
Luca 2369323259 fix(projects): only link cockpit rows when the target feature is enabled
The cockpit surfaces quotes/invoices/contracts by PERMISSION, but their
detail routes are gated by feature FLAG (RequireFeature). With those flags
off, clicking a row navigated to a route that redirects to /admin/dashboard
— so links 'did nothing' while the email action buttons (plain API calls)
worked. hrefFor now returns null when the destination flag is off, so the
row renders as non-clickable text instead of a dead link. Galleries/events
are never flag-gated, so they always link.
2026-06-06 23:05:49 +02:00
Luca 94f2c01590 feat(projects): flag re-rendered emails in the feed
Each email in the rollup now carries a 'stored' flag (rendered_html present).
Emails without an exact stored copy show an amber '≈ re-rendered' tag next to
Preview, so it's visible at a glance — not just inside the modal. en + de.
2026-06-06 22:50:03 +02:00
Luca f02fba6332 fix(projects): scope email rollup to CRM types + re-render unstored previews
- The customer address often doubles as the admin notification target, so
  matching emails purely by recipient swept in system alerts (backup_failed,
  restore_failed, …). The recipient match is now restricted to CRM document
  types (quote_/contract_/invoice_/storno_); event-scoped mails still match
  by event_id.
- getEmailPreview now falls back to renderQueuedEmail() — re-rendering from
  the current template + the row's stored email_data — for emails sent before
  rendered_html capture, flagged exact:false with an amber 're-rendered' note.
  Only a missing template / no variables falls through to 'nothing stored'.

Known limitation: a customer with multiple projects sees their event_id=null
CRM mails under each (email_queue has no project_id).
2026-06-06 22:47:43 +02:00
Luca c0b6d14d08 fix(projects): clickable milestones/feed, email rollup by customer, PG amount coercion
- Milestones + feed rows now link to the document (quote/contract/bill
  detail, event for galleries); hours have no page so stay non-clickable.
- Email rollup also matches the project customer's address — quote/invoice/
  contract mails are queued with event_id=null, so the by-event scope alone
  showed none (hence 'no email preview'). Now they appear with preview.
- Feed amounts coerce total_amount_minor with Number(): Postgres returns
  bigint as a string, which formatMoneyMinor's Number.isFinite check
  rejected and rendered as CHF 0.00. (computeValuation already coerced.)
2026-06-06 22:28:52 +02:00
Luca 7ca243780a feat(projects): rolled-up project value (newest stage wins per deal, cumulative)
- computeValuation helper: per deal_uuid, the invoice total (installments
  summed, storno netted) wins over the quote; contracts carry no total so
  never contribute. Summed across the project's events, split by currency.
- Value column on the Project Overview list + a value/paid block in the
  cockpit header. Both gated by bills.view/quotes.view so no figure leaks.
- listProjects computes all values in two bulk queries (not per-project).
- en + de i18n; six unit assertions cover the rule's edge cases.
2026-06-06 13:48:46 +02:00
Luca dffcf6269f feat(projects): attach-event control in the cockpit
Search any event by name and attach it to the project (re-points
events.project_id via assignEvent). Lists the project's current events
above the search. en + de i18n. Completes event grouping UX — admins
can now regroup the auto-created per-event projects however they like.
2026-06-06 13:27:43 +02:00
Luca 81553aa0e3 feat(projects): Project Overview cockpit UI + CRM nav entry
- ProjectsListPage: searchable list + inline create, under CRM → Overview.
- ProjectCockpitPage: editable header, milestone timeline, and one dated
  feed merging emails (with sent-HTML preview modal + resend/cancel/retry/
  send-now actions), quotes, contracts, invoices, galleries and hours.
- Routes /admin/clients/projects(/:id) gated by RequireFeature flag=projects.
- ClientsLayout 'Overview' nav entry (top), gated on flags.projects.
- en + de i18n for the projects namespace + book-to-project label.
2026-06-06 13:25:38 +02:00
Luca 0175007abc feat(projects): gated project pickers on quote/contract/hours editors
- ProjectSelect: a reusable picker that renders nothing when the projects
  flag is off (satisfies 'book to project hidden unless projects enabled').
- projects.service.ts: full frontend API client (list/get/create/update,
  overview, assign event/quote/contract, email preview + 4 actions).
- Quote + contract editors carry an optional projectId (state, prefill,
  payload); service payload/detail types updated.
- HoursSection gains a 'book to project' control; backend createEntry
  persists project_id (migration 118, hasColumnCached guarded).
2026-06-06 13:20:59 +02:00
Luca 6420047e7c feat(projects): link quotes & contracts to a project (precise cockpit rollup)
- Migration 121 adds quotes.project_id + contracts.project_id (nullable FK,
  index) and backfills the unambiguous single-project-per-customer case.
- projectService rolls quotes/contracts up by project_id, with a
  customer-based fallback on pre-121 DBs (hasColumnCached guarded).
- quote/contract create+update accept an optional projectId; detail
  transforms surface it for editor prefill.
- POST /projects/:id/quotes and /:id/contracts assign endpoints.
2026-06-06 13:05:36 +02:00
Luca 1bf0b34ea5 feat(projects): gate Project Overview behind a projects feature flag + cockpit email actions
- Migration 120 seeds the projects flag (default OFF), idempotent.
- Backend feature-flags whitelist + DEFAULT_FLAGS + clients derivation.
- adminProjects routes 403 PROJECTS_DISABLED when the flag is off.
- projectService email actions (resend/cancel/retry/send-now) + routes.
- Frontend flag type, DEFAULT_FLAGS, Features tab card (en+de).
2026-06-06 12:59:57 +02:00
Luca 874c91f944 feat(crm): Project Overview phase 3 — persist sent email HTML
processEmailQueue now stores the actual rendered HTML in email_queue
.rendered_html on a successful send (sendTemplateEmail returns it). Guarded
by hasColumnCached so installs without migration 119 just skip it; never
blocks the send. Powers the cockpit's exact-sent email preview.
2026-06-06 04:00:16 +02:00
Luca eb263137b9 feat(crm): Project Overview phase 2 — project service + routes
Backend API for the cockpit (admin-only, Model A):
- projectService: list/get/create/update, assignEvent (re-point events.project_id),
  getProjectOverview (rollup — invoices/emails/gallery by event, quotes/contracts
  by customer since they carry no event_id, hours by project_id, + a milestone
  timeline), getEmailPreview (actual sent HTML).
- adminProjects routes (/api/admin/projects): read=events.view, write=events.manage;
  the overview gates each money-doc type on the admin's own bills/quotes/contracts
  .view permission. Registered in server.js.
All aggregation queries verified against the real schema on a temp DB.
2026-06-06 03:50:55 +02:00
Luca efa47d697d feat(crm): Project Overview phase 1 — projects schema
Data model for the admin-only Project Overview cockpit (Model A — projects
group events; money docs stay per-event and roll up).
- migration 117: projects table (name, customer_account_id nullable, status)
  + events.project_id FK; backfill one auto-project per existing event (1:1
  default, customer = the event's single assignment when unambiguous), admins
  relink freely afterward. 1 project : N events.
- migration 118: customer_hour_entries.project_id (book hours to a project).
- migration 119: email_queue.rendered_html (store actual sent HTML for the
  cockpit's email preview).
All idempotent (hasTable/hasColumn guards), reversible downs. Verified: full
migration boot + backfill on a temp DB.
2026-06-06 03:06:21 +02:00
Luca 43b10f91c0 fix(crm): localize scheduled-send + installment date + timezone picker
From dev testing:
- BillEditor 'Geplanter Versand' was a native <input type=datetime-local> →
  rendered US date + 12h regardless of settings. Split into LocalizedDateInput
  + TimeField (honour general_date_format + general_time_format), recombined
  into the YYYY-MM-DDTHH:MM the payload/scheduler expect.
- InstallmentsPanel 'Send on' native <input type=date> (browser-locale via a
  lang hint, wrong in Safari/Firefox) → LocalizedDateInput, consistent in every
  browser. (Luca approved converting it.)
- Business-profile Timezone was a free-text input → dropdown of the full IANA
  list (Intl.supportedValuesOf, CH/LI fallback), blank = system default.
2026-06-06 02:22:46 +02:00
Luca ea09a86d05 fix(crm): recent-activity email placeholder + customer-dashboard locale dates
- Recent Activity rendered literal {{email}} — the per-row t() call didn't
  pass the email interpolation var. Source it like formatActivityMessage
  (metadata.email ?? actorName).
- Customer 'Deine Galerien' dates rendered en-US ('May','Jun') under a German
  UI because they used raw date-fns format(parseISO(iso),'PP') with no locale.
  Route through useLocalizedDate().format → honours general_date_format + the
  active language.
2026-06-06 00:42:08 +02:00
Luca a2b2d3fb31 fix(crm): PR #603 review follow-ups + Outlook-proof email design
Addresses the maintainer's non-blocking review items + the Outlook email bug:
- invoice create: verify the chosen event belongs to the customer (only when
  the event has assignments; legacy unassigned events pass through).
- mark-paid + import: bound paidAt to [2000-01-01, now+30d] so a typo'd year
  can't silently drop a payment out of every cash-basis revenue window.
- customer routes: country_code now {min:2,max:2}+isAlpha+uppercase-normalize
  (was isString/max:2 — allowed '', '1', '!@'), matching the business-profile
  route.
- email transporter: close the previous instance before re-init (leak guard
  for a future pooled transport).
- scheduled-email tz: warn loudly when business_hours is set but the profile
  timezone is blank (was silently using the server/UTC tz).
- wrapEmailHtml: rebuild the chrome as inline-styled tables + bgcolor and
  inline the themed CTA button, so the design survives Outlook/Apple Mail
  stripping the head <style> (kept the <style> as progressive enhancement).
2026-06-06 00:42:08 +02:00
Paul Nothaft b7fc86deef Merge pull request #606 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.3-beta.0
2026-06-04 22:10:15 +02:00
github-actions[bot] 13b3a4cfa1 chore(beta): release 3.60.3-beta.0 2026-06-04 20:09:37 +00:00
Paul Nothaft ea074d3102 Merge pull request #603 from Luca-Timo/feat/crm-improvements
CRM improvements: invoicing & payments, hours, email queue/scheduling, branding (dark mode + favicon), country pickers
2026-06-04 22:09:08 +02:00
Luca 1214b6b762 fix(security): re-apply SVG CSP on the direct favicon route (PR #603 blocker)
The /favicon.ico + /apple-touch-icon routes stream the file directly,
bypassing the secureStatic middleware that locks down served SVGs. An
admin-uploaded SVG favicon with <script> would then run at the top-level
origin (stored XSS). Re-apply the same CSP (default-src 'none') + nosniff
for .svg here, mirroring secureStatic.js. Reported in the #603 review.
2026-06-04 21:49:27 +02:00
Paul Nothaft 859a82a048 Merge pull request #605 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.2-beta.0
2026-06-04 21:42:30 +02:00
github-actions[bot] 6ee30a357d chore(beta): release 3.60.2-beta.0 2026-06-04 19:40:36 +00:00
Paul Nothaft b48b5b0000 Merge pull request #604 from the-luap/fix/header-skeleton-lang-in-profile
fix(admin-header): skeleton brand block + move LanguageSelector into profile menu on <sm (#523 follow-up)
2026-06-04 21:40:04 +02:00
Paul Nothaft fe10191b82 fix(admin-header): skeleton brand block + move LanguageSelector into profile menu on <sm (#523 follow-up)
Two complaints in Rekoo-PS's 3.60.1-beta.0 follow-up screenshots:

1. "Logo took some time to load" — header appeared empty for the
   ~hundreds-of-ms window between admin mount and `usePublicSettings()`
   resolving. The previous code rendered the static fallback
   `/picpeak-kamera-transparent.png` during that window, which often
   either 404'd or loaded after the rest of the chrome, and because the
   wordmark is `hidden sm:inline` whenever a logo is intended to be
   shown, phone-width admins saw an empty left cluster instead of
   anything.

   Cure: render a small pulsing skeleton block (h-8 w-8 on <sm, w-32
   on sm+) while `brandingLoading === true`. Same h-8 footprint as the
   real logo image so there's no layout shift when the real payload
   arrives. Once the public-settings query settles, the normal brand
   block renders against known state.

2. "Moving the languages inside the profile tab" — Rekoo-PS argues
   language is set-once and shouldn't occupy permanent header real
   estate on mobile (4 widgets in the right cluster on phone is
   crowded). I agree.

   On <sm: header LanguageSelector is hidden (`hidden sm:block` wrapper
   around the existing component). A collapsible Language section is
   added at the top of the user-menu dropdown showing the current
   flag/name + chevron-down. Expanding shows the 8 supported languages
   as inline rows highlighting the active one. Picking a language fires
   i18n.changeLanguage and closes the menu.

   On sm+: header LanguageSelector stays where it was. The user-menu
   Language section is suppressed (`sm:hidden`) so the same control
   isn't surfaced twice.

Also: `useOnClickOutside(userMenuRef, …)` and the in-menu action
handlers now route through a shared `closeUserMenu()` helper that
also resets the lang sub-section state, so re-opening the menu
doesn't surprise the user with the language list still expanded.
`SUPPORTED_LANGUAGES` re-exported from `components/common` so
AdminHeader doesn't reach into `LanguageSelector.tsx` directly.

No behaviour change on `sm+` — pure phone-view layout fix +
loading-state polish. Locales unaffected (uses the already-existing
language names from SUPPORTED_LANGUAGES).
2026-06-04 21:18:51 +02:00
Luca fe56d24a6b fix(crm): country dropdown on customer profile billing address too
Mirror the onboarding fix on the customer profile (Rechnungsadresse): replace
the free-text 2-char Country input with the CountrySelect dropdown and move it
below State/region. Grid reflowed: Postal+City row, then State+Country row.
2026-06-03 21:13:09 +02:00
Luca 18ffff29c3 fix(crm): country dropdown on customer onboarding, placed after State/region
The accept-invite (onboarding) address form used a free-text 2-char Country
input sitting above State/region. Replace it with the CountrySelect dropdown
(same component as the admin customer + business-profile forms) and move it
below State/region. Grid reflowed: Postal+City row, then State+Country row.
2026-06-03 21:07:59 +02:00
Luca 47edbf64b5 fix(email): surface the real error on test/save/flush instead of generic toast
The test-email, save-config, and flush mutations all showed the generic
'Failed to save changes' toast on error, hiding the actual backend reason —
so a failing test email looked like a save failure and gave no diagnosis.
Show response.data.error / .details (SMTP auth/connection failure, masked
password, private-host rejection, …) with the generic string as fallback.
2026-06-03 19:56:26 +02:00
Luca 68c967f9bb fix(email): recover stuck queue — reinit transporter on config save + manual flush ignores retry cap
Two gaps left emails stuck 'pending' after (re)configuring SMTP:

1. Saving the email config never re-initialised the transporter. The queue
   processor only re-inits when its cached transporter is null, so a changed
   SMTP account had no effect until a backend restart. Now call
   initializeTransporter(true) after save (it self-catches; invalid config
   just leaves it null, surfaced via the Test-email button).

2. The manual 'send now' flush (ignoreSchedule) still enforced retry_count<3,
   so emails that failed 3× while SMTP was broken could never be retried from
   the UI. Move the retry-cap (and schedule gate) to automatic runs only;
   a manual flush forces a retry of every pending email.
2026-06-03 19:40:36 +02:00
Luca a2b5ae17f3 fix(crm): drop redundant 'Country (full name)' field
The customer detail + business profile forms showed both a Country picker
(stores the ISO code) and a free-text 'Country (full name)' override
(migration 107). Now that the picker offers the full ISO list and the PDF
renderer derives the localized full name from the code (pdfService.countryName,
used as 'country_name || derive' for both issuer and recipient), the free-text
field is redundant. Remove the input from both forms. The DB column + the
fallback stay, so any legacy override still renders.
2026-06-03 19:28:47 +02:00
Luca c60e34ecae fix(branding): point HTML favicon link at /favicon.ico (the real Safari fix)
index.html hardcoded <link rel=icon href=/favicon-32x32.png>. When the HTML
declares a favicon link, the browser uses it and NEVER requests /favicon.ico
— so Safari showed the bundled default and our dynamic backend route was
never hit (direct /favicon.ico was correct, but the tab wasn't). DynamicFavicon's
JS swap is exactly what Safari ignores.

Point the link at /favicon.ico (backend dynamic route) + add apple-touch-icon,
no type/sizes so the response content-type wins. Now the configured favicon
shows from first paint in every browser, Safari included.
2026-06-03 19:01:55 +02:00
Luca 7ccfdc1aea fix(branding): stream favicon bytes directly (Safari ignores the 302)
The /favicon.ico route 302-redirected to the uploaded file. Firefox/Chrome
follow that, but Safari does NOT reliably follow a redirect for favicon
requests — it falls back to the HTML <link>, i.e. the bundled picpeak
default. Stream the file bytes directly for local /uploads favicons (with a
path-containment guard); only external URLs and the missing-favicon fallback
still redirect. sendFile sets the content-type from the extension.
2026-06-03 18:35:46 +02:00
Luca 82ec23824a feat(crm): cash-basis revenue + backdatable payment date on mark-paid
Per decision: keep dashboard revenue windows on pure cash basis (recognise
by paid_at for ALL invoices) and give the admin control over paid_at.

- adminDashboard: revert the imported-vs-native split; winSum is paid_at >=
  cutoff for every paid invoice again (clean cash basis).
- BillDetailPage mark-paid dialog: add an optional 'Payment date' field
  (LocalizedDateInput, defaults to today) so a payment can be backdated to
  when it actually arrived. Backend already accepted paidAt end-to-end
  (route validator + markPaid service + payment-log) — only the UI was
  missing. EN/DE 'bills.payment.date' added.

This fixes the collapsed 30=90=365 windows (they were collapsing because
many invoices were marked paid in one session, all stamped 'now').
2026-06-03 18:20:55 +02:00
Luca 0b4690afab fix(crm): commit LocalizedDateInput value live, not only on blur
The historical-invoice import (and any form whose date field has a non-empty
default like today) lost a typed date: the value was only pushed to the parent
on blur, so submitting while the field was focused — or before React
re-rendered after the blur-time setState — sent the stale default. Issued/
event dates came out as 'today' instead of the entered date.

Now commit as soon as a complete, valid date is entered (toIso returns '' for
partial input, so intermediate keystrokes emit nothing); blur still normalises
display + handles clearing. Applies to every LocalizedDateInput consumer.
2026-06-03 17:50:13 +02:00
Luca db3e3270f3 fix(branding): serve favicon via backend route so Safari picks it up
Safari requests /favicon.ico and /apple-touch-icon*.png at the site root and
is unreliable about honouring JS-injected <link rel=icon>, so an admin-set
favicon never showed there (index.html only ships /favicon-32x32.png; a bare
/favicon.ico 404'd).

- Backend: GET /favicon.ico + /apple-touch-icon(.png|-precomposed.png) resolve
  the configured branding_favicon_url (redirect to its /uploads path or the
  absolute URL), falling back to the bundled /favicon-32x32.png.
- nginx: exact-match (=) locations proxy those paths to the backend, winning
  over the static-asset regex that previously served them from the build dir.
- DynamicFavicon also emits an apple-touch-icon link (belt-and-braces).

Requires a frontend image REBUILD (nginx.conf change) in addition to backend.
2026-06-03 17:35:32 +02:00
Luca 5e79c69cda fix(crm): recognise imported-invoice revenue on issue_date, not paid_at
The dashboard revenue windows (30/90/365 days) keyed purely on paid_at.
Imported historical invoices therefore landed in the recent window whenever
their paid_at sat there — notably legacy rows imported before commit c6b8cc9
began anchoring an import's paid_at to its issue_date, which still carry an
import-time paid_at. Recognise imported invoices (imported_pdf_path NOT NULL)
on their issue_date instead; native invoices keep cash-basis paid_at. No data
migration needed — fixes already-imported year-old invoices too.
2026-06-03 17:02:50 +02:00
Luca 0b4b5cf46e fix(branding): theme-aware logo across all login / auth entry pages
Extends the dark-logo fix to the customer login, customer accept-invite,
customer reset-password, and gallery client-access pages — they all rendered
only the light logo on the themed (possibly dark) surface.

Also makes the login-page pick frame-aware: a framed login logo sits on a
fixed cream plate, so the light (dark-ink) logo always reads there; only the
frameless logo sits on the themed page background and uses the dark variant.
This corrects the admin login too (was unconditionally swapping when dark).

Customer/gallery pages read isDark from usePublicDarkMode (branding_force_
color_mode + OS fallback), matching CustomerLayout.
2026-06-03 16:56:15 +02:00
Luca 05c1e8d18b fix(branding): theme-aware logo on customer-facing public pages
The public quote, contract-signing, and payment-check pages baked a single
light logo (the contract page showed none), so the dark page rendered a
dark-text logo on a dark background.

- usePublicDarkMode now returns { isDark } (reactive) alongside applying
  the .dark class, so pages can pick a theme-aware asset.
- The three public routes now surface both branding logo URLs (logoUrl +
  logoUrlDark) in the issuer block; the contract issuer gains a logo too.
- QuoteResponsePage, ContractResponsePage, and the payment-check
  BrandingHeader pick the dark variant when isDark, falling back to
  whichever exists. Covers the accept/accepted states of each page.
2026-06-03 16:48:54 +02:00
Luca a5011b1ea2 fix(pdf): version the logo rasterisation cache so the font fix takes effect
The SVG->PNG cache was keyed only by source path + mtime + size, so an
override logo rasterised once WITHOUT fonts (text -> tofu) stayed cached
after the font fix - the source SVG was unchanged, so the stale tofu PNG
kept being served. Add a RASTER_VERSION component to the cache key; bumping
it (v2-fonts) invalidates every prior rasterisation without clearing the
cache dir by hand.
2026-06-03 16:34:38 +02:00
Luca 12591556a0 fix(branding): accept SVG favicons
The favicon upload allowed only PNG/ICO, so an SVG favicon was rejected.
Accept image/svg+xml (.svg) too - DynamicFavicon already emits the right
MIME type and served SVGs are CSP-locked (render-only) by secureStatic.
Update the EN/DE help text accordingly.
2026-06-03 16:34:38 +02:00
Luca 307b84fe05 fix(branding): use dark-mode logo on the admin login page
The login page only ever rendered branding_logo_url (the light logo), so a
dark-text logo sat on the dark background in dark mode. Pick the dark
variant via useAdminDarkMode (honouring branding_force_color_mode too),
mirroring AdminHeader/AdminSidebar, with a fallback to whichever exists.
2026-06-03 16:34:38 +02:00
Luca 9454f43eba fix(pdf): install fonts so SVG logo text rasterises correctly
The runtime image (node:22-alpine) shipped without any fonts, so when
sharp/librsvg rasterised an SVG logo containing live <text> for the CRM
PDFs, the vector artwork drew but the text rendered as tofu boxes - a
'corrupted' logo on invoices/quotes.

- Add fontconfig + DejaVu/Liberation (broad Unicode fallback) and refresh
  the font cache.
- Register picpeak's own bundled brand fonts (assets/fonts/<Family>/*.ttf -
  the same files PDFKit and the web UI already use) with fontconfig via a
  conf.d <dir> entry + fc-cache, so the logo's text renders in its ACTUAL
  brand typeface rather than a generic fallback.
2026-06-03 16:07:29 +02:00
Luca 11257a7936 fix(branding): allow larger square favicons
The upload never enforced 32x32 - only the help text recommended it,
which misled admins. Update the EN/DE guidance to recommend a larger
square image (512x512) and raise the favicon upload cap 1MB -> 2MB so
high-resolution PNGs fit comfortably.
2026-06-03 16:01:07 +02:00
Luca 7b36582ef5 fix(crm): make 'Configure defaults in Settings' link navigate in-app
The link was an <a target="_blank"> doing a hard SPA boot in a new tab,
which tripped the error boundary. Switch to a react-router <Link> so it
opens Settings -> CRM the same way the sidebar nav does (known-good path).
2026-06-03 16:01:07 +02:00
Luca 615ef757d3 fix(crm): offer full ISO 3166-1 country list in pickers
The country dropdown was a curated 22-entry European subset; expand it to
the complete ISO 3166-1 alpha-2 set so customers from any country can be
selected. Labels are still derived from Intl.DisplayNames and sorted by
localized name at render time, so no translation map is needed.
2026-06-03 16:01:07 +02:00
Luca 4d3da35168 fix(branding): dark logo in the Branding live preview
GalleryPreview used only branding.logo_url, so the Live Preview kept the
light logo when previewing a dark theme. Pick the logo by theme.colorMode
(symmetric fallback) and pass logo_url_dark through from BrandingPage.
2026-06-03 15:30:16 +02:00
Luca 7d34c97c58 fix(branding): dark-mode logo in the admin sidebar
The sidebar brand row (logo_position=sidepanel) + collapsed rail used
the light logo unconditionally. Make it theme-aware via useAdminDarkMode
with the same symmetric fallback as the header (dark uses dark||light,
light uses light||dark). This was the missing admin surface — the header
already switched.
2026-06-03 15:19:08 +02:00
Luca e8960a41cf i18n(contracts): German translations for the contract detail page
The whole contracts.detail.* namespace was English-fallback-only, so the
contract detail page rendered English in German. Add all 64 keys to en +
de (native German), covering actions, signing/counter-sign, audit trail,
convert, and PDF flows. Also dedupe a duplicate events.createInvoice key
(identical value).
2026-06-03 14:49:22 +02:00
Luca 807ae3d4fa fix(security): block script execution in served SVGs via CSP
Serve uploaded SVGs (admin logos etc.) with a restrictive
Content-Security-Policy (default-src 'none'; style-src 'unsafe-inline';
img-src 'self' data:) + X-Content-Type-Options: nosniff in secureStatic.
The browser still renders the vector, but any embedded <script>/on*
handler can't execute if the SVG is opened directly — keeps real SVGs
(scalable) instead of rasterising them. Applies to all secureStatic
mounts (uploads/photos/thumbnails/fonts); only SVGs get the header.
2026-06-03 14:37:40 +02:00
Luca 02742b3163 feat(hours): open the hours invoice in the editor to add items
After 'Create draft invoice' mints the single scheduled invoice from a
per-event customer's unbilled hours, navigate to the bill editor so the
admin can add other line items before it ships (invoice is already
status='scheduled' + editable). Updated the per-event hint copy.
2026-06-03 14:20:49 +02:00
Luca 5d7b545bf7 feat(admin): System health page surfacing stuck/failed emails
New /admin/system-health page (sidebar entry, settings.view) that lists
emails the queue gave up on (status='failed' or pending+retry>=3) with
retry (re-queue) and dismiss (delete) actions. Backend adds /failures,
/failures/email/:id/retry and DELETE on adminSystemHealth. First source
is email failures (the original trigger — quote_sent template errors
left invoices unsent for 14h with no signal); more sources can be added.
2026-06-03 13:56:08 +02:00
Luca a6e6ef7b83 fix(branding): SVG (and .ico) favicons now render
DynamicFavicon hardcoded link.type='image/png', so an SVG/.ico favicon
was declared as PNG and browsers ignored it. Derive the type from the
file extension instead. (Sidebar icon already uses <img> which renders
SVG fine.)
2026-06-03 13:38:52 +02:00
Luca 44590b8c0b feat(branding): symmetric light/dark logo fallback + customer surface
Pick the logo by the active color mode with a symmetric fallback: a
single uploaded logo serves both modes (dark uses dark||light, light
uses light||dark). Apply to admin header, customer gallery, and the
customer portal (follows branding_force_color_mode). PDFs already use
the light branding logo with the business-profile PDF logo as override
(resolveLogoFile) — unchanged.
2026-06-03 13:33:27 +02:00
Luca 4790ea5ccd feat(branding): dark-mode logo variant
Add an optional dark-mode logo (branding_logo_url_dark) alongside the
main logo. Upload/remove via the logo endpoint (?variant=dark) on the
Branding settings page. Admin header (admin dark mode) and the public
gallery (dark themes) pick the dark logo when active, falling back to
the light logo when unset. PDFs keep using the light logo.
2026-06-03 13:26:56 +02:00
Luca d7f0488f6e feat(crm): cross-link to CRM settings from quote + invoice editors
Add a 'Configure defaults in Settings' link (opens Settings → CRM in a
new tab) under the payment-conditions section of the quote and invoice
editors, so the admin can jump to the payment-term / Skonto / numbering
defaults without hunting for the settings page.
2026-06-03 13:12:28 +02:00
Luca 7eec1337da feat(contracts): preview PDF before sending
Add a 'Preview PDF' button on draft contracts that renders a fresh PDF
via the existing no-write /preview endpoint, so the admin can check
layout + signature blocks before sending (no audit trail created).
2026-06-03 13:10:06 +02:00
Luca f3a6c8940a fix(date-input): render pg full-ISO dates in the configured format
LocalizedDateInput.toDisplay only matched a bare yyyy-MM-dd, but Postgres
serializes DATE columns as a full ISO datetime, so the field printed the
raw "2026-…T…Z" string (SQLite returned a bare date, hiding it). Match the
leading yyyy-MM-dd of any ISO value and slice the hidden native picker's
value to 10 chars. Fixes ISO-form dates on customer/event/passive-create.
2026-06-03 11:23:58 +02:00
Luca a03146edc9 feat(events): "Create invoice" action on the event detail page
Add a bills-gated button that opens the bill editor pre-filled with the
event (eventId FK + name/date snapshot) and the linked customer (when
exactly one). BillEditorPage gains eventId state + query-param prefill +
sends eventId on create; backend validates eventId (already forwarded +
persisted). Reuses the editor — no empty drafts. Does not auto-pull hours.
2026-06-03 00:07:00 +02:00
Luca b378ad679f feat(email): hold relationship mail to business hours
queueEmail gains options.respectBusinessHours: snaps the send time to the
next open business-hours block (from now), only deferring when it actually
falls outside hours. Applied to dunning reminders + gallery-expiry warnings;
transactional/admin-initiated mail stays immediate. No-op until business
hours are configured.
2026-06-02 19:44:04 +02:00
Luca 626ab45e0b refactor(time): app-wide setting-aware TimeField for all time inputs
Add shared components/common/TimeField (displays per general_time_format,
stores canonical HH:MM, parses tolerant free-text, browser-independent)
and migrate every native <input type="time"> to it: business hours,
HoursSection, HourEntryInlinePopover, CreateEventPage, Quote/Bill/Contract
editors. Removes the unreliable lang-hint plumbing.
2026-06-02 17:24:27 +02:00
Luca 2149f38cd1 fix(business-profile): setting-aware custom time field for business hours
Native <input type="time"> ignores general_time_format (browser-locale
controlled; lang hint failed in Safari and Ralf's Chrome for both en-GB
and de-DE). Replace with a custom TimeField text input that displays per
general_time_format (24h "13:00" / 12h "01:00 PM") and stores canonical
HH:MM, parsing tolerant free-text on blur. Keeps the fixed-width
alignment fix.
2026-06-02 16:57:08 +02:00
Luca e1d9d06ae9 fix(business-profile): use the app-standard de-DE lang hint for 24h time
Business-hours time pickers used lang="en-GB", which didn't render 24h in
Chrome. Switch to lang={timeFormat==='12h'?'en-US':'de-DE'} — the same hint
HoursSection/Quote/Bill/Contract editors use — so the picker shows 24h in
Chrome/Edge. Keep the fixed-width plain <input> for column alignment.
2026-06-02 16:36:46 +02:00
Luca f4af290fa0 fix(business-profile): align business-hours time columns
The shared Input wraps fields in a w-full div, so two per flex row split
the width and the trailing +/trash buttons knocked columns out of
alignment. Use a plain fixed-width <input> for the start/end time fields.
2026-06-02 15:16:52 +02:00
Luca 2ef837680e fix(i18n): honor general_time_format across all time displays
Route 10 surfaces that hardcoded 12-hour date-fns patterns
('h:mm a', 'PPp', 'p', toLocaleTimeString) through useLocalizedDate's
formatDateTime/formatTime so they respect general_date_format +
general_time_format: backup/restore, archives, photo viewer, feedback,
event details, gallery timeline, public quote page, CMS save indicator.
Also pin a lang hint on the business-hours native time inputs (Chrome/Edge
render 24h). Drops now-unused date-fns imports.
2026-06-02 15:13:55 +02:00
Luca 2723b3f29e fix(business-profile): honor 24h time format in business-hours pickers
Native <input type="time"> renders AM/PM from the browser locale, ignoring
general_time_format. Pin a lang hint (en-GB for 24h, en-US for 12h) so
Chrome/Edge render the admin's chosen format. Display-only; the stored
value was already 24h HH:MM.
2026-06-02 15:03:35 +02:00
Luca 333379b321 Merge branch 'beta' of https://github.com/the-luap/picpeak into feat/crm-improvements 2026-06-02 14:17:46 +02:00
Luca a5c88f2d9f i18n(crm): fill missing DE/EN keys on CRM admin surfaces
The t() calls on the Features tab, CRM settings tab, contract editor,
and settings nav carried English fallbacks but the keys were absent from
both locale files, so DE rendered English. Add ~67 keys to en.json +
de.json (DE native): the full contracts.editor.* subtree (whole page was
English), settings.features.{contracts,crmDevelopment,hoursLogging}.*,
crmSettings contracts/dashboard-overview/ToS labels, and two settings
nav titles. Also drop a dead duplicate bills.field.sourceQuote key in de.
No code changes — additive translations only.
2026-06-02 14:11:44 +02:00
Luca 603ba1e504 feat(email): read-only "Sent emails" tab over email_queue
Add a paginated, filterable view of the email_queue (recipient, type,
status, queued/sent timestamps, error, event link) as a third tab in
Email config, beside SMTP + Templates. Filters: status, recipient/type
search, created-at range. email_data is never exposed. Pairs with the
"Send queued emails now" flush — flush, then watch what sent/failed.
2026-06-02 13:58:54 +02:00
Luca 8d14441df4 feat(quotes): admin decline-on-behalf with optional reason
Add a "Decline on behalf" action mirroring accept-on-behalf, for when a
customer says no by phone/email. Flips a draft/sent/expired quote to
declined, stamps declined_at, closes the public response window, and
invalidates outstanding accept/decline tokens so the emailed link can't
toggle it back. Optional free-text reason persisted to a new
quotes.decline_reason column (migration 115) and shown on the quote
detail page. Hard-delete intentionally not included.
2026-06-02 13:47:31 +02:00
Luca 621ce942b5 feat(email): per-weekday business hours + manual queue flush
Move the scheduled-email business-hours floor onto the business profile
as Google-style per-weekday opening blocks (multiple blocks/day for lunch
breaks). Migration 114 adds business_profile.business_hours (JSON) +
scheduled_email_floor_enabled; emailProcessor snaps a queued email to the
next open block, read in the profile timezone. Editor lives under
Settings → Business profile.
Add an admin "Send queued emails now" flush (POST /admin/email/flush-queue)
that drains the queue immediately, ignoring the business-hours floor — the
escape hatch before maintenance/updates. processEmailQueue now takes
{ignoreSchedule, limit} and returns send counts; the scheduled interval
run is unchanged.
2026-06-02 13:00:13 +02:00
Luca 93956db0ca feat(hours): aggregate open-hours landing view on /admin/clients/hours
When no customer is selected, list every customer with unbilled hour
entries — entry count, total hours, and open amount (resolved via the
override → customer-rate → install-default chain). Rows with no
resolvable rate are flagged "Rate not set" rather than undercounted.
Click a row to drill into the per-customer logging section.

Backend: getUnbilledSummaryByCustomer() + GET
/api/admin/customers/hour-entries/unbilled-summary (customers.view).
2026-06-02 11:43:42 +02:00
Luca ab6bad17c9 feat(hours): install-wide default rate + inline missing-rate CTA
Hour-entry saves hard-failed with an English-only error when a customer
had no rate, and the standalone hours page showed a disabled rate field
that looked set. Add a global business_profile default_hourly_rate_minor
(migration 113) as the last link in the rate chain
(entry override → customer → install default), so saves succeed with the
global rate. When no rate resolves anywhere, replace the save-time error
with a read-only resolved-rate display + a CTA to set a customer or
install-wide rate, disable Add-entry until a rate/override exists, and
translate the backend HOURLY_RATE_REQUIRED toast (en+de).
2026-06-02 11:33:45 +02:00
Luca d9251c0850 feat(crm): Finder-style sortable column headers, default sort by issue date
Replace the sort <select> dropdowns on the invoice, quote and contract
list pages with clickable column headers that toggle asc/desc and show a
chevron indicator. Adds a shared SortableHeader component + useColumnSort
hook that maps clickable columns onto the server-side sort enum.
Make issue date (newest first) the standard sort on all three lists,
set at the frontend, route and service layers. Adds issue_asc/issue_desc
to invoices and an "Issued" column to the bills table so the default is
visible and toggleable. Extends sort coverage so every clickable column
has both directions (+customer_desc on all; +issue_asc/desc on
quotes/contracts). Storno rows remain listed.
2026-06-02 10:59:36 +02:00
Luca 095edfe06d fix(bills): localize the event_date on the invoice detail card
The Anlass field rendered inv.eventDate verbatim (raw ISO from pg
date-as-Date serialization) while every other date on the card went
through useLocalizedDate. Route it through fmtDate so it honors the
general_date_format setting. (The event_id → /admin/events linkify was
already in place.)
2026-06-02 10:04:23 +02:00
Luca db7d11dc7f feat(bills): capture event name/date when importing historical invoices
The historical-invoice import form had no event field, so imported rows
landed with event_name = NULL even when the admin knew the occasion. Add
free-text Event name + Event date inputs to the import modal, thread them
through billsService.importHistorical and the POST /admin/invoices/import
validator, and store them in the event_name/event_date snapshot columns
(migration 107). event_id stays NULL — no FK, since the event may predate
picpeak. Autocomplete-to-event_id linking deferred as a future bonus.
2026-06-02 09:50:51 +02:00
Luca c2d3f663bc fix(settings): use CountrySelect for business-profile country (no more FL)
The business-profile country field — the seed source for the customer-create
country default — was still a free-text input placeholdered "FL", which could
reintroduce the non-ISO "FL" code that migration 110 normalized to "LI" and
re-open the create/edit CH-vs-FL default mismatch. Swap it for the shared
CountrySelect so every surface stores ISO alpha-2. The free-text countryName
verbatim-PDF override (migration 107) is unchanged.
2026-06-02 09:37:33 +02:00
Paul Nothaft d57a59a4c1 Merge pull request #601 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.1-beta.0
2026-06-02 09:29:23 +02:00
Luca 06b4522dec feat(billing): manual cadence + fix admin date inputs ignoring date-format setting
Manual billing cadence
  Adds a "Manual (trigger only)" cadence alongside monthly/quarterly. It reuses
  the monthly draft accumulator — invoices and billed hours pile onto one running
  draft — but stores NULL monthly_period_start/end so the scheduler's auto-flush
  never matches. The draft ships only when an admin clicks "Trigger invoice now".
  No migration: billing_cadence is a free-form string column gated by validators.
  - adminCustomers.js: allow 'manual' in billing_cadence validator
  - invoiceService.js: route manual through accumulator; NULL periods + placeholder
    issue/due date in getOrCreateMonthlyDraft
  - customerHoursService.js: manual auto-appends hours to running draft;
    billUnbilledEntries refuses manual (CADENCE_MISMATCH)
  - CustomerDetailPage.tsx: dropdown option, cycle-day hidden for manual,
    NULL-period-safe draft preview + trigger button, manual-specific copy
  - customerAdmin.service.ts: cadence union + nullable periodStart/periodEnd
  - en.json / de.json: manual, triggerConfirmManual, triggerHintManual,
    draftPreview.titleManual
Date-format fixes
  Replace raw <input type="date"> (browser-locale) with LocalizedDateInput so
  these admin surfaces honor the general_date_format setting:
  - ContractEditorPage.tsx (issue / valid-until / event dates)
  - QuoteEditorPage.tsx (event / valid-until dates)
  - EventDetailsPage.tsx (expiry date)
  - HoursSection.tsx (entry date)
2026-06-02 09:27:10 +02:00
github-actions[bot] 3361a8bb51 chore(beta): release 3.60.1-beta.0 2026-06-02 07:17:10 +00:00
Paul Nothaft 940fc60740 Merge pull request #598 from the-luap/fix/notifications-clear-all-597
fix(notifications): restore /clear-all route the frontend already calls (#597)
2026-06-02 09:16:47 +02:00
Paul Nothaft 316ad5fa2e Merge pull request #600 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.0-beta.0
2026-06-02 09:16:24 +02:00
github-actions[bot] 987911a03b chore(beta): release 3.60.0-beta.0 2026-06-02 07:11:30 +00:00
Paul Nothaft 9d424d0dbb Merge pull request #596 from Luca-Timo/bugfix/crm-backup
Backup & Restore hardening — close the silent files-only data-loss class
2026-06-02 09:10:57 +02:00
Luca d788a6cde5 feat(crm): per-customer Skonto opt-out
Adds customer_accounts.skonto_disabled (migration 112) so a customer
that negotiated "no early-payment discount" can be flagged once instead
of ticking the per-invoice toggle on every invoice. resolveSkontoPercent
ForInvoice and the PDF render context both honour it, extending the
resolution chain to customer → invoice → snapshot → quote → global.
Checkbox added to the customer detail Billing card (en + de).
2026-06-02 09:00:05 +02:00
Luca 45c7cc80ab fix(invoices): anchor issue date + Skonto window to the actual send date
A scheduled invoice's issue_date was stamped at creation, so a long-
scheduled invoice printed a stale date by the time it shipped — the
relative Skonto window ("pay within N working days") and the net-days
due date were then counted from the authoring day, not the send day.
sendInvoice now stamps issue_date = send date on the first send and
re-derives the due date from it, preserving a manual due-date override.
Adds resolveNetDaysForRow to read net days from the persisted snapshot.
Deselecting Skonto before the scheduled send already propagates (the
scheduler re-reads the row fresh and the render context honours
skonto_disabled); no change needed there.
2026-06-02 08:54:23 +02:00
Luca 7c39a30957 feat(bills): link event label to its event detail page
The Anlass / event name on the invoice detail page and the bills list
now links through to /admin/events/:id when the invoice references a
real event row. The list link stops propagation so it doesn't trigger
the row's invoice navigation. Falls back to plain text when the invoice
carries only a free-text event snapshot. Customer portal unchanged
(no admin route access).
2026-06-02 08:38:07 +02:00
Luca 522cf2aa4a fix(invoices): auto-track due date from send date + payment term
Due date now derives from (scheduled send date else issue date) plus the
selected Net-days template, both in the editor and on save. The bill
editor renders it read-only with an Override toggle for manual entry;
existing invoices preserve their stored due date. Backend adds a single
resolveNetDays resolver that honors the split payment-net-days template
(previously only the legacy FK was read) and the
crm_payment_default_net_days setting, used by createInvoice and the
installment-spawn path alike.
2026-06-02 01:52:45 +02:00
Luca c6b8cc9199 fix(crm): anchor imported invoice dates to issue_date, not import time
The invoice-import endpoint stamped sent_at and paid_at with the moment
of import (new Date()) instead of the document's historical dates. The
CRM dashboard "Revenue · last 30 days" card keys on paid_at, so a
year-old paid invoice imported today wrongly counted toward the rolling
window. The dashboard windowing is correct (cash-basis "received in the
window") — the bug was the wrong paid_at on imported rows.

POST /admin/invoices/import now anchors sent_at to issue_date and
paid_at to issue_date (or an optional new paidAt param when the admin
knows the real payment date), never to import time.

Migration 111 backfills rows imported under the old behaviour: for every
invoice with imported_pdf_path set, sent_at/paid_at are reset to
issue_date. The old code never captured a real payment date, so
issue_date is the only sensible anchor. Idempotent and scoped strictly
to imported rows, so picpeak-issued invoices are untouched.

paid_at/sent_at are operational timestamps, not the invoice's immutable
legal content, so correcting the import-time error is safe under the
§14/§11 UStG immutability rule.
2026-06-02 01:36:52 +02:00
Luca db2c482ae9 feat(crm): country dropdown + name guard for customer create/edit
Replace the free-text 2-char country code field on the inline customer
create form and the customer detail page with a dropdown that shows
localized country names (Intl.DisplayNames, no hardcoded map) while
still storing the ISO 3166-1 alpha-2 code. The create form now seeds the
default country from the business profile instead of leaving it blank or
guessing CH/FL. The free-text countryName override is kept for the rare
case where an operator wants a custom display string.

Standardize Liechtenstein on the ISO code LI instead of the colloquial
plate code FL so it matches the PDF renderer's locale-aware lookup and
the new dropdown. Migration 110 normalizes existing FL rows to LI on
customer_accounts and business_profile (idempotent, case-insensitive).

Require at least one human-readable identifier (company name or a
contact name) at create time so the form can't produce a nameless row
that's impossible to recognise in lists later. Enforced on both the
frontend (isValid + toast) and the backend POST /admin/customers
validator so the API can't be bypassed.

i18n: en + de updated; other locales fall back to inline English
defaults and should get a native review before release.
2026-06-02 01:28:00 +02:00
Luca 840df52581 fix(crm): respect general_date_format on all admin date inputs
Admin date inputs were inconsistent: raw <input type="date"> on event
creation and the bill editor rendered in the browser locale (en-US users
saw MM/DD/YYYY regardless of Settings -> General), while the historical-
invoice import modal used a private LocalizedDateField that displayed the
configured format but showed a text box plus a tiny native date stub
side-by-side ("two date fields, looks corrupted").

Extract a single shared LocalizedDateInput that displays/parses in the
configured general_date_format on every browser and opens the native
picker via a calendar icon button (showPicker on a visually-hidden native
input), so there is one date field, not two. Wire it into event creation,
the bill editor (event/issue/due dates), the import modal, and the tax-
report range filters (dropping the Chromium-only lang={dateInputLang}
workaround there).
2026-06-02 01:15:40 +02:00
Luca 7988c18972 fix(restore): set was_successful=true on the completed update
Caught during the round-4 e2e validation on real PG: every
successful restore landed with `status='completed', was_successful=false`
because the success-branch update only wrote `status` but not
`was_successful` (column default is false). Visible side effect: the
BackupDashboard's "last successful restore" filter would skip the
row + any future audit query gating on was_successful would miss it.

One-line cure: include `was_successful: true` in the success-branch
update payload. Inline comment explains why and references the
review note so future edits keep the two fields together.

Source-inspection test in restoreService.pgBranch.test.js pins the
contract: after `performPostRestoreVerification(...)`, the
`status: 'completed'` update payload must also contain
`was_successful: true`. Future refactors of the success payload that
drop the flag fail the test before merge.

36/36 backup-related integration tests pass.
2026-06-01 22:56:02 +02:00
Luca 20e3092c14 fix(restore): move operator-meta replay after post-restore verification (PR #596 round 3)
End-to-end DR cycle surfaced one more PG-only landmine — and it
turned out to be a side-effect of the round-1 replay placement, not
a new bug. Round 2 fixed the comparison logic; round 3 fixes the
ordering.

Symptom on real PG install:

  [install-from-backup] FAILED — Post-restore verification failed:
    Table app_settings row count mismatch: expected 190, got 191.
    Trigger file left in place for retry.

Root cause: the operator-meta replay (introduced in round 1) ran
INSIDE performDatabaseRestore, lined up BEFORE the post-restore
verification step in the parent restore() method. So:

  1. psql restores app_settings → 190 rows (matches backup)
  2. Replay upserts `restore_allow_force_auto_upgraded` (which the
     fresh-install seeded but the backup didn't have) → 191 rows
  3. performPostRestoreVerification counts 191, manifest says 190,
     verification fails the row-count check.

Replay is doing the right thing (preserving operator policy). The
verification is doing the right thing (counts must match). They
disagree because the replay landed in the wrong sequence relative
to verification.

Cure: move the replay out of performDatabaseRestore and into
restore() AFTER `performPostRestoreVerification` passes.
Verification now sees the as-restored DB (matches the backup
exactly), replay layers on top once verification has signed off.

Mechanism: snapshot stashed on `this.preservedMetaSnapshot`
(initialised in constructor, reset per run at the top of restore()).
performDatabaseRestore writes it in the PG branch before DROP;
restore() drains it after verification. SQLite leaves it empty,
both steps no-op there.

Tests:
  - Updated `restoreService.pgBranch.test.js` to pin the new shape:
    * `this.preservedMetaSnapshot` is initialised in the constructor
    * No stray `let preservedMeta = []` local declarations anywhere
    * Replay drain (`this.preservedMetaSnapshot.length > 0`) sits in
      restore() AFTER `performPostRestoreVerification(...)` and is
      lexically OUTSIDE `performDatabaseRestore`.
  - The bigint-as-string contract from round 2 still holds.

34/34 backup-related integration tests pass.
2026-06-01 22:44:51 +02:00
Luca 354fbed182 fix(restore): coerce pg bigint counts to Number before comparing (PR #596 round 2)
pg-driver serialises `bigint` (which is what `COUNT(*)` returns) as a
JavaScript STRING to preserve precision for huge counts. The manifest
stores `expected.rowCount` as a JS number (parseInt'd at
databaseBackup.js:118). Strict `!==` in performPostRestoreVerification
flagged every match as a mismatch on PG:

  Table activity_logs row count mismatch: expected 16, got 16
  Table admin_users row count mismatch: expected 1, got 1
  Table app_settings row count mismatch: expected 165, got 165
  ... (every table, all matching)

Symptom matched the preservedMeta scope leak from round 1: install-
from-backup logged FAILED, trigger file wasn't cleaned, data was
actually intact. Caught on PR #596 e2e re-run.

Cure: coerce both sides with `Number(...)` at the comparison AND in
the interpolated value so the warning text renders `16` not `"16"`.

Pre-emptive: lines 448 + 458-459 had the same string-vs-number issue
masked by `>` (JS coerces operands for `>`), but the warning text
printed `"5"` on PG vs `5` on SQLite, and a future patch changing
`>` to `=== 0` or `!== expectedCount` would silently break on PG.
Coerced at the read site into `eventCountN` / `activeUsersN` locals
+ added a comment block explaining the contract so future edits
don't drop the Number() calls without re-auditing.

New source-inspection test: pins the contract that every `.count`
result in restoreService.js MUST be wrapped in `Number(...)` when
used in a comparison (===/!==/>/</>=/<=). Same source-inspection
pattern as the preservedMeta test added round 1 — pragmatic until
the real-PG integration test follow-up lands.

The maintainer's audit of the rest of the backup/restore surface
(_installFromBackupBoot, _restoreSettingsBoot, _backupPathsBoot,
backupCoverageService, backupIntegrityService, backupService,
databaseBackup) confirmed no other bigint-as-string sites — the
class is now closed in the audited scope.
2026-06-01 22:23:26 +02:00
Luca 3322a1d998 feat(restore): docker-logs visibility + ADMIN_CREDENTIALS.txt restore notice
Two nice-to-haves from the PR #596 review.

1. Install-from-backup logging mirrors to stdout
   The winston logger writes to /app/logs/combined.log and may not
   tee to stdout. Operators tailing `docker logs picpeak-beta-backend`
   after a `compose up` saw the migration sweep + npm notice and
   nothing about the restore. Three key events now also fire through
   `console.log` with a `[install-from-backup] ` prefix:
     - "trigger file detected → <manifest>"
     - "starting restore from <manifest>"
     - "restore completed successfully" / "FAILED — <reason>"
   Plus the "skipping — existing data" branch.
   docker-logs surface now tells the restore story without requiring
   an `exec into the container` step.

2. ADMIN_CREDENTIALS.txt flags stale creds when restore is queued
   Migration 001 detects a pending `RESTORE_ON_INSTALL` file BEFORE
   writing the fresh-install credentials file. If a trigger will fire
   on the next boot, the file now opens with a clear warning:

     ⚠️  RESTORE_ON_INSTALL TRIGGER DETECTED ⚠️
     These credentials are temporary. An install-from-backup run is
     queued to fire on the next server start, which will REPLACE
     this admin row with the one from the backup. After the restore
     completes, log in with your ORIGINAL pre-disaster credentials
     — not the ones below. If the restore fails for some reason,
     the credentials below remain valid as a fallback recovery path.

   Doesn't skip the file (so a failed restore still has the fallback
   credentials), just annotates it. Closes the maintainer's "stale
   junk credentials" observation.
2026-06-01 21:52:38 +02:00
Luca a23fa3bb12 fix(restore): hoist preservedMeta above SQLite/PG split (PR #596 blocker)
`preservedMeta` was declared with `let` INSIDE the PostgreSQL else
branch of performDatabaseRestore (~L850), then read AFTER the else
block closed at the shared replay site (~L1030). On every real PG
restore, this threw:

  ReferenceError: preservedMeta is not defined

after psql had already loaded the data successfully. Knock-on
effects per the maintainer's review:

  - Loud `Install-from-backup: FAILED` line in combined.log even
    though the data restored cleanly
  - Trigger file in `_installFromBackupBoot.js` was left in place
    because the success branch never ran — admin had to manually
    rm it before the next boot
  - The operator-meta replay (restore_allow_force,
    restore_allow_force_auto_upgraded) silently dropped, exactly
    the chicken-and-egg the snapshot was added to close.
    `restore_allow_force` reverted to the backup's value on every
    PG restore.

CI missed it because integration tests around `performFullRestore`
only exercise the SQLite branch (`this.dbType === 'sqlite'`). The PG
branch requires a real psql binary + cluster, which lives in the
"real-PG integration test in CI" follow-up.

Cure: hoist the `const PRESERVED_META_KEYS = [...]` + `let
preservedMeta = []` declarations above the SQLite/PG split. SQLite
leaves them empty; PG branch fills them; replay block at the bottom
reads them on both paths (no-op on SQLite).

New test: `restoreService.pgBranch.test.js` pins the scope contract
via source inspection. Two assertions:
  1. Exactly one `let preservedMeta = []` declaration in the file,
     positioned before the SQLite/PG branch split
  2. The replay block `if (preservedMeta.length > 0)` sits outside
     the else block (closing `      }` exists between the branch
     opener and the replay site)
Source-inspection beats a runtime test here because (a) it doesn't
need a real PG cluster + psql binary, (b) it pins the EXACT property
that broke, more directly than a runtime test would.

Closes PR #596 review blocker.
2026-06-01 21:51:49 +02:00
Paul Nothaft 29e63e5ce5 fix(notifications): restore /clear-all route the frontend already calls (#597)
The AdminHeader "Clear All" notifications button has been 404'ing for
a while: frontend `notifications.service.ts` calls
`DELETE /admin/notifications/clear-all`, backend only defined
`DELETE /admin/notifications/clear-old`.

The /clear-old route was misleadingly named anyway — it tried to
delete read OR >30-days-old rows, then had a fallback that nuked
EVERY row when nothing matched. Both the frontend and the existing
test expect a simple Clear All shape, so just rename to /clear-all,
drop the tiered logic, and return the plain
`{ message, deletedCount }` payload the test asserts on.

The test (adminNotifications.test.js) was hiding the breakage —
it was on CI's --testPathIgnorePatterns ignore list and so never
ran. Two reasons it failed locally before this fix:
  1. Route path mismatch (the actual #597 bug).
  2. The mock only stubbed adminAuth — requirePermission lives in
     its own middleware module and ran for real, 403'ing before
     the handler. Add a passthrough mock for that too.

With both fixed, the test passes. Drop adminNotifications from the
CI ignore list so future regressions in this route fail loudly
instead of going to ground.
2026-06-01 19:31:44 +02:00
Luca 205802fb9d Delete .claude/security-reports/2026-05-22-idor-crm-admin-endpoints.md 2026-06-01 10:45:38 +02:00
Luca 1a65d9d2f9 Delete .claude/drafts/issue-48-reply.md 2026-06-01 10:44:57 +02:00
Luca 07f9110674 chore(migrations): renumber 108_add_backup_paths to 109 to avoid upstream collision
upstream/beta independently shipped 108_seed_sl_email_template_translations.js
(Slovenian email template translations) using the migration number
this branch had already claimed for 108_add_backup_paths.js. Knex's
filename-based ordering would have caused both to attempt the slot
at merge time.

Renamed via `git mv` so file history is preserved. All five
references updated in lockstep:
  - backend/src/services/_backupPathsBoot.js (require + comments)
  - backend/src/services/backupService.js (LEGACY_BACKUP_PATHS comment)
  - 3 integration test files (require + "migration 108" prose)
  - migration's own header comment, with a paragraph explaining the
    rename so reviewers don't wonder why the number jumped

**No data-migration impact for installs that already ran the
108-named version** (Ralf's beta, primarily): the migration's body
is idempotent — createTable is guarded by `hasTable`, and the seed
uses `onConflict('path').ignore()`. So when 109 runs against an
install whose backup_paths table is already populated, both the
schema step and the seed step no-op cleanly. The orphaned
`108_add_backup_paths.js` row in the `migrations` tracking table
sits harmlessly alongside the new `109_add_backup_paths.js` row.

No data lost, no double-insert, no schema drift. Mechanical rename
ahead of the PR opening.
2026-06-01 00:31:08 +02:00
Luca 43cb0ea4bf docs: consolidate disaster-recovery into Backup & Restore guide
The previous split (separate docs/install-from-backup.md + separate
README link for "Disaster Recovery") fragmented what's conceptually
one workflow: backup → restore. DR is a specific scenario of restore
(the destination is wiped), not a separate feature.

This merge:
  - Folds install-from-backup content into docs/backup-restore.md
    as a "Disaster recovery (install from a backup)" section with
    its own table-of-contents anchor.
  - Adds an explicit ToC at the top so admins land on what they
    need in one click.
  - Frames the two restore paths up front: "live install" → wizard,
    "fresh / wiped install" → trigger file. Admins encountering DR
    in panic mode don't need to know to look under a separate link.
  - Drops the duplicate "Disaster Recovery" README bullet. The
    "Backup & Restore" blurb now mentions DR explicitly so it's
    still findable via Ctrl+F on the README.
  - Removes docs/install-from-backup.md (its content is now in
    backup-restore.md's DR section).

Single source of truth = less risk of one doc going stale relative
to the other when the feature evolves. Maintainer-facing surface
on docs.picpeak.app shrinks back to one /guides/backup-restore page.
2026-06-01 00:21:39 +02:00
Luca 09f6a1af6a fix(backup-ui): respect general_date_format + general_time_format
The four backup admin panes (BackupHistory, BackupDashboard,
BackupCoverageCard, BackupIntegrityCard) used raw date-fns
`format()` with hard-coded tokens like 'p' (12-hour AM/PM), 'PP',
'PPP', 'PPp', and 'yyyy-MM-dd HH:mm:ss' — ignoring the admin's
configured `general_date_format` and `general_time_format`
settings.

Net effect on a 24h-configured install: backup History row showed
"11:25 PM" instead of "23:25", and the Coverage tab's "Last dump"
+ "Coverage generated" timestamps were stuck on
yyyy-MM-dd HH:mm:ss regardless of the admin's date-format choice.

All four panes now route through `useLocalizedDate()` which honors
both settings + the active i18n locale (per the existing
[[feedback_respect_general_format_settings]] pattern).

Tokens replaced:
  format(date, 'p')              → formatTime(date)
  format(date, 'PP')             → format(date)
  format(date, 'PPP')            → format(date)
  format(date, 'PPp')            → formatDateTime(date)
  format(date, 'yyyy-MM-dd HH:mm:ss') → formatDateTime(date)
  format(date, 'yyyy-MM-dd HH:mm')    → formatDateTime(date)

No backend changes — settings already shipped via /admin/settings;
this just makes the consumers actually read them.
2026-05-31 23:33:50 +02:00
Paul Nothaft 3b2c74803b Merge pull request #595 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.59.1-beta.0
2026-05-31 23:12:23 +02:00
github-actions[bot] b646dab493 chore(beta): release 3.59.1-beta.0 2026-05-31 21:10:15 +00:00
Paul Nothaft c68a03c20e Merge pull request #594 from the-luap/fix/bugs-batch-523-564-590-591-592
fix(bug-batch): #523 #564 #590 #591 #592
2026-05-31 23:09:49 +02:00
Paul Nothaft c246fd3cc8 fix(admin-header): hide wordmark on <sm when logo also shows (#523)
Rekoo-PS's v3.59.0-beta.0 screenshot showed a different shape than
the truncate fix in e7cf834 addressed. Their company name ("Arkan
Studio") isn't unusually long, but with logo_and_text display mode
on a phone-width viewport the wordmark wrapped to two lines and the
LanguageSelector button — sitting in the right action cluster —
landed visually on top of the wrapped second line.

Truncate alone left "Arkan Studio" rendered as "Ar..." after the
logo image. Functional but ugly, and on accounts where the wordmark
reaches the right cluster the visual overlap returns. Match what
LanguageSelector does for its language name in #527: hide the
wordmark on <sm when a logo is also showing (the logo carries the
identity), keep it on sm+. text_only mode is unchanged — wordmark
shows on every width, otherwise nothing would render.

Truncate stays in place as defensive depth for the text_only path.
2026-05-31 23:07:19 +02:00
Luca 83fdb47fbf feat(installer): install picpeak directly from a backup via trigger file
Closes the six-step DR dance ("onboard throwaway admin → restore via
wizard → log out → log back in with originals") by letting admins
recover an install with zero clicks past `docker compose up`.

Convention: drop a file named `RESTORE_ON_INSTALL` (no extension OR
.txt) into the existing `/backup` bind mount. On next container
start, the new boot hook detects it, runs the restore, and starts
the server with the restored state. Admin opens the browser, login
works first try.

Payload variants:
  - empty file → auto-picks newest backup-manifest-*.json from
                 /backup/manifests/. Useful for "restore the latest".
  - path inside the file → uses that specific manifest. Useful for
                 "I want this older backup, not the most recent".

Safety gates (three layers):
  1. Trigger file must exist — no auto-magic, admin signals intent
  2. DB must be empty (no events, ≤1 admin) — refuses to clobber
     production data
  3. Restore failure leaves the trigger file in place for retry on
     next container start. Success deletes it so subsequent boots
     don't redo the work.

Override hook: INSTALL_FROM_BACKUP_FORCE=true skips guard #2 for the
"I know what I'm doing" edge case (dev env rebuilds, etc).

No docker-compose changes required — uses the bind mount picpeak
already has, env vars are optional. The minimal admin workflow now
matches the bare-minimum mental model: "copy my backup files,
restart the container, log in with original credentials."

Tests: 7 scenarios covering trigger detection, payload variants,
safety gates, success/failure trigger-file lifecycle.
2026-05-31 23:06:41 +02:00
Paul Nothaft 8c6525af01 test(v1/events): update mock chains to cover new app_settings probes
The #592 fix added a devtools-detection probe, and the #592 follow-up
added a require_password probe + a branding-defaults whereIn().select().
Both shift the db() call indices the existing #550 test relied on, and
the branding probe needed `.select()` to resolve to an array (the mock
chain wasn't thenable, so `for..of` on the result threw → 500 on every
test that hit BASE_BODY).

Add `whereIn` + `selectResult` to buildChain so the branding probe
yields an iterable. Factor the three pre-slug app_settings chains into
a baseSettingsChains() helper and update each test's queued sequence
and toHaveBeenNthCalledWith / toHaveBeenCalledTimes expectations to
match the new shape. No behaviour change in v1/events.js — only the
test scaffolding moves.
2026-05-31 23:02:51 +02:00
Luca e7dffa656b feat(backup-stats): per-Stage-B-path counters in backup statistics
Closes the last gap from tonight's backup-hardening: backup_runs.
statistics now carries a `per_path` map keyed by backup_paths.path
(e.g. `events/active`, `business-docs`), with per-bucket count + size.

Backend (backupService.js):
  - new `computePerPathStats(backedUpFiles, allFiles)` helper that
    bucket-sorts each backed-up file into its owning backup_paths row
    by longest-prefix match. Reuses the same backup_paths source the
    walker reads, so toggling include_in_default off propagates
    correctly. Falls back to LEGACY_BACKUP_PATHS if the table is
    missing.
  - runBackupInternal calls it after the destination implementation
    reports back, includes the result in statistics under both
    snake_case (`per_path`) and camelCase (`perPath`) keys for the
    same alias treatment the existing fields get.

Frontend (BackupHistory.jsx):
  - Backup History detail pane now renders one row per per_path entry
    when present, with path label + count + formatted size.
  - Falls back to the legacy Photos / Archives / "Other" rendering
    when the field is absent (backups taken before this commit). No
    breaking change for stored history.

Tests: new backupService.perPathStats.test.js — 2 scenarios pinning
attribution behaviour (single-path, nested-paths-don't-collide).
Plus a NOTE comment about overlapping-path walker behaviour (out of
scope; canonical seed doesn't hit it).
2026-05-31 22:54:09 +02:00
Luca e0ace0864e fix(restore): preserve operator-meta settings across restore
Closes the chicken-and-egg where `restore_allow_force` (and its
auto-upgrade tracking flag) got overwritten on every restore by
whatever value happened to be in the backup. Net effect:

  1. Admin enables Force Restore (via tonight's default-ON migration
     edit, or hand-SQL on older installs).
  2. Restore runs successfully.
  3. Restored DB has `restore_allow_force = <backup's old value>`.
  4. Next restore attempt: "Force restore is not allowed by system
     settings" — admin needs the SQL workaround AGAIN.

Cure: snapshot a small list of operator-meta keys BEFORE the DROP
DATABASE (while we still have a working pool against the OLD DB),
then UPSERT them back AFTER the psql restore + migrate.latest.

The preserved set is intentionally narrow — currently just
`restore_allow_force` and `restore_allow_force_auto_upgraded`. These
are about how the operator wants the install to behave, not user-
facing state. Adding more keys is a one-line addition to the
PRESERVED_META_KEYS constant.

Survives both:
  - backup is OLDER than the operator's most recent setting change
  - backup is NEWER but had a different operator policy
Either way, the post-restore install reflects the LIVE operator
policy, not the backup's snapshot of it.
2026-05-31 22:48:56 +02:00
Paul Nothaft 791e9974eb fix(gallery): preserve per-viewer is_liked across hard refresh (#590 follow-up)
The in-session toggle fix in d292b9f handles click 2 correctly, but
on a hard refresh likedPhotoIds was always initialized to an empty
Set — so previously-liked photos rendered un-filled until the user
opened the lightbox.

Backend: gallery.js GET /:slug/photos now mounts resolveGuest and
emits a per-viewer is_liked boolean per photo. Prefers req.guest.id
when a verified guest token is present (per-person identity), falls
back to the IP+UA hash that generateGuestIdentifier produces — same
identity model galleryFeedback.js uses for /my-feedback. Skipped
when feedback is hidden from guests.

Frontend: Photo type gains optional is_liked. Each of the 7 grid
layouts (Masonry / Grid / Justified / Timeline / Carousel / Mosaic /
Premium) seeds its lifted likedPhotoIds Set from photos.filter(is_liked)
on the first non-empty payload, gated by a seededRef so subsequent
React Query refetches don't clobber in-session optimistic toggles.
Mosaic uses photo.is_liked ?? false in its per-card useState initializer.

GalleryPremium also drops the buggy `|| like_count > 0` fallback at
line 521 that treated "anyone liked this" as "I liked it" — the
per-viewer seed is now the correct source.

GalleryStory had the same shape of bug in two places — same #590 fix:
- Seed switched from like_count > 0 (global) to is_liked (per-viewer),
  with the same mount-only seededRef guard.
- handleToggleFavorite now calls submitFeedback on EVERY click, not
  only when adding. The previous code skipped the unlike submit, so
  the UI removed the heart while the server kept the like row.
2026-05-31 22:47:16 +02:00
Paul Nothaft 2d44b1ab2d fix(api/v1/events): also honour require_password + branding defaults (#592 follow-up)
Same class of bug as the devtools-detection gap landed in 2304b25.
v1 POST /events was hardcoding require_password=true in the destructure
default and skipping getBrandingDefaults entirely, so:

- Admins who disabled "require password by default" globally still
  got password-required galleries through the API.
- API-created events ignored the global branding_logo_display_hero
  and branding_logo_size toggles, defaulting to visible/medium
  regardless of the admin's preferred branding chrome.

Mirror the readBooleanSetting + getBrandingDefaults pattern from
adminEvents.js inline (helpers aren't exported, and pulling them out
is out-of-scope for this fix). Adds validators, fallback resolution,
and the three resolved values to the events insert. hero_logo_position
stays at 'top' since #357 / migration 084 explicitly disconnected it
from the header-bar branding_logo_position setting. OpenAPI updated.
2026-05-31 22:47:01 +02:00
Luca 989b42c2f1 fix(restore-wizard): warn on backups without a database dump
Closes the loop on the original 2026-05-29 data-loss class: Ralf had
four "Run Backup Now" manifests sitting on disk with
database.backup_file = null because Stage A wasn't yet in place.
The restore wizard would have happily restored any of those four,
bringing back files (photos, PDFs) but leaving the database empty —
silently re-creating the exact data loss the rest of this branch
prevents going forward.

The /restore/list-backups endpoint now returns `database_included`
per row (parsed from the manifest at discovery time). The wizard
uses it to:

  - Per-row badge: red "No DB" pill next to any backup where
    database_included === false. Tooltip explains the consequence
    in plain English: "restoring this will NOT recover the database".
  - Selected-card callout: full red banner under the chosen row
    when database_included is false, restating the warning + giving
    the admin a clear path: "pick a different backup if you have
    one with a database dump, or proceed only if files-only is
    what you want."

The wizard does NOT block the restore — the admin may genuinely want
a files-only restore (e.g. recovering a deleted photo while keeping
current DB state). The warnings make sure that choice is informed.
2026-05-31 22:44:37 +02:00
Luca 155aa63103 fix(backup-dashboard): show last SUCCESSFUL backup + last attempt separately
The dashboard widget used `lastBackup.created_at` for the "Last
successful backup: X ago" text — but lastBackup is the most recent
row of any status. So a crashed restore (status=running, never
updated) or a recent failure showed up labeled as the last
successful backup. Same "silent failure not surfaced" class the
restore wizard had.

Backend now returns:
  lastSuccessfulBackup — most recent backup_runs with status='completed'
  zombieRuns — running rows older than 30 min (likely crashed mid-flight)
  lastBackup — unchanged (most recent any status)

Frontend renders:
  - "Last successful backup: X ago"  — always from lastSuccessfulBackup
  - "Last attempt: Y ago · failed/running"  — when lastBackup differs
    from lastSuccessful. failed shows the first line of error_message
    in red; running stays neutral.
  - Zombie callout — "N backup(s) running >30min — may have crashed"
    in amber, so admin sees stuck rows at a glance.
  - Health score downgrades from "excellent" to "warning" if the
    latest attempt failed, even when older successes keep the age
    fresh — surfaces regressions without erasing the green history.
2026-05-31 22:44:22 +02:00
Luca 47ed6907d1 fix(restore-wizard): surface failure status, stop showing "completed at 0%"
The progress step used a binary `isRunning ? "in progress" : "completed"`
check. So when the backend rejected the restore (pre-flight validator
threw, path error, etc.) the wizard cheerfully rendered "Restore
completed" with 0% progress and no error context — the admin had to
SSH into the server and inspect `restore_runs.error_message` to find
out what happened.

Now reads the most recent row from `restoreStatus.history[0]` and
renders one of three states:
  - running    → blue text, progress bar updates
  - succeeded  → green tick + post-restore actions (existing behaviour)
  - failed     → red banner with the first line of error_message, and
                 a callout if was_rollback_attempted is true so the
                 admin knows the destination is safe to retry on top of.

Net: the wizard now tells the truth about what just happened.
2026-05-31 22:44:00 +02:00
Luca 48e9c9c79a fix(restore): re-init knex pool after DROP/CREATE DATABASE
`db.destroy()` during restore tore down the in-process connection
pool to release PG sessions so DROP DATABASE could succeed. After
CREATE DATABASE + psql restore, the old code did
`require('../database/db')` expecting a fresh instance — but Node
caches require results, so it got the SAME destroyed instance back.
Every subsequent query in the process failed with "Unable to acquire
a connection" until the container was manually restarted, even
though the restore technically succeeded.

Net effect for admins: login showed "An error occurred", customer /
invoice / quote pages were blank, no surface hinted at the dead pool.

Cure: db.js now wraps the live knex instance in a Proxy that forwards
to a mutable internal reference, with a `reinitPool()` function that
destroys the old instance + builds a fresh one + probes with `SELECT 1`
so any reconnect failure surfaces immediately. The thousands of
existing `const { db } = require(...)` imports work unchanged — they
capture the Proxy once, and every call goes through to the current pool.

restoreService calls reinitPool() after CREATE DATABASE and before
migrate.latest(), so the rest of the request + every subsequent admin
action runs against the fresh pool. Container restart no longer
needed after restore.
2026-05-31 22:43:40 +02:00
Paul Nothaft 2304b25624 fix(api/v1/events): honour global devtools-detection default on create (#592)
Same class of bug as #550 part 2 (feedback default ignored on API
events): the events table column default for enable_devtools_protection
is true, so an admin who disabled detection globally still got it ON
for every API-created gallery.

Mirror the feedback fallback that landed in 1b521e7 — accept an
optional enable_devtools_protection body field, fall back to the
app_settings entry of the same name, and write the resolved value
explicitly on insert so the column default doesn't shadow it.
OpenAPI doc updated to match.
2026-05-31 22:35:23 +02:00
Paul Nothaft c83e88348f fix(nginx): defensive large_client_header_buffers bump (#591)
Default nginx is 4 8k — too tight when an outer Cloudflare /
corp-proxy injects long Set-Cookie / X-Forwarded-* headers, or when
a power-user accumulates many per-gallery gallery_token_<slug>
cookies over the 24h maxAge in tokenUtils.js. Either way users hit
"400 Request Header Or Cookie Too Large" and clearing cookies is
the only workaround.

4×32k is cheap RAM, matches what most reverse proxies do upstream,
and means PicPeak doesn't fail the request before the upstream even
sees it.
2026-05-31 22:35:19 +02:00
Paul Nothaft d292b9fa10 fix(gallery): toggle (not add) the local liked set on click (#590)
The /feedback like endpoint is a server-side toggle — the same one
the lightbox uses. Every grid layout's optimistic-UI setter only
ever did next.add(photoId), so click 2 on a liked tile fired a
server unlike but kept the heart filled in the UI.

Switch each setter to toggle (delete if present, else add). Covers
Masonry (default), Grid, Justified, Timeline, Carousel, Mosaic, and
Premium layouts — including their identity-modal callback paths for
shape consistency. Lightbox toggle is unchanged (already correct).
2026-05-31 22:35:15 +02:00
Paul Nothaft e7cf834325 fix(admin-header): truncate long company names on narrow widths (#523 regression)
#527 hid the language *name* on <sm to free space for the title.
Since then the right cluster gained dark-mode toggle, notifications,
and the user avatar, and the brand block still had no truncation —
so a long branding_company_name would still push past the available
width into the action buttons on phones.

Defensive fix: min-w-0 on the brand-block wrapper, truncate on the
company-name span, flex-shrink-0 on the logo image. Long names now
ellipsis within the left cluster regardless of how many widgets
fill the right.
2026-05-31 22:35:07 +02:00
Paul Nothaft dcc629cad2 fix(csp): external bootstrap script to survive strict reverse-proxy CSP (#564)
demo.picpeak.app sits behind Caddy + Cloudflare; Caddy replaces the
nginx CSP entirely with one that omits 'unsafe-inline' / hash / nonce,
so the #358 inline theme-bootstrap was being blocked there — admin
loaded a black page, the SPA bundle 404'd, link buttons did nothing.

Move the bootstrap to /public/bootstrap.js served as 'self' so the
script runs under every reasonable CSP without further coordination.
Vite copies /public/* to the dist root at build time (same pipeline
as /favicon-32x32.png), and it remains in <head> without defer/async
so it still runs before <body> paints. The OS-preference @media CSS
above still handles the first-frame dark/light baseline.
2026-05-31 22:35:00 +02:00
Luca c435263744 fix(restore): default restore_allow_force=true + auto-upgrade existing installs
Root cause of the persistent "Force restore is not allowed by system
settings" error even on fresh installs after `docker compose down -v`:

  migrations/core/032_add_restore_runs_table.js seeded the row with
  `JSON.stringify(false)` = the literal string 'false'.

So every install (fresh OR upgraded) wrote restore_allow_force=false
at migration time. The boot self-heal added earlier today saw the row
and respected "admin policy" per its safety design — never noticing
that the row was the deprecated migration default, not an explicit
admin choice.

Cure follows [[feedback_migration_no_compensation]] +
[[feedback_self_heal_pattern]]:

  1. Edit migration 032 IN PLACE — flip seed value from false to
     true. Fresh installs forward get the correct default at install
     time, no boot helper needed.

  2. One-time auto-upgrade in _restoreSettingsBoot.js for installs
     that already ran the OLD migration. Bumps restore_allow_force
     to 'true' iff the current value is the deprecated literal
     'false' AND the new tracking key
     `restore_allow_force_auto_upgraded` doesn't yet exist. The
     tracking flag is always written after the first boot pass, so
     subsequent admin choices (e.g. deliberately disabling force)
     are preserved on every boot after.

  3. Defensive: adminRestore.js getRestoreSettings() now normalizes
     'true'/'false'/'"true"'/'"false"' string shapes to JS booleans,
     not just '1'/'0'. Belt-and-suspenders so any future seeder that
     uses a different boolean serialization doesn't silently break
     the !settings.restore_allow_force gate.

Net effect: any picpeak install pulling this image — fresh or
existing — gets restore_allow_force=true on first boot after the
upgrade. The catch-22 that forced every disaster-recovery admin to
hand-write SQL before their FIRST restore is closed.
2026-05-31 21:35:48 +02:00
Luca dbcecfe2aa feat(restore): self-heal restore_allow_force default ON at boot
Fresh installs of picpeak had `restore_allow_force` defaulting to
false (or missing entirely). Combined with the "1 active admin
user" pre-restore warning that the fresh-install admin auto-creates,
this meant the very first restore on every new install hit:

  Force restore is not allowed by system settings

Admins then had to hand-craft SQL to flip the setting before they
could recover their data — at the worst possible moment, when they
were already mid-disaster.

This isn't security: the admin who can SQL the setting on can also
flip it via the UI. It's just a sharp edge that bites every new
install once.

Cure: boot-time self-heal that seeds restore_allow_force=true only
when the row doesn't exist. Existing installs that explicitly set
the row (true OR false) are NOT touched — admin policy wins.
Pattern mirrors _backupPathsBoot.js and _emailTemplateBoot.js.

Default-ON rationale matches Stage A's principle: the cost of
forgetting (= can't recover from a disaster) outweighs the friction
saved (= adversarial admins can't run forced restores). Audit
logging keeps the accountability story intact.
2026-05-30 21:48:05 +02:00
Luca 7f7c8eee61 fix(backup-history): show Total + Other so per-row sums match the count
The "Content Backed Up" panel in Backup History only counted two
categories (Photos + Archives), so a 3-file backup that landed all
3 in business-docs (Ralf's case after the storage truncation +
restore tonight) showed:
  Photos (0 of 0)
  Archives (0)
  → total: 3 files
The discrepancy made admins wonder where the 3 files actually went.

Adds two rows:
  - "Business documents & other" = files_processed - photos - archives
  - "Total files" = files_processed
So the math adds up regardless of which Stage B path the files came
from. Properly per-path-category breakdown requires backend-side
per-path counters (separate follow-up); this commit closes the
visible-discrepancy gap without that schema change.

i18n: en + de added; other locales fall back to en until reviewed.
2026-05-30 21:22:27 +02:00
Luca cfaa7eb095 fix(restore): re-sync PostgreSQL sequences after psql load
pg_dump emits setval() statements for SERIAL/IDENTITY columns, but
they don't always land cleanly: --clean ordering, knex pool sequence
caching, rows inserted mid-restore (the pre-restore safety backup
writes a database_backup_runs row before DROP), etc. Net result on
Ralf's install after a successful restore:

  - "A record with this value already exists" on every CRUD action
  - duplicate key value violates unique constraint
    "database_backup_runs_pkey" on the next Run Backup Now

Same root cause: every SERIAL column's sequence was pointing at or
below MAX(id), so the next INSERT collided.

Fix: append a DO block after the psql restore that walks pg_class +
pg_attribute and setval()s every public-schema sequence to
GREATEST(MAX(<col>), 1). Cheap (a few ms even on large schemas),
safe (read-only on row data), idempotent — re-running it just
re-asserts the same values.

Seventh latent PG-restore bug discovered on Ralf's install tonight.
Manual hand-fix worked; this commit makes the fix automatic for
every future restore.
2026-05-30 21:10:10 +02:00
Luca a39def672e fix(restore): evict active sessions before dropping target DB
PostgreSQL refuses DROP DATABASE while any session is connected:
  ERROR: database "picpeak_prod" is being accessed by other users
  DETAIL: There are 6 other sessions using the database.

The backend's own knex pool holds 5-25 active connections to the
target DB. So even after closing the request that initiated the
restore, the pool keeps the DB busy and the DROP statement fails.

Three-layered cure, all in the restore service's PG branch:

  1. Call `db.destroy()` first to close the in-process knex pool so
     we don't fight ourselves. Knex will lazily re-open on the next
     query via db.js's retry logic, so this is safe to do mid-restore.

  2. SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE
     datname=<target> AND pid<>pg_backend_pid() — evicts any sessions
     from other processes (other server replicas, leftover idle
     transactions, things our own pool destroy missed).

  3. DROP DATABASE IF EXISTS "<target>" WITH (FORCE) — PG13+ kills
     remaining connections atomically with the DROP. Falls back to
     plain DROP on older Postgres where WITH (FORCE) is a syntax error.

Surfaced as the FIFTH latent bug in the restore path tonight: the
DROP DATABASE statement always assumed a quiescent destination, but
the live backend keeps the destination busy at all times. Every
previous PG install of picpeak that ever tried Restore would have
hit this — meaning the disaster-recovery feature has shipped broken
for a long time without anyone exercising it end-to-end.
2026-05-30 13:20:20 +02:00
Luca 4c31a22626 fix(restore): DROP/CREATE DATABASE needs explicit -d maintenance DB
`psql` with no -d connects to a database whose name matches the
connecting user. On installs where the user's home DB doesn't exist
(common pattern: DB_USER=picpeak, DB_NAME=picpeak_prod, no `picpeak`
DB), the restore's DROP DATABASE / CREATE DATABASE statements failed
with:

  FATAL: database "picpeak" does not exist

even though the target DB (picpeak_prod) was alive and connectable.
And of course you can't connect to the target DB itself for DROP —
PostgreSQL refuses while a connection is open to it.

Fix: explicitly connect to `postgres` (the maintenance DB every PG
cluster ships with) for the DROP/CREATE statements. Override via
DB_CHECK_DB env var if the `postgres` DB is restricted to superusers
on the cluster — matches the pattern wait-for-db.sh already exposes.

Also quote the database name in the SQL so installs whose DB has
unusual characters (numbers, hyphens) don't break the statement.

Surfaced during Ralf's end-to-end restore validation — yet another
"never been tested on a real PG install" latent bug exposed by the
Stage A inline-dump path actually being able to produce a restorable
manifest for the first time on his install.
2026-05-30 13:06:15 +02:00
Luca 5c0be66a14 fix(restore): resolve local source + always rollback on failure
Two changes that close the disaster-recovery loop the Stage A-B-C
backup-hardening plan opened:

1. Resolve 'local' source to backup_destination_path
   The wizard passes options.source = 'local' (the SOURCE TYPE
   string). The old code assigned that verbatim to localBackupPath
   and every downstream path.join() ended up with junk like
   'local/database/<file>.sql.gz'. Fixed by looking up
   backup_destination_path from app_settings when source='local',
   plus a layered candidate fallback in performDatabaseRestore so
   absolute paths in manifests are honoured first.

2. Auto-rollback on ANY failure during restore
   Previously rollback only fired when post-restore VERIFICATION
   failed (inside the try block). Anything that threw earlier —
   path bugs, pg_restore failure, file copy errors — left the
   destination half-clobbered with no automatic recovery. Now the
   catch block always invokes attemptRollback if a pre-restore
   backup exists, and persists rollback status in
   was_rollback_attempted + an enriched error_message so the admin
   can tell at a glance whether the destination is safe to retry
   on top of or needs manual inspection first.

Surfaced during Ralf's validation of the end-to-end backup +
restore cycle (`docker compose down -v` then restore from disk).
Every prior failed attempt left stray PDFs behind that the next
attempt had to navigate around — exactly the "every failure makes
the next worse" pattern this fix kills.
2026-05-30 12:47:40 +02:00
Luca 44c7935b84 fix(restore): resolve 'local' source to backup_destination_path
Two stacked bugs in the disaster-recovery path:

1. The wizard passes `options.source = 'local'` (the source TYPE
   string) and the service assigned it verbatim to `localBackupPath`.
   Every downstream `path.join(localBackupPath, ...)` ended up with
   junk like `local/database/<file>.sql.gz` and `local/events/...`.

2. performDatabaseRestore reconstructed the dump path from the
   manifest by basename-only:
     path.join(backupPath, 'database', path.basename(dbBackupFile))
   discarding the absolute path the manifest actually recorded.

Cure:
  - At the entry point, if `options.source === 'local'`, look up
    `backup_destination_path` from app_settings and use that as the
    local root. Honour s3:// downloads via the existing branch.
  - In performDatabaseRestore, try the manifest's absolute path
    first, then `localRoot + manifest_value`, then the legacy
    `localRoot + 'database' + basename` reconstruct as a final
    fallback. First hit wins; error message lists every candidate
    so future failures are diagnosable.

Surfaced during Ralf's end-to-end validation of the Stage A-B-C
backup-hardening plan — restored fresh after `down -v`, the wizard
failed silently with `Database backup file not found: local/database/...`
even though the dump existed at the path the manifest recorded.
With this fix, the same destruction-and-recovery sequence completes.
2026-05-30 12:38:59 +02:00
Luca f664fea60c fix(restore): discover backups from disk, not just the DB
The Restore wizard's "Choose Backup to Restore" list was driven only
by the backup_runs table. After `docker compose down -v` (the disaster
this whole hardening effort is designed to recover from), the DB is
empty and the wizard shows "No backups found in selected source" —
exactly when it's needed most. The manifest JSONs are still on disk;
the wizard just can't see them.

Adds disk-first discovery:
  - Walks backup_destination_path AND backup_manifest_path (manifests
    can live in a sibling directory under the canonical
    <root>/manifests/backup-manifest-<id>.json layout). Depth-limited
    recursion (3 levels) so the scan doesn't enumerate the photo tree.
  - Matches backup-manifest-*.json|yaml AND legacy bare manifest.json.
  - Parses each manifest for real metadata (timestamp, size, file
    count, database.backup_file presence) instead of showing the
    admin opaque filenames.
  - Layers in surviving backup_runs rows, deduping by manifest_id.

Applied to both GET /available-backups (legacy) and POST /list-backups
(the one the frontend actually calls). Same helper, two call sites.

Side benefit: each returned row now carries `databaseIncluded` — so a
future Restore UI iteration can show a "this backup has no DB dump"
warning before the admin picks a files-only backup. Exactly the
surface that would have caught Ralf's original four files-only
manifests if it had existed.
2026-05-30 04:07:40 +02:00
Luca ed7ab61b90 fix(admin-ui): backup download button hits API path, not SPA route
`BackupHistory.jsx` opened `/admin/backup/download/<id>` via window.open,
which goes to the React SPA's router — no matching route, so it
rendered the "Page Not Found" screen.

The actual download endpoint lives at `/api/admin/backup/download/:id`
on the backend (adminBackup.js:685). Cookie-based admin auth already
supports the implicit cookie sent by window.open, so the URL prefix
was the only thing missing.

Predates today's backup-hardening work — the bug has existed since
this download button shipped. Surfaced now because Ralf finally has a
completed backup to try downloading after the Stage A inline-dump
guard started working.
2026-05-30 03:51:02 +02:00
Luca 0ad14899fa ix(database-backup): drop bogus --single-transaction flag from pg_dump
pg_dump rejects `--single-transaction` — it's a pg_restore / psql flag,
never a pg_dump one. Triggered as soon as the inline-dump path landed
on Ralf's install:

  pg_dump: unrecognized option: single-transaction
  pg_dump: hint: Try "pg_dump --help" for more information.

pg_dump already wraps the entire export in a single REPEATABLE READ
snapshot automatically (since Postgres 9.x), so the original intent —
consistent snapshot of the live DB — is preserved by removing the
flag. Same "latent until Stage A wired it in" pattern as the three
prior bugs this rollout has surfaced (PG insert destructure → bind-
mount EACCES → Node 22 stdio strict mode → this).
2026-05-30 03:35:08 +02:00
Luca d34036c4ef fix(safe-exec): Node 22-compatible stdio + error-bridge for spawnTo/FromFile
spawnToFile and spawnFromFile passed an unopened WriteStream/ReadStream
directly as a stdio entry to child_process.spawn. Older Node versions
auto-extracted .fd; Node 22 throws synchronously:

  The argument 'stdio' is invalid.
  Received WriteStream { fd: null, path: '/backup/database/...sql', ... }

Bug bit Ralf's install once today's `bugfix/crm-backup` image landed —
Node 22 came with that image, and Stage A's inline-dump path is the
first caller of spawnToFile on this install. Latent on the previous
image (Node 20); fatal on this one. restoreService's pre-restore
safety snapshot uses the same helper and would have hit it next time
a restore ran.

Cure: stdio: ['ignore', 'pipe', 'pipe'] (and ['pipe', 'pipe', 'pipe']
for spawnFromFile) + manual pipe of child.stdout/stdin through the
file stream. Works on every Node version. Also wires the WriteStream's
'error' event to the promise via settleReject so a future EACCES /
ENOSPC reaches the caller's try/catch instead of becoming a process-
fatal unhandled error event — closing the same "Stage A guard
bypassed" hole noted in the spawned follow-up task.

Side benefit: outStream.end() now awaits flush before resolving, so
fast pg_dump runs can no longer produce a truncated dump.
2026-05-30 03:26:10 +02:00
Luca f741e88acb fix(database-backup): Postgres-safe insert destructure (runs the inline dump)
databaseBackupService.backup() did `const [runId] = await db(...).insert({...})`
without a .returning() — works on SQLite (knex returns [lastInsertId]) but
throws "(intermediate value) is not iterable" on Postgres (knex returns
a non-iterable shape).
Bug was latent until Stage A of the backup-hardening plan wired this
method into the "Run Backup Now" inline-dump path. Before Stage A only
the scheduled cron + the dedicated admin-DB-backup page called it, and
Ralf's install had never exercised either — so the inline-dump default
landing in production was the first time the destructure ran on his PG.
Cure: same explicit .returning('id') + dual-shape coalesce pattern that
backupService.js uses for its own backup_runs insert (line 949).
Two more sibling files have the same anti-pattern (userManagementService,
customerAccountsService — invitation flows) and will bite under the
same conditions; spawned a follow-up task to fix them in a separate PR.
2026-05-30 02:56:10 +02:00
Paul Nothaft cc9a1ffa8f Merge pull request #589 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.59.0-beta.0
2026-05-29 22:56:31 +02:00
github-actions[bot] 714f7647fc chore(beta): release 3.59.0-beta.0 2026-05-29 20:54:16 +00:00
Paul Nothaft c4a9b3636f Merge pull request #588 from the-luap/feat/admin-user-activate-delete
feat(admin/users): reactivate + delete actions for deactivated admin users
2026-05-29 22:53:54 +02:00
Paul Nothaft dfcebccee9 feat(admin/users): reactivate + delete actions for deactivated admin users
#574 follow-up — @blazmaric flagged that once an admin user is
deactivated, the UI loses every affordance to manage that record.
The deactivate button hides (rightly — they're already deactivated)
but nothing replaces it, leaving the row stranded in the list with
no path to either restore access or permanently remove it.

## Backend

New on `userManagementService`:

- **`activateAdminUser(id, activatedById)`** — symmetric to
  `deactivateAdminUser`. Flips `is_active` back to true, logs
  `admin_user_activated` activity. Idempotent: already-active target
  short-circuits without bumping `updated_at`. No "can't activate
  yourself" guard needed (actor is by definition already active).
- **`deleteAdminUser(id, deletedById)`** — hard-deletes the row.
  Same self-action and last-super-admin guards as deactivate.
  Last-super-admin guard counts ACTIVE super admins excluding the
  target — so an already-deactivated super_admin can still be
  deleted when an active super_admin remains. FK ON DELETE rules
  in core migrations handle the cascade: SET NULL on
  `created_by_admin_id` everywhere (events, photos, quotes,
  invoices, contracts, customer_accounts, …); CASCADE on the
  user's own `api_tokens` + their pending admin / customer
  invitations.

New routes on `adminUsers.js`:

- `POST /api/admin/users/:id/activate` — `users.delete` permission
  (same tier as deactivate; reverting deactivation is the same
  scope of action as performing it).
- `DELETE /api/admin/users/:id` — `users.delete`.

## Frontend

`UserManagementPage.tsx`:

- New mutation hooks: `activateUserMutation`, `deleteUserMutation`.
- The row's action cell now branches on `user.isActive`: active
  users see Edit + Deactivate (unchanged); deactivated users see
  Edit + Reactivate (`UserCheck` icon, green hover) + Delete
  (`Trash2` icon, red hover).
- The shared `ConfirmDialog` handles all four action types
  (deactivate / activate / delete / cancelInvitation) via per-type
  title / message / confirmText / variant lookup.

`userManagement.service.ts`:

- New `activateUser(id)` and `deleteUser(id)` methods mirroring the
  existing `deactivateUser` shape.

i18n keys are added with English fallbacks via `t(key, fallback)`
so the page works on every locale without a missing-translation
warning. Native translations can be filled in via a follow-up.

## Test plan

- [x] 8 new service tests pin: activate happy-path, idempotency on
  already-active, NotFoundError on missing target, activity log
  emitted, delete self-refusal, last-super-admin guard for both
  active and already-deactivated super_admin targets, hard-delete
  success, delete activity log.
- [x] Frontend type-check clean.
- [x] Frontend lint clean for the changed files.
- [x] Backend lint clean.
- [ ] Manual: deactivate a user → row now shows Reactivate + Delete
  → reactivate → user can log in again. Then deactivate again →
  delete → row vanishes, pending tokens for that user invalidated.

Closes the UX gap blazmaric called out in
https://github.com/the-luap/picpeak/pull/579#issuecomment-... .
2026-05-29 22:49:01 +02:00
Paul Nothaft d32bdda1b3 Merge pull request #586 from the-luap/feat/crm-route-tests-570
test(crm): HTTP route tests for CRM public + admin surface
2026-05-29 22:42:03 +02:00
Paul Nothaft 5c4da1eacd test(crm): HTTP route tests for CRM public + admin surface (#570)
Closes #570.

PR #555 shipped the CRM module with strong service-layer coverage
but no HTTP-layer tests. This adds Supertest-based route coverage
across the externally-reachable public routes (P0) and an auth-gate
sweep of every CRM admin route (P1+P2).

## What's covered

### P0 — Public routes (49% of new tests)

The three public routes are the security-sensitive surface — any IP
with the raw token from a leaked email can hit them. Tests pin the
publicTokenGuards.loadActionToken contract end-to-end:

- **publicQuotes** (8 tests) — GET load + POST respond: 404 unknown,
  400 malformed, 410 expired, 200 valid w/ sanitised payload (no
  customer_account_id / created_by_admin_id leakage), 429 after 20
  bad attempts (IP lockout), 400 invalid action.
- **publicContracts** (10 tests) — GET load + POST sign + POST
  upload-signed-pdf + GET pdf: same guard outcomes per endpoint,
  plus the pre-multer token check (malformed token rejected before
  multer reads the body — prevents the disk-spam attack the
  preMulterTokenGuard was added for).
- **publicPaymentCheck** (6 tests) — different shape (no
  loadActionToken; service does its own validation): validator gate
  on token shape, all 4 canonical actions pass through the
  validator, negative amountMinor rejected.

The NULL-expires_at defensive branch in loadActionToken is
documented but not tested here — current schema declares
quote/contract_action_tokens.expires_at NOT NULL, so the branch is
unreachable at the route level. Worth a direct unit test on
loadActionToken if anyone wants to cover it.

### P1 + P2 — Admin routes (51% of new tests, 25 cases)

One consolidated `adminCrmAuth.test.js` file rather than nine
per-route files — the auth-gate contract is identical for every CRM
admin route, so a parametrised `describe.each` is more efficient
and lands the same coverage:

Per route (adminQuotes, adminContracts, adminInvoices, adminCalendar,
adminDeals, adminTaxReport, adminBusinessProfile):
- 401 without Authorization header (adminAuth gate)
- 401 with invalid JWT signature (adminAuth signature check)
- 2xx with super-admin token + CRM feature flags on (permission +
  feature-flag gates both pass)

Plus 4 tests for the CRM additions in adminCustomers
(hour-entries / bill / trigger-monthly-bill) — those endpoints
are mixed in with pre-existing customer routes, so they get
explicit coverage rather than bulk via the parametrised sweep.

## Harness extensions to integration/helpers/crmDb.js

Three new helpers (one place for any future route test to find):

- `mintAdminToken(adminId, opts)` — JWT signed with the test
  JWT_SECRET, shape matches what adminAuth expects.
- `createPublicToken(db, tableName, opts)` — insert a row into
  quote/contract_action_tokens with controllable expires_at /
  used_at / token. Note: Date values are explicitly ISO-stringified
  before insert — bare Date objects round-tripped inconsistently
  through knex+SQLite, sometimes via .toString() → literal
  `"[object Object]"` which parsed back to NaN and silently defeated
  the expiry guard. Caught it in test bring-up.
- `buildRouteApp(mount, router)` — minimal Express app (json + cookies)
  with a catch-all error handler that mirrors middleware/errorHandler
  (uses err.statusCode, not err.status — getting that wrong silently
  maps every 4xx to 500 in tests).
- `assignAdminRole(db, adminId, roleName)` — promotes a seedMinimal
  admin into super_admin (or any seeded role) for happy-path tests.

## Out of scope (follow-up)

Deeper integration tests for the document mint/send paths
(adminQuotes.send → PDF persisted + token minted + email queued;
adminInvoices.Storno → new row with shared deal_uuid + original
cancelled; adminContracts.countersign → integrity_hash computed)
are deferred. The service-layer behind those is already covered by
the existing __tests__/services/ suites — this PR pins the
HTTP-layer contract, which is what #570 actually asked for.

## Counts

- 4 new test files, 49 tests total
- ~860 LOC of test code + ~85 LOC of new harness in crmDb.js
- All tests pass in <2.5s (no real network, no real disk except the
  per-test tmpdir, no email sending)
2026-05-29 22:36:59 +02:00
Luca 03e6617f38 feat(backup): coverage diagnostic — what will the next backup miss?
Stage C of the three-stage backup-hardening plan (Stage A: inline
DB dump + fail-loud landed in 7fdf01a; Stage B: config-driven walker
in 302fc6b). Answers the "what would I lose if I clicked Run Backup
Now right now?" question that Stage B made possible to answer.
Backend:
  - new backupCoverageService.js: per-path coverage classification,
    drift detection (top-level subdirs not in backup_paths and not
    in the backups/tmp allow-list), DB-dump mode + staleness block
  - new GET /api/admin/system-health/backup-coverage route, same
    auth + settings.view permission as /backup-integrity
  - 7 integration scenarios pinning the classifier behaviour
Frontend:
  - new BackupCoverageCard with auto-fetch (cheap; no recursion)
  - new Coverage tab on BackupManagement next to Integrity
  - en + de i18n; other locales fall back to en keys until a native
    speaker reviews
Verification:
  - 26/26 backup integration tests pass (Stage A 5 + Stage B 7 +
    Stage C 7 + adminBackupIntegrity 4 + businessDocs 3)
  - frontend build clean
  - 4 pre-existing integration failures confirmed unrelated
2026-05-29 22:20:32 +02:00
Paul Nothaft 97d0a4bf7e Merge pull request #585 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.58.0-beta.0
2026-05-29 22:15:50 +02:00
Luca 302fc6b937 feat(backup): config-driven walker via backup_paths table
Stage B of the three-stage backup-hardening plan (Stage A:
inline-DB-dump + fail-loud guard already landed). The file-backup
walker used to hard-code its subdirectory list inside
`getFilesToBackupInternal`, which is the same footgun that hid the
`business-docs` gap for ~6 months — a new feature drops artefacts
under STORAGE_PATH and the maintainer has to remember to edit the
walker.

Now driven by a `backup_paths` table:

  - Migration 108 creates the table and seeds the 7 canonical
    defaults (events/active, events/archived, thumbnails, previews,
    heroes, uploads, business-docs). Seed data lives on the
    migration as `DEFAULT_PATHS` so the boot self-heal can re-use it.
  - `_backupPathsBoot.js` mirrors `_emailTemplateBoot.js`: on every
    boot it diffs the canonical list against the current rows and
    `INSERT ... ON CONFLICT DO NOTHING`s the missing ones. Keeps
    admin edits intact, picks up new defaults shipped after the
    install (Knex won't re-run migration 108). Wired into server.js
    just before `startBackupService()`.
  - Walker now calls `resolveBackupPaths(config)` which:
      * reads `backup_paths WHERE include_in_default=true ORDER BY
        display_order`
      * falls back to a hard-coded `LEGACY_BACKUP_PATHS` if the
        table is missing OR empty (defense in depth — never silently
        scans nothing)
      * gates each row by its `feature_flag` column (matches how
        `backup_include_archived` already worked; data-driven now)
  - Backward compatible: `getFilesToBackup(true|false)` still works
    for legacy callers and the existing businessDocs test. New
    callers should pass the full config object so feature gates
    other than `backup_include_archived` evaluate correctly.

Tests:
  - new: `backupService.configurableWalker.test.js` — 7 cases
    covering canonical seed, toggling include_in_default, runtime
    INSERT picked up without restart, feature_flag gating both on
    and off, empty-table → LEGACY fallback, boolean backward compat
  - all 15 backup-walker integration tests pass
    (configurableWalker 7 + inlineDbDump 5 + businessDocs 3)
  - frontend build clean
  - 4 pre-existing integration failures (webhookDelivery, storage
    backend, adminPhotos.reference, imageProcessor.storage) confirmed
    unrelated via `git stash` baseline run

Stage C (CRM feature coverage audit + diagnostic UI) follows
in a separate commit.
2026-05-29 22:09:23 +02:00
github-actions[bot] d50edcc427 chore(beta): release 3.58.0-beta.0 2026-05-29 20:05:46 +00:00
Paul Nothaft 433af15146 Merge pull request #582 from the-luap/feat/slovenian-locale-580
feat(i18n): add Slovenian (sl) language support
2026-05-29 22:05:32 +02:00
Paul Nothaft f4609f80ef Merge pull request #584 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.57.2-beta.0
2026-05-29 22:05:13 +02:00
github-actions[bot] 4989c468a8 chore(beta): release 3.57.2-beta.0 2026-05-29 20:04:53 +00:00
Paul Nothaft 1ed48046cb Merge pull request #581 from the-luap/docs/crm-readme-mention
docs: list CRM under Beta Features + note dev-compose rebuild gotcha
2026-05-29 22:04:40 +02:00
Paul Nothaft 2908cadf77 Merge pull request #583 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.57.1-beta.0
2026-05-29 22:04:27 +02:00
github-actions[bot] 7be484eb8e chore(beta): release 3.57.1-beta.0 2026-05-29 20:03:02 +00:00
Paul Nothaft de9a924c77 Merge pull request #579 from the-luap/fix/email-normalization-574
fix(email): preserve dots + subaddresses across all normalization sites
2026-05-29 22:02:40 +02:00
Luca 7fdf01ad21 fix(backup): inline DB dump + fail-loud guard so "Run Backup Now" can't ship files-only
The previous file-backup workflow only LOOKED UP an existing
database dump via getDatabaseBackupInfo() and silently shipped a
files-only manifest when none was found. Admins clicking "Run
Backup Now" (or relying on the schedule) got an apparent success
that omitted every customer / quote / invoice / contract / payment-
log row. The data-loss footgun was discovered 2026-05-29 when an
admin who'd been "backing up" for weeks via the UI lost the entire
CRM after a routine docker compose down -v — every produced
manifest had database: { backup_file: null, size: 0, tables: {} }.
New helper `ensureDatabaseDumpForBackup(config)` encapsulates:
  1. Inline pg_dump (or SQLite copy) before the file scan, via
     databaseBackupService.backup(). Result lands in
     database_backup_runs and is picked up by the existing
     getDatabaseBackupInfo lookup that writes the manifest.
  2. Fail-loud guard: if no usable dump file is reachable (path
     missing, 0 bytes, or never existed), throw — the existing
     catch in runBackupInternal marks the backup_runs row failed
     with the error_message and emails the admin if configured.
     No more silent files-only manifests.
  3. Opt-out: `backup_database_inline_dump = false` skips the
     inline dump for admins who already run their own scheduled
     `backup_database_schedule`. The fail-loud guard still
     applies, so an opted-out install with no recent dump still
     aborts loudly instead of producing a partial backup. Default
     ON is encoded as "skip only when explicitly false" — undefined
     (existing installs upgrading) falls through to the safe-
     default ON branch.
The helper returns the verified `databaseInfo` so the manifest-build
step at runBackupInternal:917 reuses it instead of calling
getDatabaseBackupInfo a second time. S3/future destinations that
override `result.databaseInfo` are still respected (the existing
`result.databaseInfo ||` fallback shape stays put).
Test suite covers: default-on happy path, dump-throws-aborts-run,
opt-out + recent dump + proceeds, opt-out + no-dump + fail-loud,
opt-out + 0-byte dump + fail-loud. Mocks
databaseBackupService.backup so the tests don't depend on pg_dump
or sqlite3 CLI binaries being installed.
Stage A of three-stage backup hardening plan. Stage B (config-
driven walker) and Stage C (audit + diagnostic UI) follow in
separate commits.
2026-05-29 22:00:15 +02:00
Luca 7c230bdc24 fix(backup): inline DB dump + fail-loud guard so "Run Backup Now" can't ship files-only
The previous file-backup workflow only LOOKED UP an existing database
dump via getDatabaseBackupInfo() and silently shipped a files-only
manifest when none was found. Admins clicking "Run Backup Now" (or
relying on the schedule) got an apparent success that omitted every
customer / quote / invoice / contract / payment-log row. The
data-loss footgun was discovered 2026-05-29 when an admin who'd been
"backing up" for weeks via the UI lost the entire CRM after a routine
docker compose down -v — every produced manifest had database:
{ backup_file: null, size: 0, tables: {} }.

Changes to runBackupInternal:

  1. Inline pg_dump (or SQLite copy) before the file scan, via
     databaseBackupService.backup(). Result lands in
     database_backup_runs and is picked up by the existing
     getDatabaseBackupInfo lookup that writes the manifest.

  2. Fail-loud guard after the dump step: if no usable dump file is
     reachable (path missing, 0 bytes, or never existed), throw —
     the existing catch block marks the backup_runs row failed with
     the error_message and emails the admin if configured. No more
     silent files-only manifests.

  3. Opt-out: `backup_database_inline_dump = false` skips the inline
     dump for admins who already run their own scheduled
     `backup_database_schedule`. The fail-loud guard still applies,
     so an opted-out install with no recent dump still aborts loudly
     instead of producing a partial backup. Default ON is encoded
     as "skip only when explicitly false" — undefined (existing
     installs upgrading) falls through to the safe-default ON path.

Test suite covers: default-on happy path, dump-throws-aborts-run,
opt-out + recent dump + proceeds, opt-out + no-dump + fail-loud,
opt-out + 0-byte dump + fail-loud. Mocks
databaseBackupService.backup so the tests don't depend on pg_dump
or sqlite3 CLI binaries being installed.

Stage A of three-stage backup hardening plan. Stage B (config-driven
walker) and Stage C (audit + diagnostic UI) follow in separate
commits.
2026-05-29 21:57:12 +02:00
Paul Nothaft 37cc3631d8 feat(i18n): add Slovenian (sl) language support
Closes #580.

Slovenian community contribution from @blazmaric (filed as an issue
with attached files rather than as a PR — files inlined here unchanged
except for the migration number).

## Changes

- **`frontend/src/i18n/locales/sl.json`** — full Slovenian UI
  translations. Covers every top-level key present in `en.json` as
  of pre-CRM beta. The new CRM-module keys (`bills`,
  `businessProfile`, `calendar`, `contracts`, `crm`, `crmDev`,
  `crmSettings`, `dealLineage`, `eventReminderOverride`,
  `hoursLogging`) are not yet translated and will fall back to
  English — same posture as FR / NL / PT / RU / ES currently have
  for the CRM module (see PR #555 description).
- **`frontend/src/components/common/LanguageSelector.tsx`** — adds
  `SLFlag` SVG component + registers `{ code: 'sl', name:
  'Slovenščina', Flag: SLFlag }` in `SUPPORTED_LANGUAGES`. Frontend
  i18n auto-discovers locale files via `import.meta.glob` so no
  separate config registration is needed.
- **`backend/migrations/core/108_seed_sl_email_template_translations.js`** —
  contribution-author's `107_*` filename renumbered to `108_` to
  avoid collision with `107_crm_consolidated.js` that landed on beta
  in the meantime. Idempotent insert via (template_id, language)
  uniqueness check — re-runnable, never overwrites admin edits.
  Covers 17 templates: admin invitation / password reset, archive
  complete, backup completed / failed, customer gallery assigned,
  customer invitation / password reset, database backup completed /
  failed, expiration warning, gallery created / expired, restore
  completed / failed, version update available / test.
- **`backend/src/services/emailProcessor.js`** — adds `.si → sl` to
  the email-domain → language inference map, matching the pattern
  for every other supported locale. A customer with `@example.si`
  now gets Slovenian emails automatically without needing to set
  their preferred_language explicitly.

## Out of scope (consistent with existing locales)

- CRM email templates (quote_sent, invoice_sent, contract_sent, etc.,
  seeded at boot by `crmEmailTemplates.ensureCrmEmailTemplatesSeeded`)
  will fall back to English for Slovenian customers — those seeders
  only emit EN + DE rows today across every locale.
- CRM UI strings under the missing top-level keys listed above will
  fall back to English.

Both gaps mirror the existing FR / NL / PT / RU / ES situation.
2026-05-29 21:54:55 +02:00
Paul Nothaft 975a815f99 Merge branch 'beta' into fix/email-normalization-574
Resolves a conflict with the CRM merge (#555) that landed on beta
between when this branch was cut and now.

Two conflict regions in backend/src/routes/adminCustomers.js:

1. **Require block** — both branches added new requires after
   customerAccountsService. Kept both: this branch's
   emailNormalization import AND beta's customerHoursService +
   invoiceService imports (the CRM merge added the hours-billing +
   invoice-creation paths to this router).

2. **Edit-customer validators** — both branches changed the same set
   of body() validators in the PUT /:id handler. This branch added
   the IDENTITY_PRESERVING_NORMALIZE_EMAIL options arg to
   normalizeEmail; beta changed every body() to optional({ nullable:
   true }) so passive-customer records that store nulls for missing
   profile fields don't reject on save. Kept both: the nullable
   pattern from beta + the email-normalization options from this
   branch. Preserved beta's explanatory comment about the nullable
   choice.

Also patched one NEW normalizeEmail site the CRM merge introduced:

- backend/src/routes/adminCustomers.js:231 — POST /admin/customers
  now exists (CRM-era customer-create endpoint). Same options arg
  applied.

backend/src/routes/adminBusinessProfile.js has an isEmail() WITHOUT
normalizeEmail() on the issuer email — intentional (no normalization
means no risk of the Gmail dot-strip bug for that field), no change
needed.

All 18 normalizeEmail sites now pass IDENTITY_PRESERVING_NORMALIZE_EMAIL.
7/7 regression tests still pass. Lint clean on the merged file.
2026-05-29 21:48:42 +02:00
Paul Nothaft 2692e71297 docs(contributing): note rebuild-after-package.json gotcha for dev compose
When PR #555 (CRM module) added pdfkit/swissqrbill/pdf-lib/qrcode to
backend/package.json, every dev with an already-built dev image hit
a MODULE_NOT_FOUND restart loop on the next pull. Root cause: the dev
compose bakes node_modules into the image while live-mounting src/
from disk — a dep added on disk isn't visible to the running container
until the image is rebuilt.

The symptom doesn't point at the cause, so this adds a short rebuild
note to the Local Development section of CONTRIBUTING.md. A
self-healing entrypoint (compare node_modules/.package-lock.json
vs /app/package-lock.json on boot, npm ci if they differ) would fix
this at the runtime layer too; tracked as a follow-up.
2026-05-29 21:43:49 +02:00
Luca 5b3bfed144 revert(docker): drop /backup chown from wait-for-db.sh
The fix shipped in 3ab3756 added /backup to the boot-time chown list.
That broke installs that don't bind-mount ./backup:/backup — the
single greedy `chown -R /a /b /c /backup` returned non-zero on any
individual failure, exiting the script and putting the backend into
a restart loop.

Reverting to the upstream-stable version. The original EACCES at
backup time is better fixed by admins pointing the backup destination
at a writable path via the admin UI (e.g. /app/storage/backups,
which the script already chowns) rather than baking a /backup
assumption into every install's boot path.
2026-05-29 18:05:40 +02:00
Luca 3ab3756a56 fix(docker): chown /backup mount to nodejs on container startup
The docker-compose `./backup:/backup` mount was the only bind mount
not included in wait-for-db.sh's startup chown step. On a fresh
install (or any time the mount point is recreated), it stays
owned by root, and the nodejs (UID 1001) process running the
backup service gets EACCES when trying to mkdir under /backup.

Added /backup to both the chown list (root branch) and the
writable-check list (compose `user:` override branch), each guarded
by `[ -d /backup ]` so installs that don't use the bind mount —
native deployments, k8s with a different backup destination, etc. —
still boot cleanly.

Existing installs hit by this need a one-time host-side
  sudo chown -R 1001:1001 <host-mount-for-/backup>
because the on-disk ownership won't fix itself; the script only
chowns at startup, and the directory was already created with
the wrong ownership by Docker's mount-point auto-creation. From
this commit onward, fresh installs are correct from the first
boot.
2026-05-29 16:42:52 +02:00
Paul Nothaft c2dcd9ca84 docs(readme): list CRM module under Beta Features with own-risk disclaimer
PR #555 shipped the CRM module on beta. The README's "Beta Features
(Use at your own risk)" table is the right place to signal that the
feature exists, is opt-in, and carries non-trivial legal / financial
caveats — readers landing on the README should not first discover the
CRM by enabling its feature flags and bumping into the seeded
example contract bodies without warning.

Adds one row to the Beta Features table linking to
docs.picpeak.app/features/crm where the full disclaimers,
sub-feature pages, and admin-settings reference live.

CRM is intentionally NOT added to the top-of-README "Key Features"
list — those are stable, production-ready features. Mixing the beta
CRM in there would undermine the clear stable/beta distinction.
2026-05-29 16:39:11 +02:00
Paul Nothaft fd61416665 Merge pull request #578 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.57.0-beta.0
2026-05-29 16:26:46 +02:00
github-actions[bot] 463ef4d0fd chore(beta): release 3.57.0-beta.0 2026-05-29 14:25:27 +00:00
Paul Nothaft e537923857 Merge pull request #576 from the-luap/docs/release-cadence-565
docs(release): establish stable-channel cadence + promotion process
2026-05-29 16:24:59 +02:00
Luca 3f5d006625 fix(test-infra): scope databaseBackup fs.unlink stub so it doesn't leak
Line 205 of databaseBackup.test.js reassigned `fs.unlink` directly
(`fs.unlink = jest.fn(...)`), which permanently mutated the global
fs.promises module. Every test running after this in the same jest
worker process inherited the no-op stub, including
integration/storageBackend.test.js — whose LocalFsStorage.delete()
silently became a no-op, making the subsequent exists() assertion
flip from false to true.

Confirmed by adding a diagnostic patch to LocalFsStorage.delete:
post-await fsp.unlink, fs.existsSync(abs) returned true. unlink had
resolved without throwing but the file was still there → the unlink
was a mock.

Fix: jest.spyOn(fs, 'unlink').mockResolvedValue(undefined) + a
matching mockRestore() at the end of the test. Behaviour is
identical inside this test; the original fs.unlink is restored
when the test finishes, so subsequent tests get real fs.unlink
again.

Pre-existing issue — has been latent on upstream/beta forever.
Only surfaces consistently when CI load shifts jest's worker
allocation such that databaseBackup and storageBackend land in
the same worker process. This PR's extra integration test files
made that allocation deterministic locally and frequent enough on
CI to fail reliably.
2026-05-29 16:21:15 +02:00
Luca ecb2aeacf9 fix(test-infra): unref sessionTimeout cleanup interval so workers exit gracefully
The 5-minute session-sweep interval at sessionTimeout.js:17 fired at
module-load time without .unref(), so every jest worker that
transitively required this module (server.js → middleware → most
of the route layer) kept the event loop alive forever. The worker
then got force-killed on shutdown, surfacing as the longstanding
"worker failed to exit gracefully" warning at the end of every CI
run on upstream/beta.

Under enough I/O / memory pressure on a CI runner, the force-kill
could land MID-test rather than after the suite finished, taking
out whatever else was running on that worker — most visibly
integration/storageBackend.test.js on PR #555's runs.

.unref() makes the timer not keep the loop alive on its own.
Production behaviour is unchanged: the timer still fires every
5 min as long as anything else is holding the loop open (the HTTP
server, always).
2026-05-29 15:41:58 +02:00
Luca 614c8b9b8f test(backup-integrity): tolerate both knex .returning('id') return shapes
CI's SQLite returned `[N]` (plain int) from `.insert().returning('id')`
while local SQLite returned `[{ id: N }]` (object form). The brittle
`const [{ id }] = ...` destructure crashed on the int shape. Switched
to the unwrap pattern used by the existing crmDb test harness so the
suite runs on both PG and every SQLite/knex combo the project supports.
2026-05-29 13:25:36 +02:00
Luca 7e2feca12f feat(backup): UI for backup-integrity verifier — tab + post-restore CTA
Frontend half of the diagnostic shipped in 4812fcd. Adds:
  - BackupIntegrityCard component — runs the check on demand, surfaces
    the five summary counters (total / verifiedOk / existsButNoHash /
    missing / hashMismatches), and expands collapsible result tables
    for missing files + hash mismatches. existsButNoHash is exposed as
    a separate amber-toned bucket so admins can distinguish hash-
    verified evidence from existence-only at a glance — the latter is
    explicitly weaker in a legal dispute and the UI says so.
  - "Integrity" tab on BackupManagement, alongside the existing
    Dashboard / Configuration / History / Restore tabs. Card is
    portable — when the System Health page (backlog item) lands it
    can lift the component without changes.
  - Post-restore CTA on the RestoreWizard success card (D2 follow-
    through): "Verify document integrity now" button that switches
    the parent tab to Integrity. The audit trail captured at sign /
    issue time is worth nothing if the documents it refers to are
    missing from the restored copy — verifier surfaces that drift
    in one click before the admin trusts the restored state.
i18n strings added in EN + DE (per user_languages — only those two
are native; other locales fall back to the English defaults and
should be flagged for native-speaker review per
feedback_translation_flagging if anyone picks them up).
2026-05-29 13:11:43 +02:00
Luca 4812fcdec3 feat(backup): admin endpoint to verify CRM document-artefact integrity
Diagnostic for the bug fixed in a9280ea — confirms every *_path
column on quotes / contracts / invoices points at a file that
actually exists on disk and (where a *_sha256 column is set) the
file's bytes still hash to the expected value. Read-only;
on-demand only; no scheduler.
Per the design decisions locked in this PR's design call:
  D1 — on-demand only for v1; scheduling deferred until we have
       runtime data on large installs
  D2 — not auto-triggered after restore; surface a "verify
       integrity now" CTA on the restore-completed screen instead
  D3 — wet-upload contracts hash-verified same as system-rendered
       (signed_pdf_sha256 is computed at upload time, no special
       case needed in the verifier)
Coverage (single source of truth in backupIntegrityService.CHECKS):
  quotes.pdf_path                           existence
  contracts.pdf_path + pdf_sha256           existence + hash
  contracts.signed_pdf_path + signed_pdf_sha256  existence + hash
  contracts.signed_customer_signature_path  existence  (PNG/JPG, no hash)
  contracts.signed_admin_signature_path     existence  (PNG/JPG, no hash)
  invoices.pdf_path                         existence
  invoices.imported_pdf_path                existence  (admin-uploaded scans)
Report shape buckets each row into verifiedOk / missing /
hashMismatches / existsButNoHash so callers can distinguish hash-
verified from existence-only — the latter is weaker evidence in
a legal dispute and the UI should reflect that.
Route GET /api/admin/system-health/backup-integrity accepts an
optional ?scope= CSV filter (quote | contract | contract-signature
| invoice). Unknown scope tokens are rejected with a 400 +
BACKUP_INTEGRITY_UNKNOWN_SCOPE code rather than silently scanning
everything.
Frontend half (BackupIntegrityCard on a System Health page) is
deferred until backlog #11 (System Health page) is scaffolded.
The endpoint is independently useful via curl in the meantime.
2026-05-29 13:00:18 +02:00
Luca a9280ea9ba fix(backup): include storage/business-docs/ in the in-app backup walker
backupService.getFilesToBackupInternal() enumerated a fixed list of
storage subdirectories (events/active, events/archived, thumbnails,
previews, heroes, uploads) and silently omitted the entire
business-docs/ tree. Every CRM PDF artefact and signature image fell
outside the in-app scheduled backup — restoring the DB without the
PDFs would have left every *_path column on quotes/contracts/invoices
as a broken FK and lost forensic evidence (the customer signature
PNG/JPG drawn on the public signing page is referenced by
contracts.signed_customer_signature_path; the rendered contract PDF
is referenced by signed_pdf_path with a stored signed_pdf_sha256
that would have nothing to verify against; wet-uploaded contracts
and admin-imported historical invoices are irrecoverable by design
since no renderer can reproduce them).
Single new scanDirectory call after the existing uploads scan,
covering:
  - business-docs/quote/<year>/*.pdf
  - business-docs/contract/<year>/*.pdf
  - business-docs/contract/signatures/<contract_id>/*.{png,jpg}
  - business-docs/invoice/<year>/*.pdf
  - business-docs/invoice-imports/<year>/*.pdf
  - and incidentally business-docs/dev-test/ (managed by adminDev.js,
    bounded to 7 newest files, harmless to back up)
Verified that no migration is needed: hasFileChanged returns
!existing || checksum mismatch, so the first backup after this lands
flags every business-docs/** file as new and copies it. Restore path
in restoreService.performFilesRestore uses fs.mkdir({ recursive:
true }) on path.dirname(targetPath), so business-docs subdirectories
are recreated automatically from manifest entries — no restore-side
code change required.
Integration test pins the contract so a future refactor cannot
silently drop business-docs again.
The shell-script backup at scripts/backup.sh already covered all of
this via blanket `tar -czf storage`; only the in-app service was
affected.
2026-05-29 12:50:02 +02:00
Paul Nothaft 075b45f020 fix(email): preserve dots + subaddresses across all normalization sites (#574)
Closes #574.

Reporter (@blazmaric) identified the root cause cleanly:
express-validator's `.normalizeEmail()` applies provider-specific
canonicalization by default — Gmail dot-stripping, +tag stripping,
googlemail → gmail folding, etc. That's wrong for identity: PicPeak
uses email as a login identifier, so `john.doe@gmail.com` getting
silently stored as `johndoe@gmail.com` means the user can't log in
with the address they were invited with.

The bug existed at 17 call sites across the codebase (auth, admin user
create/update, customer create/update, event create/update on three
different routes, customer login, feedback submission). All of them
are identity-bearing — none had a legitimate reason to strip dots
for deduplication.

Fix: introduce one shared options object in `utils/emailNormalization`
disabling every provider-specific normalization
(gmail_remove_dots, gmail_remove_subaddress,
gmail_convert_googlemaildotcom, outlookdotcom_remove_subaddress,
yahoo_remove_subaddress, icloud_remove_subaddress). The only default
left enabled is `all_lowercase`, which is safe — local-parts are
case-insensitive in practice on every major provider, and lowercasing
keeps login lookup consistent.

Every call site updated to pass the shared options. 7 unit tests pin
the preserved-dots, preserved-subaddress, preserved-googlemail-domain,
and still-lowercase behaviours so a future refactor can't silently
regress.

## Migration note

Existing accounts whose emails were already stripped before this fix
remain with the stripped form in the DB. The fix takes effect for new
invitations going forward. If an admin re-invites an existing user
with the un-stripped address, that would create a duplicate account —
out of scope here; if it becomes a real problem we can add a
backward-compat login fallback (try lookup with dot-stripped form too)
as a separate change.
2026-05-29 11:42:25 +02:00
Paul Nothaft 48cf1121e5 Merge pull request #575 from the-luap/feat/clickable-version-links-566
feat(admin): clickable version links + update-available modal with changelog & upgrade command
2026-05-29 11:35:06 +02:00
Paul Nothaft 43fe0eb70d Merge pull request #577 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.56.0-beta.0
2026-05-29 11:34:48 +02:00
github-actions[bot] 84486c65f1 chore(beta): release 3.56.0-beta.0 2026-05-29 09:27:47 +00:00
Paul Nothaft 5f0fcc225c Merge pull request #555 from Luca-Timo/feat/crm-pr
feat: CRM module — quotes, contracts, invoices, hours, calendar, tax
2026-05-29 11:27:23 +02:00
Paul Nothaft 832f7bad45 feat(admin): update-available modal with aggregated changelog + upgrade command (#567)
Closes #567.

The sidebar already had a "vX.Y.Z available" indicator (#566 made it a
link to that release's page) but there was no way to read the actual
changelog inline or to grab a copy-paste upgrade command. This adds
the modal the issue spec'd, layered on top of the existing
updateCheckService / environmentService backend infrastructure that
already shipped.

## Backend

- `updateCheckService.fetchAvailableVersions` now returns full release
  objects (tag, name, body, publishedAt, htmlUrl) instead of just
  version strings — body data is what the changelog modal renders.
  `checkForUpdates` extracts the version strings for its existing
  consumers; no API change visible to callers.
- New `getReleasesSince(currentVersion, channel)` returns the list of
  releases strictly newer than current, filtered to the user's
  channel. Reuses the same 1-hour cache as `checkForUpdates` so the
  modal opening doesn't trigger an extra GitHub round-trip.
- New `GET /admin/system/updates/changelog` route in `adminSystem.js`,
  same auth + UPDATE_CHECK_ENABLED gating as the existing
  /updates and /updates/instructions endpoints.
- 4 unit tests (axios mocked) pin: strictly-newer filtering,
  channel-scoped, empty array on GitHub fetch failure, empty array
  when already on latest.

## Frontend

- New `UpdateAvailableModal.tsx` — opens from the sidebar chip. Two
  sections:
    1. **How to upgrade** — fetches /updates/instructions for the
       environment-detected copy-paste command (Docker compose / git /
       standalone). Copy-to-clipboard button per step.
    2. **Release notes** — fetches /updates/changelog for every
       version between current and latest in the user's channel.
       Latest is auto-expanded; older releases are collapsed by
       default (click to expand). Each release also has a "View on
       GitHub" link to the canonical release page.
  - Renders release body markdown through the existing safe
    MarkdownContent component (marked + DOMPurify allowlist).
- New `updateDismissal.ts` helper — single localStorage key holds the
  last-dismissed version. Chip stays hidden until a STRICTLY newer
  version appears, using the same compare semantics as the backend
  (stable > beta, higher beta > lower beta, semantic numeric on
  major.minor.patch). 9 unit tests pin the rules.
- `VersionInfo.tsx` — chip is now a button that opens the modal
  instead of an external link (the #566 link-to-release behaviour is
  preserved on the modal's per-release "View on GitHub" affordance).
  Dismissal triggers an immediate re-render so the chip disappears
  without waiting for the next route change.

No new dependencies — uses `marked` + `DOMPurify` that were already
present in the bundle for the contract block renderer.
2026-05-29 11:26:24 +02:00
Paul Nothaft ab81998996 docs(release): establish stable-channel cadence + promotion process (#565)
Closes #565.

Beta has been the de-facto stable channel because the actual stable
lagged so far behind that new users following the README ended up
worse off than users who knew to switch to beta. The fix has two
parts: regular stable cuts (the PR #568 promotion is the first one)
and a written process so future cuts don't depend on memory.

This adds:

- RELEASING.md at the repo root — full operational doc with cadence
  target (4–6 weeks), promotion criteria (CI green + 7-day bug soak
  + upgrade-walk on real-shaped data + operator smoke), the actual
  beta→main mechanics including the conflict-resolution checklist we
  used in PR #568, hotfix backport path (with PR #412 as the worked
  example), and the project's versioning rules.
- CONTRIBUTING.md — replaces the four-line "Release Process" stub
  (which was wrong; it described a hand-rolled flow that release-please
  has handled for the last several releases) with a brief summary and
  a pointer to RELEASING.md.
- README.md — one-sentence addition to the existing "Release Channels"
  section pointing curious users at RELEASING.md.

No code change. CHANGELOG.md and version files are intentionally
untouched — release-please will catch this on the next regular cut.
2026-05-29 11:18:45 +02:00
Paul Nothaft d231623c59 feat(admin): link version numbers in sidebar to GitHub release notes (#566)
Closes #566.

The admin sidebar showed the running frontend + backend versions as
plain text. Wraps each version (and the "update available" indicator)
in an anchor pointing at the corresponding GitHub release tag, opening
in a new tab so the admin session isn't disrupted.

A small githubReleaseUrl helper (extracted to its own module for
testability) does the version → URL mapping. Because release-please
tags every release as `vX.Y.Z[-beta.N]`, the version string already
carries the channel suffix and a pure template covers both stable and
beta without branching.

Three unit tests pin the URL template — stable, beta-with-suffix, and
a defensive check that the leading `v` isn't double-prefixed if a
caller accidentally passes a tag-shaped value.
2026-05-29 10:25:12 +02:00
Luca d1aecaa180 fix(crm): thread trx through sequence-claim sites to unblock SQLite
Reviewer feedback on #555: nextQuoteNumber inside createQuote's
db.transaction was called without passing the outer trx, so
claimNextSequence opened its own connection — Postgres tolerated this
via the pool, SQLite (1-connection default) deadlocked on every quote
creation.
Audited the same pattern across invoiceService + contractService and
found five more matching call sites:
  - createInvoice (single-row path after installment auto-route)
  - spawnInstallmentInvoices (per-sibling claim inside the loop)
  - createStorno
  - createContract
  - createFromQuote
All now thread trx through to nextXxxNumber → claimNextSequence so
the claim joins the caller's transaction on both engines.
convertToInvoiceOnly's Path B (standalone-contract) is the lone
remaining nextInvoiceNumber() call without trx — that path isn't
wrapped in a transaction at all (separate concern: sequence-number
leak on insert failure, tracked separately).
2026-05-27 22:08:38 +02:00
Luca 5ce0b6edc3 fix(quote-response): compute minutes-remaining for the DE changeWithin string
Previous fix (45f0606) papered over the bug by changing the German
wording from "innerhalb von {{minutes}} Minuten" to "bis {{at}}" —
that worked but changed the UX intent. The original German wording
("you have N minutes left") was deliberate and clearer than an
absolute clock time; the actual bug was that no caller ever computed
`minutes` from `responseLockedAt`.

Revert the DE translation to its original wording, then build a
{ at, minutes } object at the call site so EN ("until {{at}}") and
DE ("innerhalb von {{minutes}} Minuten") each pick up the variable
they need. `minutes` rounds UP so a 14m 32s remainder displays as
"15 Minuten" rather than promising 14 the customer can't actually
hit.
2026-05-27 16:06:35 +02:00
Paul Nothaft 3ceeccd85a Merge pull request #562 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.55.0-beta.0
2026-05-27 16:01:01 +02:00
github-actions[bot] fd8ee5d52c chore(beta): release 3.55.0-beta.0 2026-05-27 13:53:11 +00:00
Paul Nothaft e016f510b6 Merge pull request #561 from the-luap/fix/android-download-latency-554
fix(lightbox+events): Android download lag, multi-photo Web Share re-land, theme branding inheritance
2026-05-27 15:52:44 +02:00
Paul Nothaft d5a37df2c4 fix(events): preserve branding inheritance when saving events with null color_theme
API-created events (and any event whose `color_theme` is NULL) had two
visible bugs in the admin edit page (#550 follow-up — PR #552 fixed the
v1 POST write path, this fixes the read/save path):

1. The theme picker initialised to the hardcoded `GALLERY_THEME_PRESETS
   .default.config` ("Classic Grid", green) — which had nothing to do
   with the admin's actual branding palette, while the gallery itself
   was rendering with the branding theme. Confusing visual mismatch.
2. Saving the event for ANY reason (changing the date, password, etc.)
   wrote `color_theme = 'default'` back to the row because the save
   handler always emitted the picker's initial preset name. That
   silently replaced "inherit from branding" with the literal Classic
   Grid preset, so the gallery's visuals jumped.

Two fixes, both in EventDetailsPage:

- Add a `themeChanged` flag, defaulted false. Flip in the picker's
  onChange / onPresetChange / onSyncFromBranding callbacks. The save
  handler now only writes `updateData.color_theme` when the flag is
  true, so saving without touching the picker preserves NULL.
- When `event.color_theme` is null and `publicSettings.theme_config`
  (the site branding) is available, initialise `currentTheme` from
  branding instead of the Classic Grid preset, with currentPresetName
  set to 'custom' (since inherited branding isn't a named preset).
  Falls back to the Classic Grid preset only when no branding theme
  exists either.

Combined effect: opening an API-created event shows the same palette
the gallery uses, and saving without changing the theme preserves the
inheritance. Existing events with a stored color_theme are unaffected
(themeChanged stays false → no write, just like before for the
common no-change-to-theme save).
2026-05-27 15:44:41 +02:00
Luca 45f0606ea3 fix(i18n): align quoteResponse.changeWithin DE placeholder with call site
The German string used `{{minutes}}` while the call site at
QuoteResponsePage.tsx:300 passes `{ at: <localized time> }`, matching
the English string's `{{at}}`. Result on the public quote page when
the customer had already responded: the literal text "{{minutes}}"
rendered instead of the unlock time.

Switched the German wording to match the English semantics
("until X:XX") since the underlying value is an absolute time, not a
minutes-remaining count — the previous DE wording was also wrong about
WHAT the variable meant.
2026-05-27 15:38:35 +02:00
Paul Nothaft d5823c79d9 feat(lightbox): multi-photo Web Share save-to-Photos on iOS (#557)
Extends #531 to the selection-based bulk-download flow. On iOS with a
selection at or under MAX_WEB_SHARE_FILES (25), galleryService
.downloadSelectedPhotos now routes through navigator.share({ files })
so the photos land directly in Photos via the share sheet's "Save N
Images" action. Above the cap, anywhere off-iOS, or on any failure,
the existing server-side zip path runs unchanged.

The 25-file cap is the empirically-safe ceiling: iOS Safari's share
sheet starts choking beyond ~25–30 files, and every File materialises
as an in-memory Blob before share() is invoked, so a 500-photo
selection would buffer multiple GB on the device.

trySaveMultipleToDevice exposes three outcomes:
- 'shared'    — share() resolved; flow ends
- 'dismissed' — user cancelled (AbortError); flow ends without zip
                fallback so dismissal isn't silently overridden
- 'fallback'  — capability missing or unexpected failure; caller
                takes the zip path

Partial shares are deliberately avoided: a single failed photo fetch
collapses the whole selection back to the zip endpoint rather than
sharing only the photos that resolved.

All 4 grid callers (PhotoGrid, PhotoGridWithLayouts, GalleryStoryLayout,
GalleryPremiumLayout) funnel through downloadSelectedPhotos, so no
caller-side changes are needed. Android, desktop, Firefox, and
"Download All" are untouched.

Layers on top of #556 (iOS-only gating via isIOS()). Builds against the
fix/android-download-web-share-554 branch.
2026-05-27 15:37:52 +02:00
Luca 83933baeec fix(crm): self-heal missing CRM email templates at boot + recover queue
The CRM template seeders (crmEmailTemplates / contractEmailTemplates /
eventReminderTemplates) were idempotent and ready, but only
contractEmailTemplates was actually called (lazily, by contractService
sends). crmEmailTemplates had no caller anywhere — every install that
didn't pre-exist its templates failed every quote_sent / invoice_sent /
storno_issued / invoice_reminder_* send with "Email template '<key>'
not found". The queue processor retries 3 times then leaves the row
in status='pending', retry_count=3, silently dead with no admin
surface (see project_crm_backlog for the eventual System Health page).

Fix: wire all three seeders into server.js startServer() right before
startEmailQueueProcessor. The new _emailTemplateBoot.js orchestrates
all three and then, for any template_key it just inserted, resets
retry_count on stuck email_queue rows of that email_type so the
queue processor's next tick picks them back up. Recovery is targeted:
unrelated retry-exhausted rows (e.g. SMTP-timeout failures) are not
touched.

Integration test boots a fresh CRM DB, pre-seeds a stuck quote_sent
row plus an unrelated stuck row, runs the boot helper, and asserts:
templates landed, stuck quote_sent row was reset, unrelated row was
left alone.

Already-deployed installs heal automatically on the next backend
restart after this lands.
2026-05-27 15:18:29 +02:00
Luca 09c5110d2b feat(crm): route billing docs to billing_email when set
Wires customer_accounts.billing_email into the invoice, Storno, and
payment-reminder send paths. Previously the column existed on the
schema and the customer-detail page rendered an input for it, but no
send path read it — every outbound email landed on customer_accounts.email
regardless. That mismatch is the failure mode flagged in
feedback_data_driven_completeness: a UI field that promises behavior
the backend silently doesn't deliver.

Routing matrix:
  - invoice / Storno / payment reminder
      To: billing_email (fallback email when unset)
      CC: email (when billing_email took the To slot) + per-doc cc_pdf_email
  - quote / contract / event reminder / gallery share
      To: email (unchanged — decision-maker address)
  - payment-check / paid-notification
      To: admin contact (unchanged — internal flow)

A new resolveBillingRecipients helper centralises the rules:
prefer billing_email, dedupe addresses case-insensitively, keep
per-doc cc_pdf_email as a supplemental CC. Lives in its own file
(_billingRecipients.js) to match the _renderContext.js convention.
2026-05-27 14:04:15 +02:00
Paul Nothaft 04795219a0 fix(lightbox): eliminate download lag on Android by skipping the blob round-trip
`savePhotoToDevice` previously buffered the full image through JS as a
Blob on every platform before clicking <a download>. On cellular this
added ~5s of dead air between the button press and the browser's
download dialog, prompting users to re-click and produce duplicate
downloads (#554 follow-up, post-#556).

The blob round-trip is only required for the iOS Web Share path
(`navigator.share({files})` needs File objects in hand). On Android and
desktop the browser can fetch the download URL itself and show its own
progress in the notification shade — instantly. So iOS keeps the
existing flow; everywhere else gets a direct anchor navigation.

The new `triggerDirectDownload` helper uses `api.getUri()` so the path
also works in split-origin deployments (where the existing hardcoded
`/api/...` pattern used by `downloadAllPhotos` would 404).

Tests updated: Android / desktop / regular-Mac branches now assert that
`fetchPhotoBlob` is NOT called and `triggerDirectDownload` is invoked
with a `/gallery/{slug}/download/{id}` URL. iOS tests unchanged.
2026-05-27 13:16:36 +02:00
Luca 3d37324080 feat(crm): allow negative line items for manual discount/Rabatt rows
Drops the isInt({ min: 0 }) constraint on lineItems.*.unitPriceMinor
in both the adminInvoices and adminQuotes POST/PUT validators so
admins can add Treuerabatt / Frühbucherrabatt rows as standalone
negative-priced lines (matches standard DE/CH invoice practice).

A service-layer guard rejects saves whose computed total goes below
zero (INVOICE_TOTAL_NEGATIVE / QUOTE_TOTAL_NEGATIVE, both 400) so a
mis-typed discount can't accidentally mint a credit-balance invoice
that would masquerade as a regular row in dashboards. Credit notes
still belong in the Storno path (createStorno), which is unchanged.

Quote-side integration coverage is omitted for now — createQuote's
cold-require path takes ~30s under the test harness; the invoice
test exercises the same validator + guard shape.
2026-05-26 23:54:50 +02:00
Paul Nothaft 9ae1f79769 Merge pull request #559 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.7-beta.0
2026-05-26 22:54:48 +02:00
github-actions[bot] 54b185db47 chore(beta): release 3.54.7-beta.0 2026-05-26 20:54:29 +00:00
Paul Nothaft 578397bc6b Merge pull request #556 from the-luap/fix/android-download-web-share-554
fix(lightbox): restrict Web Share save-to-Photos path to iOS (#554)
2026-05-26 22:54:06 +02:00
Paul Nothaft 2a309c75a7 fix(lightbox): restrict Web Share save-to-Photos path to iOS (#554)
PR #531 routed the single-photo download through navigator.share()
whenever canShare({files}) returned true, on the assumption that any
mobile share sheet would expose a "Save Image" action. That holds on
iOS — Safari's share sheet has a first-party "Save to Photos" entry —
but on Android the system share sheet only lists installed apps that
registered an image/* intent (WhatsApp, Telegram, Drive, etc.). There
is no built-in save-to-Gallery action, so Android users tapping the
download button got an app-picker instead of the file saved to their
device.

Fix: gate the Web Share branch behind a UA-based isIOS() check. Android,
desktop, and everything else fall through to the existing <a download>
path (file lands in Downloads, visible in the Photos / Gallery app
afterwards — same behaviour as before #531). iOS — including iPadOS
13+, which reports as MacIntel + touch — keeps the share-sheet flow
that drops directly into Photos.

UA-sniff is the only available signal here: canShare({files}) is true
on both iOS Safari and Chrome Android, so feature detection cannot
distinguish them.

Tests pin all six scenarios — iOS share path, Android download fallback
(even with canShare=true), desktop, iPadOS-as-Mac detected as iOS, regular
Mac NOT detected as iOS, AbortError dismissal preserved (no surprise
fallback), and non-Abort share() rejection falls back to download.
2026-05-26 22:37:01 +02:00
Luca 6d302e7998 feat(crm): add event_reminder_* templates to dev email tester
The pre-event reminder feature shipped with 5 seeded templates
(event_reminder_default + wedding/birthday/corporate/other) but the
CRM → Development "Send any CRM email to me" picker only listed the
quote/invoice/contract templates. Maintainer can now eyeball each
reminder category's body without staging a real event.

Backend:
- Extend TEMPLATES_KEYS in adminDev.js with all 5 reminder keys.
- Add event_date (today+2d), days_before (2), business_name (from
  business_profile.legal_name) to the common payload so the
  {{tokens}} in the reminder bodies resolve.

Frontend:
- Extend CrmEmailTemplateKey union.
- Add TEMPLATE_LABEL_KEYS entries.
- EN+DE i18n labels under crmDev.templates.label.event_reminder_*.

No PDF attachment — reminders are body-only emails (matches the
real flow).
2026-05-26 20:44:11 +02:00
Luca b064aab8ef feat(crm): unlock reminderEmails feature flag in Features tab
The full reminder-email implementation (eventReminderService,
eventReminderTemplates self-heal, ReminderTemplatesPage,
EventReminderOverrideCard) shipped in the CRM bundle but the
FeaturesTab card kept lockedReason=NOT_YET_AVAILABLE — so the
working feature was invisible.

Flip the card to the same shape as customerPortal: status="beta",
real setFlag handler, no disabled/lockedReason. The sub-tab in
Settings → Reminder templates already self-mounts when the flag
is on, and the per-event override card already self-renders on
the event detail page.

Description copy + EN/DE i18n updated to describe what the feature
actually does (per-category pre-event nudge) instead of the old
"coming soon" placeholder.
2026-05-26 20:31:14 +02:00
Luca 3240137f1e test(crm): integration harness + schema-shape regression net
Adds two pieces:

- __tests__/integration/helpers/crmDb.js — boots a temp-SQLite test
  DB by invoking every migrations/core/*.up() directly. Bypasses
  knex's Migrator because its exclusive write lock deadlocks
  001_init's nested initializeDatabase() call. ~1 second cold start.

- __tests__/integration/crmSchema.test.js — 36 assertions on the
  table + column layout after the consolidated CRM migration runs.
  Pins:
    - every CRM table present (quotes, contracts, invoices + the
      eight supporting tables)
    - deal_uuid columns on all three lineage tables (the column
      DocumentLineageCard joins on — drop it anywhere and the card
      silently returns partial data)
    - back-pointer FKs (converted_contract_id, source_contract_id,
      source_quote_id) — the exact columns that triggered the
      Postgres FK-ordering bug fixed earlier in this PR
    - Storno discriminator (kind, cancels_invoice_id, replaces_
      invoice_id) per feedback_storno_filter_everywhere
    - event time columns from migration 137

A full quote→contract→invoice lineage walk is deferred — quote
service's nextQuoteNumber() opens an inner transaction from inside
the createQuote outer transaction, which deadlocks SQLite's default
1-connection pool. Postgres dev DBs never see it. Either fix the
service to thread trx through, or run lineage tests against a real
Postgres in CI (mirror schema-drift.yml). Filed as separate work.
2026-05-26 19:50:57 +02:00
Luca 482043f786 ci: run backend Jest + frontend Vitest on every PR
The suites already existed (538 backend tests, 40 frontend tests, with
solid CRM coverage on quoteService/contractService/invoiceService/
customerHoursService/eventService.calendar) but no CI workflow invoked
them. Wire both into a single Tests workflow that triggers on any push
or PR to main/beta.

Six backend suites are excluded — they fail on upstream/beta too
(supertest fixture + knex mock chain issues unrelated to CRM). The
explicit ignore pattern keeps the workflow green on day 1; each
excluded suite is listed inline as test-infra debt to fix individually.

Backend job pins SKIP_S3_TESTS=true (the same default the test setup
file applies) so the backup-service integration doesn't try a real S3
round-trip when no MinIO is provisioned.
2026-05-26 19:08:48 +02:00
Luca b9cadf002c test(crm): update mocks for new createInvitation + OG date-format behavior
Two upstream tests regressed because the CRM PR added expected behavior
they didn't anticipate:

- galleryOgService.shareImage.test.js: formatEventDate is now async and
  routes through utils/dateFormatter so the OG card respects the admin's
  general_date_format setting (per feedback_respect_general_format_settings).
  That adds a third db('app_settings') call on every buildOgMetadata path.
  Mock the formatter module directly — the format itself is irrelevant
  to the cover-vs-logo contract this file pins.

- customerAccountsService.test.js: createInvitation now allows a duplicate
  email when the existing row is PASSIVE (password_hash IS NULL) — that's
  the "promote passive customer to portal" path. The active-customer
  rejection mock now has to set password_hash so the guard fires.

Both are test-only changes; no service code touched.
2026-05-26 19:07:31 +02:00
Luca 88a6b34c5e fix(migrations): defer cross-table FKs in 107_crm_consolidated
quotes.converted_contract_id and invoices.source_contract_id were
declared with inline FKs to contracts(id), but contracts is created
later in the same migration. SQLite accepted the forward reference;
Postgres rejected it ("relation \"contracts\" does not exist"), which
broke the Schema drift (#530) workflow and any fresh Postgres install.

Same pattern as events.hero_photo_id → photos.id in db.js: declare the
column without a constraint, then add the FK in a separate alterTable
after both sides exist. Wrapped in try/catch so re-runs against a DB
that already has the constraint are a no-op.

Verified locally against the #530 recovery scenario (initializeDatabase
then migrate:safe) and the fresh-install path: both converge cleanly,
both FKs land on the expected tables.
2026-05-26 18:49:38 +02:00
Luca e7db0bb866 docs(crm): legal/financial disclaimers — examples only
Adds a top-level disclaimer section to README + a dedicated
docs/crm-disclaimers.md spelling out two areas where picpeak ships
defaults the operator MUST review before going live:

1. Contract blocks (image rights, NDA, model release, cancellation,
   jurisdiction, …) — written by the maintainer, NOT by a lawyer.
   Every operator must have their lawyer review and adapt them
   before sending any contract to a customer.

2. QR-bills and SEPA EPC payloads — rendered from the data the
   operator typed. Picpeak is open source; we recommend scanning a
   test invoice with the operator's bank app to verify the QR
   actually works.

Matches the on-screen amber disclaimers already shown on the
Contract Block Library page and the Business Profile payment-block
editor.
2026-05-26 18:20:55 +02:00
Luca 409d414035 feat(crm): i18n EN + DE for CRM, machine fr/nl/pt/ru fallbacks
~940 new keys per primary locale covering every CRM surface:
quote / invoice / contract editor + list + detail + public response
pages, calendar, hours, tax report, deals lineage, reminder emails,
feature toggles, settings tabs, error toasts.

en.json + de.json are hand-translated by the maintainer and are
authoritative. fr / nl / pt / ru received the same key set but
machine-derived strings — flagged for native review in the PR
description per project policy (see memory feedback_translation_flagging).

3-way merge note: 1 conflict (fr.json) hand-resolved to keep
upstream's improved phrasing for previewLayout / livePreview /
heroPlaceholderText alongside feat/crm's pdfTypography keys.
2026-05-26 18:19:55 +02:00
Luca a7e16e7bf6 feat(crm): frontend code — pages + services + components
Brings in the full frontend CRM stack: admin authoring pages,
customer-portal surfaces, public response flows, typed services,
and the supporting component library. i18n locale JSON is the next
commit (kept separate so reviewers can read it as data).

Pages
  - Quotes: list / editor / detail / public accept-decline
  - Invoices: list / editor / detail / public payment-check
  - Contracts: list / editor / detail / block library / public sign
  - Calendar (FullCalendar — admin-only v1)
  - Tax report (period picker + CSV/PDF export)
  - Hours (logged time entries, per-customer)
  - Deals lineage (DocumentLineageCard surfaces)
  - CRM Development (admin dev tools, gated by crmDevelopment flag)
  - Customer-portal pages for quotes / invoices / contracts
  - Settings reorg: CRM-Settings group + dedicated tabs for Business
    Profile, CRM behaviour, Contracts block library, Reminder emails
  - BrandingPage typography (PDF font picker)
  - EventDetailsPage / CustomerDetailPage / CreateEventPage extensions
    (event-time fields, hours toggle, per-event reminder override)

Services (typed)
  - quotes.service, bills.service, contracts.service
  - customerAdmin.service, deals.service, calendar.service,
    taxReport.service, contracts-blocks.service
  - businessProfile.service (timezone, font picker, bank accounts)
  - useInstallmentDefaults hook, useLocalizedDate dateInputLang extension

Components (admin)
  - CustomerPicker (shared across quote/invoice/contract editors)
  - LineItemsTable (hierarchy + details_text, memoised pricing)
  - InstallmentsPanel (simple + advanced toggle, fixed-date vs trigger)
  - DocumentLineageCard (deal_uuid grouped view)
  - EditInstallmentPlanModal (atomic post-spawn plan reshape)
  - EventReminderOverrideCard, EmailTemplateEditor (tiptap),
    PdfFontPicker, IntegrityCheckCard
  - Feature-flag context + RequireFeature wrapper + AdminSidebar
    featureFlagsAny derivation + UI-hiding sweep

Build infra
  - vite.config: fullcalendar chunk carved off (~200 KB lazy-loaded)
  - frontend/package.json: tiptap, fullcalendar, signature_pad,
    react-international-phone, et al.
  - tailwind + prose styles updated for editor surfaces

3-way merge note: 1 conflict (CustomerDetailPage.tsx) hand-resolved
to keep upstream's SUPPORTED_LANGUAGES.map() data-driven pattern
over feat/crm's hardcoded option list; feat/crm's DecimalInput
import preserved alongside.
2026-05-26 18:19:32 +02:00
Luca d543949188 feat(crm): backend code — services + routes + utilities + tests
Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).

Services (CRM)
  - quoteService — full lifecycle (draft → sent → accepted → converted
    to event/invoice), Skonto + Storno + reissue paths
  - invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
    monthly-billing accumulator, payment-check tokens, dunning ladder
  - contractService — block-composable contract editor, in-browser
    signature flow, wet-PDF upload path, integrity check, audit trail
  - customerHoursService — per-entry locking, billing integration
  - dealsService — cross-document lineage (deal_uuid)
  - taxReportService — quarterly aggregates + CSV/PDF export
  - eventReminderService — pre-event customer reminder cron pass
  - _renderContext — shared issuer/recipient blocks across PDF types
  - pdfService extensions — custom-font registration, font picker

Routes (admin + public)
  - adminQuotes, adminInvoices, adminContracts, adminCalendar,
    adminDeals, adminTaxReport, adminDev, adminBusinessProfile
  - publicQuotes (accept/decline), publicContracts (sign),
    publicPaymentCheck
  - Extensions on adminEvents, adminCustomers, adminSettings,
    adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
    adminCategories, adminUsers, adminArchives, adminDashboard
  - server.js wires the new mounts (kept upstream's noStoreCache on
    customer routes per 3-way merge)

Utilities
  - schemaCache (cached hasColumn lookups across services)
  - documentSequences (atomic gap-free numbering — §14 UStG)
  - safePath (path-containment guards at fs stream boundaries)
  - clientIp (sanctioned XFF reader for audit logs)
  - publicTokenGuards (pre-multer token validation + attempt counters)
  - numericHelpers (ensureInt / ensureNumber consolidation)
  - dateFormatter (formatShortDate + dateInputLang)
  - dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile

Infrastructure
  - Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
    Montserrat / Noto-Sans / Playfair-Display / Poppins)
  - Backend package.json + lock updates (pdfkit, signature_pad,
    qrcode, et al.)
  - Sample storage layout under storage/business-docs/quote/

Tests
  - 14 new test files covering quote/invoice/contract lifecycle,
    installment plan reshape, line-item hierarchy, customer hours,
    payment check, tax report PDF, IBAN parsing, filename sanitiser
2026-05-26 18:18:51 +02:00
Luca 60abe8c76d chore(migrations): consolidate CRM migrations 102-143 + extract email-template seeds to self-heal services
Replaces what would have been 42 individual in-flight migrations
(102→143 on feat/crm) with one consolidated migration that creates
every CRM table in its final shape — no ALTER chains. Coexists with
upstream's pre-existing 102-106 by filename suffix; the runner sorts
within same-number groups.

Tables consolidated:
  - business_profile + business_bank_accounts (issuer block, fonts,
    PDF layout knobs, tax_id, timezone)
  - payment_term_templates (legacy) + payment_net_days_templates +
    payment_timing_templates (124's split)
  - quotes / quote_line_items / quote_line_item_presets / quote_action_tokens
  - invoices / invoice_line_items / invoice_payment_log /
    invoice_payment_check_tokens
  - contracts / contract_blocks (13 system blocks seeded) /
    contract_block_inclusions / contract_action_tokens
  - event_payment_plans, customer_hour_entries, document_sequences

ALTER on upstream tables (hasColumn-guarded):
  - events: quote_id, calendar columns (event_time_*, is_full_day),
    event_reminder_*
  - customer_accounts: billing_cadence/cycle_day, country_name,
    feature_hours_logging, hourly_rate_minor

Seeds:
  - RBAC perms (quotes/bills/contracts .view/.manage) + customers.create
    split into edit + events (mig 134)
  - Feature flags (quotes, bills, contracts, hoursLogging, taxReport,
    calendar, calendarBooking, reminderEmails, crmDevelopment, messaging
    — all default OFF)
  - 30+ CRM app_settings rows (skonto/QR/reminder windows, payment
    defaults, installment defaults, ToS, event reminder defaults)
  - 4 + 5 + 4 payment-term system rows across the legacy + split tables

Email-template content moves out of the schema diff into three
runtime self-heal service files that idempotently create missing
rows + backfill empty translations on first access (per the maintainer's
"never ship compensation migrations" rule):

  - backend/src/services/crmEmailTemplates.js (NEW) — quote_sent,
    quote_accepted_*, quote_declined_admin, invoice_sent,
    invoice_reminder_first/second, invoice_paid_receipt,
    invoice_cancelled, invoice_payment_check,
    invoice_paid_admin_notification, storno_issued
  - backend/src/services/contractEmailTemplates.js — contract_sent,
    contract_fully_signed, contract_signed_admin_notification
  - backend/src/services/eventReminderTemplates.js — event_reminder_default
    + per-event-type variants

Smoke-tested on fresh sqlite DB: 84 migrations apply cleanly,
all CRM tables present, seeds populated.
2026-05-26 18:18:15 +02:00
Paul Nothaft b5e7f9cec1 Merge pull request #553 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.6-beta.0
2026-05-26 11:29:23 +02:00
github-actions[bot] 08fa2e9b63 chore(beta): release 3.54.6-beta.0 2026-05-25 20:19:09 +00:00
Paul Nothaft 7ef0e40e7c Merge pull request #552 from the-luap/fix/v1-events-feedback-theme-550
fix(api/v1): accept color_theme + create feedback row on event create (#550)
2026-05-25 22:18:49 +02:00
Paul Nothaft 1b521e761c fix(api/v1): accept color_theme + create feedback row on event create (#550)
POST /v1/events was a strict subset of the admin create path: it did not
accept color_theme on the body, and it skipped the event_feedback_settings
insert that adminEvents.js does. Two visible bugs followed.

1. Editing an API-created event in the admin UI snapped the theme picker
   to GALLERY_THEME_PRESETS.default (EventDetailsPage.tsx falls through to
   the default preset when event.color_theme is falsy), and saving wrote
   that default back. Inherited themes were silently clobbered.

2. The "Enable Guest Feedback by default" admin setting (#520) did not
   apply to API-created events. With no event_feedback_settings row the
   gallery UI reads feedback as off, regardless of
   event_default_feedback_enabled.

Fix mirrors the admin path:

  - color_theme accepted on the request body (optional, persisted as-is —
    preset name or JSON-encoded ThemeConfig, same shape adminEvents
    stores).
  - feedback_enabled accepted on the request body; when omitted, falls
    back to the event_default_feedback_enabled global setting (same
    behaviour adminEvents.js:511-520 implements via readBooleanSetting).
  - event_feedback_settings row inserted when feedback resolves to true,
    using the same sub-flag defaults as the admin form (everything on
    except require_name_email).

OpenAPI JSDoc updated so docs.picpeak.app picks up the new fields.

Tests cover all four scenarios — explicit color_theme persisted, JSON
theme persisted verbatim, explicit feedback_enabled creates the row,
omitted feedback_enabled honours the global setting, and a validator
regression for non-boolean feedback_enabled.
2026-05-25 10:29:33 +02:00
Paul Nothaft ce1ccf0a4d Merge pull request #549 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.5-beta.0
2026-05-22 14:43:15 +02:00
github-actions[bot] 793aa1247e chore(beta): release 3.54.5-beta.0 2026-05-22 12:41:21 +00:00
Paul Nothaft b351d17ee9 Merge pull request #548 from the-luap/fix/nginx-forwarded-proto-547
fix(nginx): honour outer X-Forwarded-Proto when behind a reverse proxy (#547)
2026-05-22 14:40:55 +02:00
Paul Nothaft 5488de3383 fix(nginx): honour outer X-Forwarded-Proto when behind a reverse proxy (#547)
When PicPeak runs behind NPM / Traefik / Caddy, the inner nginx receives
plain HTTP from the outer proxy. The previous `X-Forwarded-Proto $scheme`
therefore always forwarded "http" to the backend, even when the public URL
was HTTPS. Express has `trust proxy` enabled for loopback/linklocal, so
req.secure became false, the Secure cookie flag wasn't set, and generated
URLs (cookies, tokens) used http://.

Add a top-of-file `map` block that picks the incoming X-Forwarded-Proto
when present and falls back to `$scheme` for direct access. Applied to both
nginx.conf (bundled production image) and nginx.dev.conf.

Validated with `nginx -t` against nginx:1.28-alpine (the same image used
by Dockerfile.prod / Dockerfile).
2026-05-22 13:32:01 +02:00
Paul Nothaft 6b6ac64346 Merge pull request #537 from rpintodasilva/imp/french-translation
French Transalation - v2
2026-05-22 10:30:44 +02:00
Paul Nothaft 27e9b0535e Merge pull request #545 from the-luap/chore/clawpatch-review-fixes
chore: address clawpatch review findings
2026-05-21 19:07:17 +02:00
Paul Nothaft dba98f1325 chore: address clawpatch review findings (test scope, deps, legal-page hardening)
- frontend: `npm test` now runs all 7 vitest suites instead of one hardcoded
  file; the previously skipped ProtectedImage / Skeleton / usePublicSettings /
  contrast / themeMigration / url suites are now active in CI
- frontend: wrap ThemeCustomizerEnhanced test in QueryClientProvider so the
  newly-enabled run passes (component uses useQuery internally)
- root: drop unused better-sqlite3 / canvas / node-fetch + their
  prebuild-install/tar-fs override (backend keeps its own copies); add dotenv
  so playwright.config.ts can load on a clean install; add name/version/private
- LegalPage: scheme-validate external_url before window.location.replace so a
  CMS edit can't redirect visitors to javascript:/data:
- LegalPage: force rel="noopener noreferrer" on target="_blank" anchors in
  sanitized CMS HTML to block reverse-tabnabbing
2026-05-21 17:10:34 +02:00
Paul Nothaft 1cf492f4b9 Merge pull request #544 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.4-beta.0
2026-05-21 10:24:50 +02:00
github-actions[bot] 955ada4945 chore(beta): release 3.54.4-beta.0 2026-05-21 08:24:22 +00:00
Paul Nothaft 9607b4666c Merge pull request #542 from the-luap/fix/recover-orphaned-527
fix: recover three orphaned commits from #527 (BRAND_TITLE runtime, Web Share, pan zoom)
2026-05-21 10:23:59 +02:00
Paul Nothaft 8c36f80471 Merge pull request #543 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.3-beta.0
2026-05-21 10:22:59 +02:00
github-actions[bot] c1e8e0d73a chore(beta): release 3.54.3-beta.0 2026-05-21 08:22:26 +00:00
Paul Nothaft 3e39112a12 Merge pull request #541 from the-luap/fix/lightbox-heart-icon-fill-538
fix(lightbox): fill the heart icon when liked (#538 follow-up)
2026-05-21 10:21:59 +02:00
Paul Nothaft efa6b4a205 fix(brand-title): runtime substitution so GHCR-image users can override (#521 follow-up)
@Rekoo-PS confirmed the prior #521 fix landed on beta but reported the
preview still shows the default "PicPeak" title — their brand is
"arkan-studio". Root cause: that fix used Vite's build-time
%VITE_DEFAULT_TITLE% substitution. Self-hosters running the pre-built
ghcr.io/the-luap/picpeak/frontend image can't override at build time
without rebuilding, so they were stuck with whatever the upstream
build baked in.

Pivot to runtime substitution: the frontend container now reads
BRAND_TITLE / BRAND_DESCRIPTION env vars on startup and envsubsts
them into index.html. Change the values in .env, restart the frontend
service, done — no rebuild required.

Mechanics:
  - frontend/index.html: tokens are now ${BRAND_TITLE} /
    ${BRAND_DESCRIPTION} (shell expansion syntax, passes through Vite
    unchanged into the built dist).
  - frontend/Dockerfile: install gettext (provides envsubst), snapshot
    /usr/share/nginx/html/index.html → index.html.tpl at build, install
    docker-entrypoint.sh, wire ENTRYPOINT to it. The .tpl is the
    immutable source — every container start re-renders index.html
    from .tpl, so restarts pick up new env values cleanly (no
    accidental "first-boot env stuck forever" trap).
  - frontend/docker-entrypoint.sh: applies defaults if env unset,
    runs envsubst (locked to BRAND_TITLE + BRAND_DESCRIPTION
    explicitly so /assets/*.js template literals aren't touched if
    anyone ever extends substitution to the bundle), execs nginx.
  - frontend/vite.config.ts: drop the htmlTitleDefaults plugin — no
    longer needed since substitution is fully runtime.
  - frontend/.env.example + .env.production.example: drop the
    VITE_DEFAULT_* docs (the vars no longer have effect).
  - docker-compose.yml + docker-compose.production.yml: pass
    BRAND_TITLE / BRAND_DESCRIPTION env into the frontend service
    with sensible defaults so unconfigured installs work unchanged.
  - .env.example: add BRAND_TITLE / BRAND_DESCRIPTION with comment
    pointing at the social-preview use case.

Verified end-to-end against the built image:
  - BRAND_TITLE="Arkan Studio" BRAND_DESCRIPTION="Wedding photographs
    by Arkan Studio" → index.html serves <title>Arkan Studio</title>
    + og:title="Arkan Studio" + og:description correctly substituted.
  - .tpl preserves ${...} tokens so the next restart can re-substitute.
  - Bundle assets unaffected.
  - Defaults applied when env unset → <title>PicPeak</title>.

Docs PR in picpeak-docs describes the two new env vars under
"Social link preview fallback" in the environment-variables reference.

Refs: #521
2026-05-21 10:00:21 +02:00
Paul Nothaft 53139b8cb8 fix(lightbox): pan zoomed image with single-finger touch on mobile (#532)
@Rekoo-PS reported zoom on mobile only shows the centre crop — the
image zooms but you can't pan around to see other parts. Single-finger
touch was being routed through the carousel-swipe branch which is
gated on zoom <= 1 (so swipe doesn't fight with pan), so when zoomed
the touch hit no handler at all.

Desktop has the equivalent path via handleMouseDown / handleMouseMove
(line 364), which is why this only manifests on mobile.

Add a single-finger pan branch to the touch handlers that mirrors the
mouse path:
  - handleTouchStart: when zoom > 1 and one finger, record dragStart
    relative to the existing dragOffset (so subsequent moves continue
    from where the last pan left off, not from origin).
  - handleTouchMove: when isDragging + zoom > 1 + one finger, update
    dragOffset from touch position.
  - handleTouchEnd: clear the isDragging flag (offset persists so the
    image stays where the user left it).

Also fix a latent bug surfaced while reading the pinch-zoom path:
when pinch-out drops zoom back to 1.0, dragOffset wasn't reset, so the
photo sat off-centre at natural zoom. Re-centre in handleTouchMove
when newZoom drops to <=1 with a non-zero offset.

Carousel swipe stays disabled when zoomed (existing behaviour). Mouse
path untouched. Pinch-to-zoom path untouched.

Refs: #532
2026-05-21 10:00:21 +02:00
Paul Nothaft b2bbf7efb5 feat(lightbox): save photo to Photos app on mobile via Web Share (#531)
@Jasper2213 reported non-technical clients struggle to get downloaded
photos into their Photos / Gallery app — current flow goes through the
Files folder, requires unzipping for the bulk download, and is hard to
explain over email. Browsers can't write directly to the OS Photos app
(it's a protected location), but navigator.share({ files: [...] }) opens
the native share sheet which on iOS includes "Save Image" and on Android
includes "Save to Photos" / "Save image" — exactly the affordance non-
technical users are looking for.

Plumbed through three layers:

1. galleryService — new savePhotoToDevice(slug, photoId, filename).
   Fetches the photo blob, probes navigator.canShare({ files: [file] })
   with a representative File (some browsers return true for empty
   files arrays even when they won't accept a non-empty one), and:
     - shares if supported,
     - falls back to the existing <a download> path otherwise.
   AbortError on share() means the user dismissed the sheet — that's
   a choice, not a failure, so no fallback. Any other error falls
   through to a regular download so the user still gets the file.
   Refactored the existing downloadPhoto to share the fetch + trigger
   helpers (no behaviour change for the other 3 callers; they keep
   the regular download path).

2. useGallery — new useSavePhotoToDevice() hook next to the existing
   useDownloadPhoto(). Onsuccess toast omitted because the share-sheet
   path doesn't finish from this code's perspective — the OS UI takes
   over and the user picks the destination, so "Photo downloaded" is
   misleading. Fallback path stays silent to keep the two flows
   symmetrical (the file appearing in Downloads is its own signal).

3. PhotoLightbox — swap the existing useDownloadPhoto call site to
   useSavePhotoToDevice. No UI change. Desktop unchanged. Other
   download buttons (PhotoGrid, PhotoGridWithLayouts, GalleryView
   bulk) still use useDownloadPhoto — scoping this PR to the
   lightbox download button per the discussion thread.

Browser support:
  - iOS Safari 15+:    Web Share Files → "Save Image" → Photos      ✓
  - Chrome Android:    Web Share Files → "Save to Photos" / "Save"  ✓
  - Desktop Chrome:    canShare returns false → regular download     ✓
  - Desktop Safari:    canShare returns false → regular download     ✓
  - Firefox (any):     no Web Share File support → regular download  ✓

No new tests — the flow is browser-API-driven; jsdom doesn't model
navigator.share or canShare, so a meaningful unit test would mostly
exercise the mock rather than the contract. Verified the build is
clean (tsc --noEmit + vite build both pass).

Refs: #531
2026-05-21 10:00:21 +02:00
Paul Nothaft 600c29db8a fix(lightbox): fill the heart icon when liked (#538 follow-up)
@Tietge86 spotted that both branches of the heart-icon className were
`text-white` — the conditional was a no-op, the `fill-current` class
that would actually fill the icon was missing entirely. The button
background was turning red on like, but the heart icon stayed as a
white outline against the red, making it nearly invisible.

Move text-white outside the conditional (always white against the
red/dark backgrounds the button uses), and add fill-current to the
liked branch so the heart fills in.

Same shape as bug 2 of the original report — the like state needed to
be visually unambiguous. PhotoLikes.tsx was already fixed in this PR;
this catches the equivalent latent bug in the inline lightbox toolbar
button.

Also: bug 4 of the original report (recovery flow) turned out to be
SMTP misconfig on the reporter's end (mailhog silently dropping
emails), not a PicPeak bug. Confirmed in this thread; no further
backend changes needed.

Refs: #538
2026-05-20 17:28:50 +02:00
Paul Nothaft 4d92fb4590 Merge pull request #540 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.2-beta.0
2026-05-20 16:49:45 +02:00
github-actions[bot] 03cf09a7bf chore(beta): release 3.54.2-beta.0 2026-05-20 14:49:19 +00:00
Paul Nothaft c900be92dd Merge pull request #539 from the-luap/fix/guest-feedback-multi-538
fix(feedback): three guest-mode bugs from #538 (filter, like state, count leak)
2026-05-20 16:48:36 +02:00
Paul Nothaft 5311588baf fix(feedback): three guest-mode bugs reported in #538
Picks up three of the four bugs from @Tietge86's report. Bug 4
(recovery flow) needs network-tab data from the reporter before a fix
makes sense; commented on the issue asking for the HTTP status from
POST /gallery/:slug/guest/recover.

Bug 1 — "Liked" filter empty in guest identity mode (GalleryView.tsx)

  The feedback filter was scoping by `photo.like_count > 0`, which is
  the global aggregate across all guests. In guest identity mode the
  filter intent is "show MY picks", so a guest who'd liked photos that
  nobody else had touched got an empty grid.

  Fix: pull the current guest's interactions from /my-feedback (already
  keyed by x-guest-token in the api interceptor) into per-type
  photo-id Sets and filter against those when identity_mode === 'guest'.
  Falls back to the aggregate-count check in simple mode where there's
  no per-person identity to scope by. Same per-guest scoping applied to
  the chip-count labels ("Liked (N)" etc.) so the chip number matches
  what the filter actually surfaces — otherwise the chip says one
  count globally and the filter shows a different (smaller) one, which
  is the same UX cliff #538 originally surfaced.

  The /my-feedback query is gated on isGuestIdentityMode (not on
  filterType being feedback-related) so the chip counts are populated
  on first render. One extra request per gallery load in guest mode;
  payload is tiny.

Bug 2 — Liked state on PhotoLikes button invisible

  bg-red-50 text-red-600 is barely visible against most themes,
  especially dark + brand-coloured backgrounds. Switch to the same
  filled state the lightbox toolbar already uses
  (bg-red-500/80 text-white) so the like registers visually.
  Heart icon's fill-current was already there for the liked state —
  unchanged.

Bug 3 — Aggregate like count leaks in lightbox toolbar

  PhotoLightbox.tsx rendered {likeCount} unconditionally next to the
  inline heart button. When the admin has show_feedback_to_guests off,
  guests still saw how many other guests had liked a photo (the count
  is an admin-only metric in that mode). Gate the span on
  feedbackSettings?.show_feedback_to_guests, matching how the rest of
  the lightbox toolbar treats that toggle. Also added show_feedback_to_guests
  to the local feedbackSettings TS type (backend already returns it).

Tests: tsc --noEmit clean. No new unit tests — bug 1 is data-flow
plumbing best validated via manual / e2e (the existing my-feedback
endpoint and feedbackService are already covered upstream); bugs 2/3
are CSS and a conditional render.

Refs: #538 (bugs 1, 2, 3 of 4)
2026-05-20 16:42:22 +02:00
rpintodasilva dae47518fb fixes 2026-05-20 13:13:35 +02:00
rpintodasilva fd15be6247 French Transalation - v2 2026-05-20 10:40:24 +02:00
Paul Nothaft cf10da29ca Merge pull request #536 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.1-beta.0
2026-05-20 08:54:14 +02:00
github-actions[bot] baf5f27fb4 chore(beta): release 3.54.1-beta.0 2026-05-20 06:53:31 +00:00
Paul Nothaft 83fc58a523 Merge pull request #535 from the-luap/fix/public-site-dark-theme-contrast
Fix public site contrast for dark themes
2026-05-20 08:53:02 +02:00
paul 8b72721812 fix(public-site): honor dark theme surface colors 2026-05-20 08:48:40 +02:00
Paul Nothaft e0ad7ac2a7 Merge pull request #534 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.0-beta.0
2026-05-20 08:24:46 +02:00
github-actions[bot] 085a2e5fed chore(beta): release 3.54.0-beta.0 2026-05-20 06:24:22 +00:00
Paul Nothaft a0ebc97cdd Merge pull request #533 from the-luap/feat/schema-drift-test-530
fix(install): skip legacy chain on recovery-state DBs + schema-drift CI (#530)
2026-05-20 08:23:57 +02:00
Paul Nothaft 4d3f2470bc ci(schema-drift): handle absent migrations table in precondition (#530)
First CI run failed at the precondition check because the SQL `CASE WHEN
to_regclass(...) IS NULL THEN 0 ELSE (SELECT count(*) FROM migrations)`
expression doesn't short-circuit at parse time — Postgres parses the
subquery against `migrations` even when the outer guard would skip it,
fails the run with "relation 'migrations' does not exist".

initializeDatabase() doesn't create the `migrations` tracking table —
that's the migrate:safe runner's responsibility — so in the recovery
scenario the table genuinely doesn't exist yet. Both "absent table" and
"present but empty table" are valid recovery states.

Split the check into two shell steps: to_regclass first, then count only
if the table exists. Avoids the parse-time subquery error and accepts
either state.
2026-05-19 22:53:30 +02:00
Paul Nothaft 8f0108ce23 feat(install): skip legacy chain when modern bootstrap fingerprint detected (#530)
Refined from the original #530 framing after a dry-run uncovered that the
"bootstrap vs migration chain" diff produces mostly noise — most of the
~200 lines of difference are expected (migrations add new tables and
columns over time). initializeDatabase() isn't a parallel path that
diverges from migrations; it's invoked by migration 001 itself, so every
normal install/upgrade runs both.

The genuine drift hazard surfaced during the dry-run: a DB with the
modern bootstrap tables but an empty `migrations` table (which happens
when a backup was restored that lost the migrations table, or someone
invoked initializeDatabase() outside the runner, or the DB was moved
between systems without copying the migrations row) fails to upgrade.

Failure mode:
  1. detectExistingSchema sees the bootstrap tables + empty migrations,
     treats it as an "existing deployment".
  2. Runs the legacy chain first.
  3. legacy/008 renames email_templates.subject → subject_en.
  4. core/029 (later in the chain) inserts email templates referencing
     the pre-rename `subject` column.
  5. Postgres rejects: column "subject" doesn't exist; subject_en is
     NOT NULL with no default.

Fresh installs avoid this because they only run core/* (and core/059
handles the rename AFTER core/029 has inserted). Real legacy upgrades
avoid it because their migrations table already records legacy/008–028
as applied historically.

Fix in detectExistingSchema:
  - Detect the modern bootstrap fingerprint (photo_categories + cms_pages
    both present, which initializeDatabase produces as part of the
    consolidated post-004-era bootstrap).
  - When matched, enumerate every file in migrations/legacy/ and mark
    each as applied. This puts the recovery state on the same code path
    fresh installs use — only core migrations run, in core order.
  - Real legacy upgrades that already have entries in the migrations
    table hit no-op markings (markMigrationAsApplied skips duplicates),
    so their behaviour is unchanged.

New CI workflow (`.github/workflows/schema-drift.yml`):
  - Boots fresh postgres.
  - Seeds via `node -e \"require('./src/database/db').initializeDatabase()\"`
    — reproduces the recovery state in one line.
  - Runs `npm run migrate:safe`.
  - Asserts: precondition (bootstrap fingerprint + empty migrations
    table), migrate:safe exits 0, final schema has ≥40 tables (soft floor,
    not exact pin so future migrations don't force workflow edits),
    legacy migrations marked applied (confirms the fingerprint check
    actually fired vs. the chain silently bailing).
  - Triggers only on PRs that touch backend/migrations/**,
    src/database/db.js, knexfile.js, or this workflow.

Manually verified end-to-end before this commit:
  Before fix:  migrate:safe dies at core/029 with NOT NULL violation
               on email_templates.subject_en (17/48 tables present).
  After fix:   82 migrations applied + 27 marked applied = 109 total,
               final state has all 48 tables matching fresh-install.

Issue body in #530 has been updated to match this refined scope.

Refs: #530, #484, #519
2026-05-19 22:48:54 +02:00
Paul Nothaft bdd973eaf9 Merge pull request #529 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.53.0-beta.0
2026-05-19 07:28:26 +02:00
github-actions[bot] c042a33431 chore(beta): release 3.53.0-beta.0 2026-05-19 05:27:14 +00:00
Paul Nothaft 633a2ae724 Merge pull request #527 from the-luap/fix/bug-batch-518
fix(bug-batch-518): lightbox comments toggle + further fixes
2026-05-19 07:26:54 +02:00
Paul Nothaft e8c2212dad refactor(slug): extract shared slugify util + scope adminPhotos category lookup (#525)
Folds all three follow-up items tracked in #525 into one commit:

1. Mirror PR #500's category scoping on adminPhotos.js. The admin
   upload route at adminPhotos.js:231 still accepted any category_id
   without event scoping — quietly less strict than the public v1
   API after #500 landed. Same one-liner fix (event_id OR is_global)
   with a matching 400 response shape so admin + v1 stay consistent.

2. Extract a shared slugify() in backend/src/utils/slug.js with the
   NFD-strip-combining-marks fix from #502, and route 5 callers
   through it:
     - adminEvents.js (event-name slug)
     - events.js      (event-create slug)
     - v1/events.js   (replaces local slugify helper)
     - adminArchives.js (archive→category slug)
   For pure-ASCII input the output is byte-identical to each old
   inline pipeline, so existing slugs in the DB keep round-tripping
   cleanly via lookup. Accented inputs now transliterate (Família
   → familia) instead of dropping the diacritic (Família → f-mlia).
   adminCategories.js stays with its own pipeline (underscores-as-
   word-chars semantics differ from the events-style transform —
   changing would silently shift wedding_party → wedding-party on
   new inserts). xmpGenerator.sanitizeKeyword stays unchanged for
   the same compat-cautious reason.

3. Cover the v1 upload happy path. Existing test only exercised the
   400-out-of-scope branch. Add two happy-path cases that stub
   sharp / generateThumbnail / storage.putFromFile and pin the
   response shape (id, category_id, type, etc.) plus the collage-
   slug → type='collage' flip. Temp file recreated in beforeEach
   because the handler unlinks it on success.

Tests:
- New slug.test.js: 22 cases pinning ASCII parity with the legacy
  pipeline (so the refactor is provably non-breaking for existing
  data) and the corrected accent handling across de/es/fr/nl/pt
  inputs, plus CJK and edge-case behaviour.
- events.category.test.js: 4 tests total (2 existing + 2 new happy
  path).
- galleryOgService.shareImage.test.js: 11 (3 added in #521 + 8 pre-
  existing) still pass.

37 tests pass across the three touched files.

Refs: #525, follows up #500 and #502
2026-05-18 23:50:35 +02:00
Paul Nothaft 4b4ecfdf71 fix(header): hide language name on mobile to free the title (#523)
@Rekoo-PS reported the LanguageSelector pushing into the company-name
title on narrow viewports — the button always rendered
Globe + flag + full language name (~120px), and on mobile that pinched
the left-side title cluster in AdminHeader.

Wrap the name in `hidden sm:inline` so <sm the button collapses to
just Globe + flag, matching the existing "hidden xl:block" pattern
on the date display in the same header. Self-explanatory at icon-only
width (users see their current flag and a globe), and the dropdown
still shows full names when opened. Title/aria-label keep the name
discoverable for screen readers + tooltip hover on the icon-only state.

Refs: #523
2026-05-18 23:19:42 +02:00
Paul Nothaft b960639035 fix(og): brandable static title + wider crawler UA coverage (#521)
@Rekoo-PS reported that gallery URLs sent via the WhatsApp Business
API render an unbranded "PicPeak - Photo Sharing Platform" preview
even though manual link sends from the WhatsApp app pick up the
per-event rich preview correctly. Two root causes, two fixes:

1. WhatsApp Business and 3rd-party preview services (Twilio,
   LinkPreview.net, etc.) don't always crawl with the recognisable
   "WhatsApp/X.Y.Z" UA we matched in nginx + galleryOgService.
   Extend the regex (both copies) to also catch WhatsAppBot, wa-bot,
   LinkPreview, and Slack-ImgProxy.

2. Even with broader UA coverage, some senders cache metadata with
   no UA at all and fetch the static SPA shell. That shell's
   <title> was hard-coded to "PicPeak - Photo Sharing Platform" —
   embarrassingly generic for any self-hosted brand. Switch to
   Vite's %VITE_DEFAULT_TITLE% / %VITE_DEFAULT_DESCRIPTION% HTML
   substitution so self-hosters can bake their brand into the
   fallback at build time. Defaults stay "PicPeak" so the upstream
   image doesn't change behaviour for anyone.

The per-event rich preview path (handleGalleryOgRequest, fired on
matched crawler UAs) is unchanged — this only improves the fallback
for unrecognised UAs and for the SPA-shell title that humans see in
their browser tab.

Adds a vite.config plugin to provide the defaults when env vars
aren't set, so unsubstituted "%VITE_..." literals never reach the
built HTML. Adds .env.example entries explaining the override.

Tests: extend galleryOgService.shareImage.test.js with an
isSocialCrawler suite that pins every documented UA (incl. the new
ones) plus three browser UAs (negative) and null/empty edge cases.
Verified locally: `vite build` with VITE_DEFAULT_TITLE="MyBrand"
produces <title>MyBrand</title> + og:title="MyBrand"; without the
env var falls back to "PicPeak".

Refs: #521
2026-05-18 22:45:00 +02:00
Paul Nothaft 3465b55abc feat(events): default Guest Feedback ON via admin setting (#520)
@Rekoo-PS asked for an admin-level switch so new events can have Guest
Feedback enabled out of the box instead of toggling it on every time.
Mirrors the existing event_default_require_password pattern (#317) —
same shape end-to-end, same set of five files.

- publicSettings.js: whitelist + expose event_default_feedback_enabled
  (defaults to false to match the prior hard-coded form default; no
  behaviour change for existing installs until an admin flips it).
- adminEvents.js: rename `feedback_enabled = false` destructure to
  `feedback_enabled: feedbackEnabledInput` so we can distinguish
  "omitted" from "explicit false", then resolve the default from the
  setting only when the caller omitted it — identical to the
  require_password handling a few lines above.
- Frontend EventSettings type + state + loader: new boolean,
  default false.
- EventsTab: toggle UI right under "Require password by default".
- CreateEventPage: one-shot useEffect that seeds
  feedback_settings.feedback_enabled from the public setting on first
  load (mirrors the require_password seed effect right above it).
  Sub-toggles (likes / ratings / comments) keep their hard-coded
  true defaults so flipping the master setting immediately gives
  sensible behaviour without a second admin setting to manage.

Refs: #520
2026-05-18 21:26:45 +02:00
Paul Nothaft d44e1adba7 fix(lightbox): hide comments toggle when allow_comments=false (#518)
@Rekoo-PS reported the MessageSquare comment button stayed visible in
the lightbox toolbar even when guest comments were disabled. Same
class of bug as #513 (per-photo Like button missing the master
gate) but on a different control.

The Like and Rating buttons in the lightbox toolbar gate correctly:
  feedbackEnabled && feedbackSettings?.allow_likes
  feedbackEnabled && feedbackSettings?.allow_ratings

The MessageSquare button only checked feedbackEnabled. Since likes
and ratings already have their own inline buttons in the same
toolbar, this third button is effectively the "open comments panel"
affordance — its badge counts comments, its tooltip mentions
comments. When comments are off it has nothing meaningful to do.

Add allow_comments to the local feedbackSettings type (the backend
already returns it via galleryFeedback.js:33) and gate the button on
feedbackEnabled && feedbackSettings?.allow_comments.

Refs: #518
2026-05-18 21:13:03 +02:00
Paul Nothaft 7b8ee1c148 Merge pull request #526 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.52.1-beta.0
2026-05-18 21:04:37 +02:00
github-actions[bot] b2b46d311d chore(beta): release 3.52.1-beta.0 2026-05-18 18:59:57 +00:00
Paul Nothaft 42c5cda38c Merge pull request #519 from the-luap/fix/install-permissions-484
fix(install): self-chowning entrypoint kills fresh-install restart loop (#484)
2026-05-18 20:59:33 +02:00
Paul Nothaft 91d47590ae Merge pull request #524 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.52.0-beta.0
2026-05-18 20:59:14 +02:00
github-actions[bot] db3f1b83ce chore(beta): release 3.52.0-beta.0 2026-05-18 18:56:50 +00:00
Paul Nothaft 2d5a2ad78a Merge pull request #500 from munin92/feat/v1-upload-category-id
feat(api/v1): accept category_id on POST /events/:id/photos
2026-05-18 20:56:17 +02:00
Paul Nothaft 763fd4593f ci(install-smoke): use BusyBox-compatible ps in node-user check
Alpine ships BusyBox ps (no -p PID, no pgrep), which failed CI on the
first run of this workflow with "ps: unrecognized option: p". Replace
the pgrep-then-ps chain with `ps -o user,comm | awk '$2=="node"'`
which works on both BusyBox (Alpine, in the container) and procps
(the GitHub runner host, though we don't use it here).
2026-05-18 10:28:29 +02:00
Marian df83b3e923 test(api/v1): cover category scoping clause + 400 response
Unit test for the v1 upload route's category lookup, requested in
the PR review. Mocks db (chainable, mirroring src/routes/__tests__/
adminAuth.test.js) plus apiTokenAuth/requireApiScope (pass-through)
and multer (stub req.file). Two cases:

1. The scoping clause: the andWhere callback applied to a knex
   builder spy produces .where({event_id: <event.id>}).orWhere(
   'is_global', true) — exactly the contract the reviewer asked
   for, exercising the OR-clause rather than just asserting the
   callback was passed.
2. Null lookup result yields 400 with "Unknown or out-of-scope
   category_id <N>".

No v1 jest scaffolding existed before, but the project-wide harness
(backend/jest.config.js + jest.setup.js) already covers the new
file via testMatch '**/__tests__/**/*.test.js'. Happy-path tests
deferred — would require stubbing fs/sharp/imageProcessor/share
linkService and several more db chains, which the reviewer was
willing to accept as a separate follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:21:43 +00:00
Marian 92bb9e1a12 fix(api/v1): scope category lookup to event_owned or global
PR review pointed out the original lookup
  db('photo_categories').where({ id: parsedCategoryId }).first()
accepted any category id — including one that belongs to a different
event. photo_categories carries both event_id (per-event) and is_global
(see backend/migrations/legacy/004_add_categories_and_cms.js); the v1
upload route should require either match.

Not a privilege issue (apiTokenAuth.js inherits the admin's powers, no
per-event scoping), but it lets a misconfigured uploader silently file
photos under a category the target event doesn't own — and the 201 echo
includes a category_id that makes no semantic sense.

Tighten to:
  .where({ id: parsedCategoryId })
  .andWhere(function () {
    this.where({ event_id: event.id }).orWhere('is_global', true);
  })
…and update the 400 message to "Unknown or out-of-scope category_id N".

OpenAPI description already documents the intended scope.

Tests deferred to a follow-up; v1 has no jest harness today, see PR
discussion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:21:43 +00:00
Marian 6901e2661e feat(api/v1): accept category_id on POST /events/:id/photos
The v1 photo upload endpoint previously ignored any caller-supplied
category and inserted photos with category_id=NULL. That meant
programmatic uploads via API tokens (e.g. a photobox sidecar) landed
in picpeak as uncategorized, forcing operators to bulk-assign category
in the admin UI after each event.

Mirror the adminPhotos.js category-handling logic on v1:
- Read optional `category_id` from the multipart form body.
- Reject unknown ids with 400 (with the id in the error) so callers
  fail fast on misconfigured envs instead of silently uncategorized
  uploads.
- Set photos.category_id on insert.
- Flip photos.type to 'collage' when the category's slug is
  collage/collages, matching adminPhotos.

Backwards-compatible: omitting category_id keeps the prior behavior
(insert with NULL category, type='individual'). OpenAPI spec + 201
response body updated to include the new field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:21:43 +00:00
Paul Nothaft 1505775678 fix(install): self-chowning entrypoint kills fresh-install restart loop (#484)
The fresh-install restart loop reported by @MrGabri (and confirmed by
@AloePacci with the user:0:0 workaround) had a clear root cause:

  - Dockerfile pinned USER nodejs (UID 1001) before the entrypoint
    ran, so the existing chown branch in init-production.sh:13 was
    dead code.
  - wait-for-db.sh (the actual entrypoint, not init-production.sh)
    silently swallowed mkdir/EACCES on bind mounts with || true,
    then a downstream migration error surfaced as the visible failure.
  - Net effect on a typical Linux host where the bind-mount dir is
    owned by UID 1000: container can't write, exits non-zero,
    restarts forever with no clear error.

Switch to the standard Docker drop-privileges pattern:

  1. Install su-exec, drop `USER nodejs` from the Dockerfile —
     container now starts as root.
  2. wait-for-db.sh: if running as root, chown /app/storage,
     /app/data, /app/logs to nodejs and re-exec self via
     su-exec nodejs:nodejs. App still ends up running as UID 1001.
  3. Preflight check for non-root invocations (compose `user:`
     overrides): verify the bind mounts are actually writable
     before continuing. If not, exit 1 immediately with an
     actionable error pointing at the docs — no more silent
     restart loops.

Also:

  - Delete backend/init-production.sh. It was an orphan — no caller
    in the Dockerfile, compose, or anywhere else. Its chown logic
    looked authoritative enough that @MrGabri ran it manually trying
    to debug, which is what finally surfaced the EACCES.
  - docker-compose.yml: drop user: + PUID/PGID env. The pattern-B
    UID-matching workaround they implemented is obsolete now that
    pattern A (root-then-drop) is in place.
  - .env.example + README: drop PUID/PGID documentation.
  - Add fresh-install smoke test workflow. Boots backend + postgres
    against bind mounts owned by UID 1000 (the GitHub runner UID,
    and the common-mismatch case on Linux hosts) and verifies:
    + container reaches healthy without restart-looping
    + chown happened (dirs now owned by 1001 inside the container)
    + node runs as nodejs, not root (su-exec drop worked)
    + /health returns status:ok
    + with --user 5005:5005 + unwritable mounts, preflight exits
      loud with the expected error string

Verified locally end-to-end against a fresh Postgres + UID-501-owned
bind mount: backend reaches healthy in ~20s, chown applied, node
runs as nodejs, no restart loop. Docs in picpeak-docs cover the new
behavior + a Troubleshooting section for the install-path bugs
fixed in #484/#494/#511/#488.

Refs: #484
2026-05-17 22:29:29 +02:00
Paul Nothaft 2619049a95 Merge pull request #517 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.5-beta.0
2026-05-17 09:29:01 +02:00
github-actions[bot] acb387f69c chore(beta): release 3.51.5-beta.0 2026-05-17 07:28:47 +00:00
Paul Nothaft ebc7da21be Merge pull request #503 from filpgame/fix/email-language-json-parse
fix(email): parse JSON-encoded language setting before using as locale
2026-05-17 09:28:25 +02:00
Paul Nothaft afcdf0d389 Merge pull request #516 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.4-beta.0
2026-05-17 09:27:19 +02:00
github-actions[bot] 4da6726e57 chore(beta): release 3.51.4-beta.0 2026-05-17 07:26:57 +00:00
Paul Nothaft a747eb351d Merge pull request #502 from filpgame/fix/category-slug-diacritics
fix(categories): strip diacritics from auto-generated slugs
2026-05-17 09:26:39 +02:00
Paul Nothaft 4fc07d9282 Merge pull request #515 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.3-beta.0
2026-05-17 01:20:56 +02:00
github-actions[bot] 941453fd7f chore(beta): release 3.51.3-beta.0 2026-05-16 23:19:50 +00:00
Paul Nothaft 482e91bbf8 Merge pull request #501 from filpgame/fix/settings-page-language-reset
fix(i18n): settings page resets UI language to server default
2026-05-17 01:19:27 +02:00
Paul Nothaft aa8cca165d Merge pull request #514 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.2-beta.0
2026-05-17 01:02:48 +02:00
github-actions[bot] 5a9ca4ad57 chore(beta): release 3.51.2-beta.0 2026-05-16 23:02:10 +00:00
Paul Nothaft aac60fa895 Merge pull request #513 from the-luap/fix/bug-batch
fix/feat: bug batch — drag-drop, lightbox, likes, downloads, upload, i18n (#504-510)
2026-05-17 01:01:49 +02:00
Paul Nothaft 51890e1aa5 fix(i18n): drive customer "Preferred language" select from SUPPORTED_LANGUAGES (#510)
Audit follow-up to the Spanish-locale commit: `CustomerDetailPage` had
a hardcoded `<option>` list for the customer's preferred-language
selector — 5 entries (en/de/nl/pt/ru) that were missing both fr (an
existing gap) and es (the new one). Every other language selector in
the frontend (the navbar `LanguageSelector`, the `GeneralTab` default-
language dropdown, the `EmailConfigPage` per-language tabs) already
reads from the shared `SUPPORTED_LANGUAGES` constant, so adding es
there was enough for those. This one had drifted.

Now mapped from `SUPPORTED_LANGUAGES` so future locales only need to
touch one place.
2026-05-17 00:57:03 +02:00
Paul Nothaft 1e7806961f Merge pull request #512 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.1-beta.0
2026-05-17 00:53:34 +02:00
github-actions[bot] 2e828fcf9f chore(beta): release 3.51.1-beta.0 2026-05-16 22:53:14 +00:00
Paul Nothaft 99e60a2433 Merge pull request #511 from the-luap/fix/install-postgres-log-noise
fix(install): silence clean-install postgres log noise (#484)
2026-05-17 00:52:48 +02:00
Paul Nothaft 061712ebf1 feat(i18n): add Spanish (es) locale (#510)
Contributed by @AloePacci on issue #510. Drops their es.json into the
existing locale set, registers Spanish in the language selector with a
flag SVG matching the inline style of the other six locales, and
extends the email pipeline so es-language guests receive a localised
email subject/body where available.

Coverage:
- frontend/src/i18n/locales/es.json — 2132 translated keys. ~824
  EN keys are not yet covered; i18next's `fallbackLng: 'en'` handles
  those at runtime so the UI never renders a missing key. fr/nl/pt/ru
  have a similar (smaller) gap and ship the same way.
- LanguageSelector.tsx — added the ESFlag inline SVG (red/yellow/red
  horizontal bands, official #AA151B + #F1BF00; no coat of arms to
  stay consistent with the other simple flag components) and a new
  entry in SUPPORTED_LANGUAGES.
- emailProcessor.js — added .es to the domain-language heuristic, and
  an `es:` row to the three inline-translated snippets
  (passwordSecurityI18n / noPasswordI18n / passwordSetAtCreationI18n).
- 106_seed_es_email_template_translations.js (new) — idempotent
  seeder for the four customer-facing templates AloePacci translated:
  gallery_created, expiration_warning, gallery_expired, archive_complete.
  Mirrors the pattern from 099. Template keys without an `es` row fall
  back to `en` via the existing resolution chain in
  emailProcessor.processTemplate — no functional gap, just untranslated
  copy until someone fills them in.

What I deliberately did NOT take from the contribution: the proposed
in-place edit of migration 075 (history mutation — won't reseed for
existing installs anyway) and the whitespace/`gallery_list_html`-drop
churn in emailProcessor.js (would have regressed the #354 follow-up).
The semantic additions from those files are preserved via 106 and the
targeted edits above.
2026-05-17 00:50:51 +02:00
Paul Nothaft 98f3c3df41 fix(upload): restore configurable batch-size for reverse proxies (#509)
Regression of #208. PR #214 (commit 02a46e0, re-merged at 9b7495e)
shipped the configurable `general_max_upload_batch_size_mb` setting so
users behind Cloudflare Tunnel and other reverse proxies with
per-request size caps could lower the chunked-upload size below their
proxy's limit. Six days later the "Merge main into beta for
release/beta-to-main" commit (28793bb) resolved its conflict by
keeping main's older tree — which silently deleted the migration
(072), the setting input on Settings → General, the i18n strings, the
`useSettingsState` field, and the read in PhotoUpload.tsx, putting the
hardcoded 500MB chunk back. Galleries fronted by Cloudflare have
quietly been broken on batch uploads since then.

Re-applying exactly the same change set:

- `backend/migrations/core/072_add_max_upload_batch_size.js`
  recreated, with a comment pointing at the regression in case the
  same merge accident happens again.
- `frontend/src/components/admin/PhotoUpload.tsx` line 168 now reads
  the setting from query cache and falls back to 95MB (Cloudflare-safe
  headroom under 100MB).
- `useSettingsState.ts`, `GeneralTab.tsx`, `en.json`, `de.json` —
  added the field to the state type + defaults + load path + the
  Site-Configuration input.

Existing installs are safe either way:
- Ran original 072 then lost the file: migrations table still has the
  filename, so the runner skips re-applying. The setting row in
  `app_settings` is also untouched (the deletion was source-only, no
  down migration ran). Now the new code starts reading it again.
- Installed after the regression: migrations runner picks up the new
  072 normally and seeds the setting at 95.
2026-05-17 00:42:22 +02:00
Paul Nothaft 33de294d57 feat(lightbox): surface original camera filenames (#508)
Photographers running the gallery as a client-selection tool want to
map a guest's picks back to source files for retouching. The
`general_use_original_filenames_for_downloads` toggle (#493) already
does this on the download side; this extends the same toggle to the
in-lightbox view so the camera filename is visible alongside the
photo while it's being looked at.

Tied to the same toggle on purpose — one switch controls both
surfaces. Off by default; existing galleries keep showing only the
position counter.

Wiring:
- gallery.js serializes `photos[].original_filename` and surfaces the
  resolved toggle as `event.use_original_filenames` so the client can
  decide whether to render it.
- The bespoke `PhotoLightbox` renders the original filename (falling
  back to the storage filename only for pre-migration-062 uploads) in
  a muted line under the position counter, truncated to keep the
  toolbar tidy.
- `GalleryStoryLayout` mounts the same `PhotoLightbox`, so its
  rendering follows along.
- `GalleryPremiumLayout` uses `yet-another-react-lightbox` instead;
  added the Captions plugin and a `title` field on the slides so the
  same name appears as a caption when the toggle is on.

The remaining layouts feed back into the main `PhotoLightbox` via
`PhotoGridWithLayouts`, so the prop reaches them through the layout
props bag.
2026-05-17 00:35:16 +02:00
Paul Nothaft 38343e62de fix(downloads): apply original-filename toggle to individual downloads too (#507)
Follow-up to #498. The toggle reached zip downloads but single-photo
downloads still landed on disk with the renamed `event_individual_NNN.jpg`
even when the admin had flipped the setting on. Two reasons, fixed
in lockstep:

- Frontend overrode the server's Content-Disposition with a hardcoded
  `<a download="X">` attribute (`gallery.service.ts`, `photos.service.ts`)
  where X was the sanitized `photo.filename` known to the client. So
  the backend's correctly-formed `Content-Disposition` never reached
  the disk write. Added `parseContentDispositionFilename` (RFC 5987 +
  plain `filename=` fallback) and let the server name win when present.
- `secureImages.js` (enhanced/maximum protection's secure-download
  route) was missed in #498 and still emitted a hardcoded
  `filename="${photo.filename}"` regardless of the toggle. Wired it
  through `getUseOriginalFilenames` + `buildContentDisposition` so it
  matches the regular gallery download path.

Also exposed `Content-Disposition` via CORS so split (cross-origin)
frontend deployments can still read it from JavaScript. Same-origin
Docker deploys already had access; this is a defensive addition for
the split case.
2026-05-17 00:25:12 +02:00
Paul Nothaft 9d2db9a73b fix(gallery): hide Like button when guest feedback is off (#506)
Four gallery layouts were rendering the per-photo Like button without
gating on the master "Guest Feedback" toggle, so a guest still saw a
heart icon and could submit likes on events where the host had turned
feedback off. The other layouts (Grid / Justified / Masonry / Story)
already gated correctly with `feedbackEnabled && allowLikes` —
Rekoo-PS's note that "it's hidden in some themes" matches that split.

- CarouselGalleryLayout, MosaicGalleryLayout, TimelineGalleryLayout:
  the existing conditional checked only `feedbackOptions?.allowLikes`,
  missing the `feedbackEnabled` master gate. Added it inline.
- GalleryPremiumLayout: the per-card Like button rendered
  unconditionally because PhotoCard never received the allow-likes
  signal. Added an `allowLikes` prop on PhotoCardProps, plumbed
  `feedbackOptions?.allowLikes` down from the parent, and wrapped the
  button in `feedbackEnabled && allowLikes`.

The follow-up "default guest-feedback ON" request from Rekoo-PS in
the comments is a separate feature (admin > General > Event Creation
default) and out of scope for this fix.
2026-05-17 00:13:21 +02:00
Paul Nothaft d2d55098d6 fix(lightbox): align swipe-neighbour height + stop black flash on commit (#505)
Two adjacent swipe-time defects, one diagnosis each:

1. Height differed between current and neighbouring slides during a
   swipe but matched when the arrow buttons advanced the carousel.
   Cause: neighbour slides wrap their image in a div with extra `px-2`
   horizontal padding while the current slide does not. `object-contain`
   then sees a narrower container on neighbours, so wide images cap on
   width first and render shorter than the same image at the current
   position. Removed the padding so both slots share the same container
   geometry. Arrow-button navigation looked fine because it never
   showed the neighbour layout side-by-side.

2. The image flashed black for ~100–400 ms each time a swipe committed
   to the next slide. Cause: the 3-slide track has no React keys, so
   React reconciled slides by position. After commit the photo at every
   position changed (`prev → current → next` shifts left), every slot's
   `<AuthenticatedImage>` saw a new `src` prop, and its fetch effect
   restarted from the placeholder state — including the slot that was
   the user's "next" slide a moment ago and held a fully-loaded image.
   Added a stable `key` derived from `photo.id` so React MOVES existing
   DOM nodes across slots instead of refetching. 2-photo galleries are
   a key-collision edge case (`prev === next`), so they fall back to
   slot-prefixed keys to keep siblings unique; behaviour there is no
   worse than today.
2026-05-17 00:09:15 +02:00
Paul Nothaft 577c4bdf29 fix(upload): wire drag-and-drop on admin + user upload zones (#504)
The dashed-border upload area in `PhotoUpload` (admin) and
`UserPhotoUpload` (gallery user-upload) is styled and labelled as a
drop zone — every locale's `upload.clickToUpload` already reads
"Click to upload or drag and drop" or its translation — but neither
component had any `onDragOver` / `onDragEnter` / `onDragLeave` /
`onDrop` handlers. Files dropped on the zone fell through to the
browser's default behaviour (open the image in a new tab), which is
what Rekoo-PS reported.

Added native HTML5 drag-and-drop wiring on both components, plumbed
through the same filter/limit/toast pipeline used by the click path
(`addFiles` helper). Visual highlight on drag-over via an `isDragOver`
flag; the listener guards against the `dragleave` strobing that fires
on every child node. Also reset the `<input>` value after onChange so
re-picking the same file still triggers an upload — matches the
new drop-then-pick mental model.
2026-05-17 00:02:39 +02:00
Paul Nothaft 86b33d4dda fix(install): silence clean-install postgres log noise (#484)
Two latent install-time issues that emitted scary postgres ERROR lines
on every fresh start but didn't actually break anything. MrGabri flagged
them after #494 had already cleared the FK-ordering crash.

1. Migration 035 builds three `CREATE INDEX` statements against
   `backup_runs(created_at, …)`, but 029 creates the table with
   `started_at` and no `created_at`. The wrapping try/catch silently
   swallowed the resulting `column "created_at" does not exist` ERROR,
   so the migration "succeeded" without ever creating the indexes.
   Switched 035 to reference `started_at` (same chronological semantics)
   and added migration 105 to create the same indexes idempotently for
   deployments whose 035 already ran and silently failed.

2. `run-migrations-safe.js` snapshots `appliedFilenames` *before*
   `detectExistingSchema()` runs. When `detectExistingSchema()` inserts a
   row for e.g. `004_add_categories_and_cms.js` (because its tables exist
   from a partially-completed prior install), the subsequent migration
   loop still doesn't know about that insert, attempts the legacy
   migration anyway, and its transaction-internal
   `insert into migrations` conflicts with the row already there.
   Re-query the applied set after detectExistingSchema so the loop sees
   the corrected snapshot.

No behavioural change for healthy installs. New installs no longer log
the `column "created_at" does not exist` or `duplicate key value
violates unique constraint "migrations_filename_unique"` ERRORs.
2026-05-16 23:56:05 +02:00
filpgame f12062f1e7 fix(email): parse JSON-encoded language setting before using as locale
general_default_language is stored as a JSON string (e.g. "\"pt\"").
getRecipientLanguage() returned the raw value including quotes, causing
the translation lookup to miss every match and fall back to English.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 03:31:15 -03:00
filpgame 848430e72b fix(categories): strip diacritics from auto-generated slugs
Accented chars (ã, ç, é, etc.) were silently dropped by the slug
regex because \w only matches ASCII. NFD decomposition + combining
mark removal converts them to ASCII equivalents instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:23:03 -03:00
filpgame 165ebce8d1 fix: settings page resets UI language to server default
When navigating to the Settings page, useSettingsState called
i18n.changeLanguage() with the server-stored general_default_language
value on every settings query resolution. This caused the admin UI
language to reset to the server default (e.g. "en") regardless of the
language the user had selected via the LanguageSelector.

The general_default_language setting is intended as the default for
public galleries, not for controlling the admin UI language. The admin
UI language is already persisted via localStorage through
i18next-browser-languagedetector and should not be overridden by server
settings.

Remove the i18n.changeLanguage() call from the useEffect that
initialises settings state from the API response.
2026-05-16 01:14:57 -03:00
Paul Nothaft 3a490844e1 Merge pull request #499 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.0-beta.0
2026-05-14 23:30:58 +02:00
github-actions[bot] 72c2b5c796 chore(beta): release 3.51.0-beta.0 2026-05-14 21:25:07 +00:00
Paul Nothaft 826e43ebac Merge pull request #498 from the-luap/feat/lightbox-preview-tier-492
feat(downloads): preserve original camera filenames on download (opt-in) (#493)
2026-05-14 23:24:41 +02:00
Paul Nothaft 019b0c0301 Merge pull request #497 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.50.0-beta.0
2026-05-14 23:22:44 +02:00
Paul Nothaft 7eeef2ba98 feat(downloads): preserve original camera filenames on download (opt-in) (#493)
New Settings → General toggle `Use original filenames on download` (off by
default). When on, single-photo downloads, bulk/selection zips, and per-event
archive zips surface `photos.original_filename` instead of the sanitized
storage filename. Storage paths are unchanged.

- Content-Disposition uses RFC 5987 (`filename=` ASCII + `filename*=UTF-8''…`)
  so unicode camera filenames survive while header-injection bytes are stripped.
- Zip entries are deduplicated with a deterministic `_1` / `_2` suffix on
  collision (folder structure preserved in archive zips).
- Pre-generated download-all zips and the in-memory setting cache are
  invalidated when the toggle flips so the next download rebuilds with the
  new names.
- Falls back to the storage filename whenever `original_filename` is null
  (legacy uploads predating migration 062).
2026-05-14 23:11:00 +02:00
github-actions[bot] 365582e678 chore(beta): release 3.50.0-beta.0 2026-05-14 20:44:57 +00:00
Paul Nothaft 3083c748b9 Merge pull request #496 from the-luap/feat/lightbox-preview-tier-492
feat(lightbox): medium-resolution preview tier (#492)
2026-05-14 22:44:35 +02:00
Paul Nothaft 61f1d13210 feat(lightbox): medium-resolution preview tier (#492)
Adds an opt-in lightbox preview tier so guests open photos against an
aspect-preserved ~1920px JPEG (~200–500 KB) instead of the full original
(often 5–12 MB). Originals are still served on Download.

Backend:
  - imageProcessor: generatePreviewImage / isPreviewValid / ensurePreviewImage
    using fit:'inside' + withoutEnlargement (longEdge 1920, q85, mozjpeg)
  - migration 104: photos.preview_path + lightbox_preview_enabled setting
    (off by default, JSON-stringified for SQLite/Postgres parity)
  - GET /api/gallery/:slug/preview/:photoId — gallery-auth, lazy generation,
    ETag based on mtime+photoId+watermarkHash
  - preview_url surfaced in the photo response only when the toggle is on
  - admin /thumbnails/regenerate-previews mirrors regenerate-thumbnails,
    skipping videos
  - backup walk + archive cleanup + photo-delete now include previews/

Frontend:
  - PhotoLightbox uses photo.preview_url ?? photo.url (null-safe fallback)
  - ThumbnailsTab gets a Lightbox Preview Tier card: opt-in toggle +
    Regenerate All Previews button (gated until the toggle is on)
  - en/de locale strings; nl/pt/ru/fr fall back to en

Tested end-to-end: 11 MB / 4000×3000 source → 985 KB / 1920×1440 preview,
381 ms first call, 7 ms cached, ~91% byte reduction.
2026-05-14 22:30:39 +02:00
Paul Nothaft 06b33ced94 Merge pull request #495 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.6-beta.0
2026-05-14 21:37:55 +02:00
github-actions[bot] d4198e1bbc chore(beta): release 3.49.6-beta.0 2026-05-14 19:37:31 +00:00
Paul Nothaft 62b3ed6364 Merge pull request #494 from the-luap/fix/postgres-fresh-install-fk-order
fix(install): defer events.hero_photo_id FK to break circular reference (#484)
2026-05-14 21:37:00 +02:00
Paul Nothaft 87834a7fff fix(install): defer events.hero_photo_id FK to break circular reference (#484)
Real root cause behind MrGabri's fresh-Postgres install crash, surfaced
by his second log dump after #488 silenced the FATAL noise:

  Initial setup failed: error: alter table "events" add constraint
  "events_hero_photo_id_foreign" foreign key ("hero_photo_id")
  references "photos" ("id") on delete SET NULL
  - relation "photos" does not exist

initializeDatabase() in src/database/db.js declared the FK inline at
events createTable (line 89), but the photos table is created later
in the same function (line 203). On Postgres this is a hard error —
the referenced table must exist at FK-declaration time. SQLite
silently tolerated it because its FK enforcement is lazy and the
inline declaration just became a column with no FK metadata.

Why no existing Postgres install hit it: initializeDatabase only
runs the createTable block on `if (!hasEventsTable)`. Once a
deployment has the events table from any prior run, the path is
skipped. So the bug only ever fires on a truly fresh Postgres
install — which is exactly MrGabri's scenario, and which our smoke
suite never exercises (it runs against a long-lived dev stack).

Fix:

- events createTable: drop the inline FK; column declared as a plain
  integer with an explainer comment.
- After both tables exist (post photos createTable): db.schema
  .alterTable('events').foreign('hero_photo_id').references...
  Wrapped in a try/catch that swallows "already exists" so re-runs
  on installs that previously got into a half-state don't fail boot.

Verified by docker compose down -v + up against the dev stack — no
FK error, all migrations apply, FK present in pg_constraint with
the expected definition.
2026-05-14 21:21:23 +02:00
Paul Nothaft 4225cd153f Merge pull request #491 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.5-beta.0
2026-05-14 21:02:58 +02:00
github-actions[bot] 1e4f762871 chore(beta): release 3.49.5-beta.0 2026-05-14 19:02:43 +00:00
Paul Nothaft d300426390 Merge pull request #490 from the-luap/fix/admin-users-sqlite-date-crash
fix(admin-users): normalise date fields to ISO across DB drivers (#485)
2026-05-14 21:02:13 +02:00
Paul Nothaft b6b58d0659 fix(admin-users): normalise date fields to ISO across DB drivers (#485)
Admin > Users page crashed with "TypeError: e.split is not a function"
on native installs (SQLite default). Reported by @blazmaric in #485
with a clean diagnosis: SQLite returns lastLogin / createdAt /
updatedAt as integer milliseconds since epoch, while Postgres
returns ISO strings via the standard JSON serialiser. The page used
parseISO() on the raw value and parseISO trips on numbers.

Fix at both layers — defence in depth:

- backend/src/routes/adminUsers.js: new toIso() helper applied in
  transformUser + transformInvitation. Coerces Date / number /
  numeric-string / null to a single ISO 8601 string contract before
  the response leaves the API. Protects every consumer (frontend
  AND external API tokens / n8n) regardless of which DB driver is
  underneath.
- frontend/src/services/userManagement.service.ts: same helper as
  defence-in-depth for stale backends mid-deploy and any cached
  pre-fix response shape. Also surfaced an existing
  transformInvitation gap — invitations endpoints were returning
  raw response.data.invitations without going through the
  transformer.

10 unit tests pin the toIso contract: all known driver shapes
(Date, number, numeric-string, ISO-string, null/undefined/empty)
plus the full transformer paths for transformUser and
transformInvitation.

Out of scope: same epoch-ms surface may exist on other admin pages
that were never tested against SQLite (events list, customers,
webhooks, api tokens, activity log). Worth a follow-up audit pass
to apply toIso() in every snake_case→camelCase transformer the
admin routes use, but the immediate Users-page crash is the only
reported one and shipping that fix unblocks @blazmaric.
2026-05-14 20:53:10 +02:00
Paul Nothaft a135544cab Merge pull request #489 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.4-beta.0
2026-05-14 20:50:23 +02:00
github-actions[bot] 57ea08e1ed chore(beta): release 3.49.4-beta.0 2026-05-14 18:49:31 +00:00
Paul Nothaft d39406b241 Merge pull request #488 from the-luap/fix/install-healthcheck-noise-and-stale-workers
fix(install): silence pg healthcheck noise + drop legacy workers container (#484)
2026-05-14 20:49:01 +02:00
Paul Nothaft d4155c4611 fix(install): drop racy migration step + add missing frontend container (#484)
Two follow-up fixes inside the same install-experience surface as
the previous commit:

1. **Removed `docker compose exec -T backend npm run migrate`** in
   both install_docker and update_docker_installation. The backend
   container's wait-for-db.sh already runs `npm run migrate:safe`
   on startup; the script was racing it with a separate (and
   non-safe) `npm run migrate`. That race is the most likely
   actual mechanism behind #484's "relation 'photos' does not
   exist" error on the second install attempt — partial schema
   visible to one of the two parallel migrators. Replaced with a
   bounded wait for the backend container to become healthy
   (Docker healthcheck reports green only after wait-for-db.sh
   finishes its migration pass).

2. **Added the missing frontend container** to the script-generated
   compose. The script previously generated a postgres + redis +
   backend stack with no frontend at all (backend on host port
   3001), while the documented production install
   (docker-compose.production.yml) ships postgres + redis +
   backend + frontend (nginx /api proxy on host port 3000). That
   shape divergence is half of issue B in #484 — script-installed
   admins had no frontend container and were left wondering where
   the UI lived. Aligning both compose files on the same shape
   eliminates the divergence; the frontend uses curl in its
   healthcheck (frontend/Dockerfile explicitly `apk add curl`)
   unlike the backend.

The remaining piece of issue B — picking ONE canonical install
path (build-from-source script vs. prebuilt-image production
compose) and deprecating the other — is a deployment-strategy
call that deserves its own design pass. Both paths now produce
architecturally-equivalent stacks.
2026-05-14 20:35:54 +02:00
Paul Nothaft 0b0b1bb2d5 fix(install): silence pg healthcheck noise + drop legacy workers container (#484)
Three install-experience bugs that compounded into MrGabri's "fresh
install fails" report:

1. **postgres healthcheck noise.** `pg_isready -U <user>` without
   -d defaults to probing a database whose name matches the user.
   Since DB_NAME defaults to picpeak_prod (not picpeak), every
   healthcheck interval logged
     FATAL: database "picpeak" does not exist
   into postgres logs even though the install was working
   correctly. Reporter saw the FATAL, assumed broken, restarted
   with DB_NAME=picpeak, hit a tainted-state migration error on
   the second try, filed a bug. Fixed in both
   docker-compose.production.yml and the inline compose generated
   by scripts/picpeak-setup.sh — pin -d to ${DB_NAME} so the
   probe hits the real database.

2. **backend container shows perpetually `unhealthy`.** Both
   compose files used `curl -f` for the backend healthcheck, but
   backend/Dockerfile only installs dumb-init + postgresql-client +
   ffmpeg — no curl. Switch to wget --no-verbose --tries=1 --spider
   to match what backend/Dockerfile's own HEALTHCHECK already
   does. Now docker ps, docker compose ps, and the backend image's
   built-in healthcheck all agree.

3. **stale separate `workers` container.** scripts/picpeak-setup.sh
   still generated a second container running `npm run workers`
   alongside the backend, but workers (fileWatcher,
   expirationChecker, emailQueueProcessor, backgroundProcessor,
   webhookWorker) have been started by server.js in-process for
   a while — see the comment at line ~895 of the same script for
   the systemd-side cleanup. The duplicate container caused two
   file watchers and two expiration checkers to compete for the
   same DB rows. Removed from the generated compose; install +
   upgrade paths now stop and rm any pre-existing picpeak-workers
   container.

Doesn't address issue B's bigger architecture mismatch (the script
generates a build-from-source compose with no frontend container,
while docker-compose.production.yml uses prebuilt images with a
separate frontend container). That deserves its own design pass
to pick a canonical install path and align — out of scope here.
2026-05-14 20:31:25 +02:00
Paul Nothaft 409ddf9c93 Merge pull request #487 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.3-beta.0
2026-05-14 20:24:30 +02:00
github-actions[bot] bc58520cd2 chore(beta): release 3.49.3-beta.0 2026-05-14 18:18:43 +00:00
Paul Nothaft d1034ce1c6 Merge pull request #486 from the-luap/fix/promo-banner-alignment
fix(promo-banner): center by default + admin alignment selector (#482)
2026-05-14 20:18:07 +02:00
Paul Nothaft a803491cf4 fix(promo-banner): center by default + admin alignment selector (#482)
The gallery promotional banner (#440) read as visually offset from
the gallery footer because:

  - Footer used `container text-center px-4` (full container width,
    centered text).
  - Promo block used `container py-4 sm:py-6` with an inner
    `max-w-3xl mx-auto` wrapper holding left-aligned text — a
    narrower column with left-aligned content sitting in the
    middle of the page.

Two issues compounded: the column was narrower than the footer AND
its text alignment differed. Reported by Rekoo-PS in #482 with a
screenshot showing the misalignment, with a request for an admin
alignment option.

Fix:

- Drop the inner max-w-3xl wrapper. Promo content now spans the
  same .container width as the footer, eliminating the
  narrower-column visual.
- Default text alignment changed from left → center to match the
  footer.
- New `branding_promo_alignment` setting ('left' | 'center' | 'right',
  default 'center'). Surfaced as a dropdown next to the existing
  Position dropdown on the BrandingPage. Live preview block on the
  BrandingPage mirrors the gallery render so admins see what
  guests will see.
- Also replaced the no-op `prose-sm` prose-modifier with a real
  `prose prose-sm` outer class so the existing `prose-a:text-accent`
  modifier actually takes effect (it didn't before — modifiers
  without an outer .prose are silently ignored by Tailwind
  Typography).

Migration 103 seeds the new setting at 'center' so existing
installs that have a promo banner today see the corrected
alignment immediately on next deploy.

i18n: en + de hand-translated; nl/pt/ru/fr machine-translated and
flagged for native review per project convention.
2026-05-14 20:10:14 +02:00
Paul Nothaft 3869e5c0dc Merge pull request #481 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.2-beta.0
2026-05-13 18:48:12 +02:00
github-actions[bot] 1cbb0c4cff chore(beta): release 3.49.2-beta.0 2026-05-13 16:46:19 +00:00
Paul Nothaft 6750f5d3b0 Merge pull request #480 from the-luap/fix/ci-trivy-platform-pin
fix(ci): pin TRIVY_PLATFORM per matrix arch (post-#477 follow-up)
2026-05-13 18:45:53 +02:00
Paul Nothaft c3256dc6bf fix(ci): pin TRIVY_PLATFORM per matrix arch (post-#477 follow-up)
PR #477 moved Trivy from the merge-* job into the per-arch build-*
matrix scanning by digest. The amd64 leg works; the arm64 leg
crashes with:

  remote error: no child with platform linux/amd64 in index
  ghcr.io/.../<image>@sha256:<digest>

Root cause: docker/build-push-action wraps every push in an OCI
index — the actual image manifest sits next to a SLSA provenance
attestation manifest as siblings under the digest. Trivy's remote
backend defaults to linux/amd64 when resolving an index, so:

  - amd64 leg → looks for amd64 child → finds the amd64 image → ok.
  - arm64 leg → looks for amd64 child → finds NO amd64 child
    (the only platform child is arm64) → fails.

Fix: set TRIVY_PLATFORM = ${{ matrix.platform }} on each leg's
Trivy step. Each scanner then asks for its own arch and finds it.
SLSA provenance attestation stays attached to the per-arch images
— a real win for supply-chain visibility we'd lose if we'd
disabled provenance instead.

amd64 was the only thing keeping CI partly green; this restores
full green across both legs without touching the build artifact
shape.
2026-05-13 18:41:49 +02:00
Paul Nothaft f9284010b7 Merge pull request #479 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.1-beta.0
2026-05-13 18:35:45 +02:00
github-actions[bot] e5ef893395 chore(beta): release 3.49.1-beta.0 2026-05-13 16:31:12 +00:00
Paul Nothaft 1144e9d162 Merge pull request #477 from the-luap/fix/ci-trivy-multi-arch-scan
fix(ci): scan multi-arch images per-arch by digest, pin trivy-action (#476)
2026-05-13 18:30:41 +02:00
Paul Nothaft f53b10dc2c Merge pull request #478 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.0-beta.0
2026-05-13 18:30:12 +02:00
github-actions[bot] b3c6508712 chore(beta): release 3.49.0-beta.0 2026-05-13 16:18:38 +00:00
Paul Nothaft d856340f0d Merge pull request #475 from the-luap/feat/og-share-cover-photo
feat(og): per-event opt-in to use hero photo as social-share preview (#474)
2026-05-13 18:18:02 +02:00
Paul Nothaft 40e176cb46 fix(ci): trivy-action tag is v0.36.0 (was 0.28.0 — does not exist)
Initial pinning shipped a tag that doesn't exist in the
aquasecurity/trivy-action repo. Workflow run failed with:

  Unable to resolve action 'aquasecurity/trivy-action@0.28.0',
  unable to find version '0.28.0'

The repo's tags use a v prefix (v0.36.0, v0.35.0, …). Bumping
both occurrences (build-backend and build-frontend matrix jobs)
to v0.36.0, which is the latest stable as of 2026-04-22.
2026-05-13 17:59:38 +02:00
Paul Nothaft caf0d61857 fix(ci): scan multi-arch images per-arch by digest, pin trivy-action (#476)
Resolves the intermittent "no child with platform linux/amd64 in
index" failure on the merge-backend job — and fixes the same latent
bug on merge-frontend before it surfaces.

Two compounding root causes per Luca's diagnosis:

1. aquasecurity/trivy-action@master was unpinned, so the action and
   its bundled Trivy binary float on every CI run. A green build
   could flip red overnight without a single repo change.
2. Trivy was asked to scan a multi-platform OCI index by tag (the
   merge-* jobs ran AFTER manifest creation). Its remote resolver
   cannot reliably pick the right per-arch child out of an index
   reference — it needs a single-platform reference (digest, or a
   --platform flag).

Fix:

- Move the Trivy + upload-sarif steps OUT of merge-backend /
  merge-frontend and INTO the per-arch build-backend / build-frontend
  matrix jobs. Each leg scans the image it just pushed by its
  sha256 digest (`...@${{ steps.build.outputs.digest }}`), which is
  always single-platform by construction.
- Pin aquasecurity/trivy-action@0.28.0 (was @master).
- Distinct SARIF category per arch
  (`backend-vulnerabilities-linux-amd64`, …-arm64) so an
  amd64-only finding in a base layer doesn't get masked by the
  arm64 scan in the Security tab.
- Move security-events: write down to the build-* jobs (where the
  scan now runs) and remove it from the merge-* jobs (which only
  publish the manifest now).

Out of scope: flipping `exit-code: '1'` to actually gate CI on
findings. Worth doing as a separate follow-up after an audit pass —
landing it here would surprise beta with a red build for any
pre-existing CRITICAL/HIGH in current images. Inline TODO in the
workflow notes the deferral.
2026-05-13 17:56:29 +02:00
Paul Nothaft 0bc7e2af17 feat(og): per-event opt-in to use hero photo as social-share preview (#474)
Background: galleryOgService already serves OG/Twitter Card meta tags
to social-crawler User-Agents (WhatsApp, Facebook, Slack, Telegram,
Discord, ~21 in total) for /gallery/:slug URLs. Today the og:image
is always the brand logo with the inline rationale "no protected
photo content".

#474 asked for a hero/cover photo preview. The trade-off is that any
URL embedded in og:image is fetched unauthenticated by every
link-preview crawler — so an opted-in image is effectively public
to anyone the gallery URL is shared to. Ship as a per-event boolean,
default FALSE, so existing galleries never start surfacing photos
without explicit admin intent.

Schema (migration 102):
  - events.og_image_share_enabled BOOLEAN NOT NULL DEFAULT FALSE.

Backend:
  - galleryOgService.buildOgMetadata: when opt-in is on AND a
    hero_photo_id is set AND the photo has a generated thumbnail,
    emit og:image as /og/gallery/:slug/cover. Falls back to the
    brand logo on any miss (deleted hero, missing thumbnail, no
    opt-in) so a half-configured gallery still gets a polished
    preview rather than a broken-image src.
  - galleryOgService.handleGalleryOgCover: new public endpoint that
    streams the hero thumbnail. Validates slug shape, checks the
    opt-in flag + hero presence + thumbnail existence; returns 404
    on any failure. ETag = thumbnail mtime + photo id so a
    regenerated thumb busts crawler caches. Cache-Control:
    public, max-age=300 (short — admins shouldn't wait an hour for
    a cover swap to land in chat previews).
  - server.js: mount the new GET /og/gallery/:slug/cover route. The
    existing nginx ^~ /og/gallery/ proxy block already covers it.
  - adminEvents.js: validator + persistence on POST + PUT.
    formatBoolean coercion so SQLite (0/1) and Postgres (boolean)
    both behave correctly.

Frontend:
  - Event type + UpdateEventData carry og_image_share_enabled.
  - EventDetailsPage adds a checkbox under the HeroPhotoSelector,
    disabled when no hero photo is picked. Help text deliberately
    spells out the public-by-design consequence — admins shouldn't
    flip this on for a sensitive gallery without realising what
    they're sharing with link-preview crawlers.

Tests: 8 new in galleryOgService.shareImage.test.js — pin the
cover-vs-logo decision contract (3 cases) plus the defensive
fallbacks (deleted hero, missing thumbnail) and the 404 contract
on the cover endpoint (4 cases). The 404 tests assert that
ensureThumbnail() is NOT called when opt-in is off, so a future
refactor can't accidentally widen the unauthenticated cover
endpoint to expose a hero the admin hasn't shared.

i18n: en + de hand-translated; nl + pt + ru + fr machine-translated
and flagged for native review per project convention.
2026-05-13 13:47:02 +02:00
Paul Nothaft 16e4d191c2 Merge pull request #473 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.48.1-beta.0
2026-05-12 23:24:51 +02:00
github-actions[bot] fa71e7ea1e chore(beta): release 3.48.1-beta.0 2026-05-12 20:57:36 +00:00
Paul Nothaft 7126f12567 Merge pull request #472 from the-luap/followup/470-tests-and-cache-headers
test+fix(customer-portal): #470 review follow-ups (test coverage + cache headers)
2026-05-12 22:57:07 +02:00
Paul Nothaft 3122dd08a8 fix(customer-routes): Cache-Control: no-store on customer endpoints (#470)
The trigger: PR #458 mounted requireCustomerPortalEnabled which
410'd every /api/customer/* + /api/admin/customers/* request when
the master toggle was off. Some browsers cached that 410 (no
Cache-Control header was set, so heuristic freshness applied —
the wrong default for an authenticated/sensitive surface).
PR #470 reverted the middleware, but a customer whose tab cached
the 410 still saw 410s until they hard-refreshed.

Add noStoreCache middleware and mount it in front of both route
groups. Every response (200, 4xx, 5xx) now carries
`Cache-Control: no-store, no-cache, must-revalidate, private`
plus the HTTP/1.0 Pragma + Expires fallbacks. Any future
transient error from these endpoints can no longer get pinned in
browser or proxy caches and outlive its cause.

Cost is one setHeader per request; applied per route group rather
than globally so static assets + galleries keep their own caching
strategy unchanged.

Includes a dedicated unit test pinning the header set so a future
cleanup pass can't quietly drop it and re-introduce the bug.
2026-05-12 22:53:49 +02:00
Paul Nothaft 5e86eef4f8 test(gallery): verifyGalleryAccess customer-assignment revocation (#470)
4 unit tests pinning the contract of the customer-minted JWT
re-check added in #470:

- via='customer' + customerId, assignment present → next() runs.
- via='customer' + customerId, assignment removed → 403 with
  CUSTOMER_ASSIGNMENT_REVOKED code.
- customerId in payload but `via` claim missing → no re-check
  (defends against a future refactor accidentally widening the
  gate to match every legacy session that happens to carry a
  customerId field).
- per-event-password JWT (no via, no customerId) → no
  event_customer_assignments query at all (asserted by counting
  db() invocations — a regression that quietly added a re-check
  here would 403 every guest the moment any unrelated customer
  was unassigned from any event).

Same mock pattern as customerAuth.middleware.test.js. The re-check
is the load-bearing piece behind the "Manage galleries" dialog
UX promise — these tests guard it explicitly.
2026-05-12 22:53:23 +02:00
Paul Nothaft 7a9c4ca44e test(customers): unit-cover setAssignmentsForCustomer (#470 follow-up)
5 new tests covering the diff math (added/removed), the
archived-event filter, the no-op short-circuit when wanted equals
existing, and the type-coercion of the wanted-list input. Mirrors
the existing setAssignmentsForEvent suite shape so the inverse-
direction service function carries equivalent regression coverage.

This function is the writer behind the "Manage galleries" dialog
and the verifyGalleryAccess re-check together form the access-
control story for the whole feature — getting the diff math
wrong here means assignments don't actually revoke, which is the
entire promise of the new UI.
2026-05-12 22:53:05 +02:00
Paul Nothaft e78e0d957a Merge pull request #471 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.48.0-beta.0
2026-05-12 22:48:50 +02:00
github-actions[bot] dafc8d041a chore(beta): release 3.48.0-beta.0 2026-05-12 20:48:26 +00:00
Paul Nothaft 9be9296eb5 Merge pull request #470 from Luca-Timo/feat/customer-detail-section-order
feat(customers): "Manage galleries" dialog with immediate access revocation + section reorder + portal-flag revert
2026-05-12 22:48:01 +02:00
Luca c02c947463 feat(customers): email customer when admin adds new gallery access 2026-05-12 01:25:12 +02:00
Luca 3f4419356a revert(customer-portal): make the global flag UI-only, drop the kill-switch middleware 2026-05-12 00:40:17 +02:00
Luca 9e418c759c fix(customer): don't log customer out on transient session-refresh errors 2026-05-12 00:07:50 +02:00
Luca d0ad9879bd chore(customers): keep search query after add + add explicit clear button 2026-05-11 23:50:23 +02:00
Luca 6d1af7a011 feat(customers): "Manage galleries" dialog on customer detail page 2026-05-11 23:40:07 +02:00
Luca 55a5846f6f feat(gallery): revoke customer-minted JWTs when assignment is removed 2026-05-11 23:39:47 +02:00
Luca 5377b88e0e feat(customers): replace-assignments endpoint for a single customer 2026-05-11 23:39:22 +02:00
Luca 592fa1e1c2 chore(customers): reorder customer detail sections for browse-first flow 2026-05-11 23:20:39 +02:00
Paul Nothaft 0fd7ea336f Merge pull request #469 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.47.2-beta.0
2026-05-11 22:16:12 +02:00
github-actions[bot] a5e58d085e chore(beta): release 3.47.2-beta.0 2026-05-11 20:15:57 +00:00
Paul Nothaft 4703fd574f Merge pull request #468 from the-luap/fix/activity-log-feature-flags-and-missing-types
fix(activity-log): smart feature_flags_updated rendering + 33 missing activity types
2026-05-11 22:15:43 +02:00
Paul Nothaft 72c55a7625 Merge pull request #467 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.47.1-beta.0
2026-05-11 22:15:31 +02:00
Paul Nothaft fad2de5abe fix(activity-log): smart feature_flags_updated rendering + 33 missing types
The Dashboard "Recent Activity" widget and the header notifications
dropdown both rendered raw activity-type strings (e.g. the literal
"feature_flags_updated") for any type missing from their lookup
maps — including everything emitted by the recently-added customer
portal (#354), webhooks (#327), API tokens (#322), event types,
event-publish flow, admin user management (#350), and the
feature-flags reorg itself.

Two coordinated changes:

1. Smart formatter for feature_flags_updated. The backend writes
   `metadata.changed = { [flagKey]: { from, to } }` on every save.
   New formatFeatureFlagsChanged() helper in admin.service.ts reads
   that diff and renders:
     - 1 change → "Customer Portal enabled"
     - N changes → "3 features updated: Customer Portal enabled,
       Calendar disabled, Quotes enabled"
   Per-flag display labels source from `settings.features.<key>.title`
   so they stay in sync with the Features tab. Unknown flag keys
   fall through to a humanised version of the key.

2. 33 missing activity types added to BOTH renderers and to the
   `admin.activities.*` + `admin.notificationMessages.*` i18n
   namespaces across all six locales. Coverage groups: customer
   portal (13 types), admin user management (6), webhooks (3),
   API tokens (2), event types (4), event publish/logo (3), bulk
   delete (1), and assorted post-merge surfaces (4).

   The notifications.service.ts switch + admin.service.ts fallback
   message map are still duplicated; consolidating them into a
   single source of truth is a follow-up worth doing before the
   next significant addition. For now both stay in sync via this PR.

en + de hand-translated. nl + pt + ru + fr machine-translated and
flagged for native review per project convention.
2026-05-11 22:06:10 +02:00
github-actions[bot] d9d52ec8ab chore(beta): release 3.47.1-beta.0 2026-05-11 19:57:31 +00:00
Paul Nothaft 441cc41937 Merge pull request #466 from the-luap/fix/features-tab-customer-portal-label
fix(features): customer-portal card uses 'Clients' to match sidebar wording
2026-05-11 21:56:57 +02:00
Paul Nothaft dec2f5d3d2 fix(features): customer-portal card uses 'Clients' to match sidebar wording
Settings → Features showed the customer-portal toggle as "Accounts"
("Konten" in DE, "Comptes" in FR, etc.) — the deeper sub-nav label
inside ClientsLayout — while the prominent menu-bar entry the admin
actually clicks first reads "Clients" / "Kunden". The mismatch was
confusing on first encounter ("which one do I look for?").

Align the Features tab card title and the "Sidebar:" callout with
the menu-bar wording (`navigation.clients`) across all six locales.
The sub-nav inside ClientsLayout keeps its own "Accounts" label —
that one matches the /admin/clients/accounts URL and is correct.
2026-05-11 21:52:42 +02:00
Paul Nothaft c86ee3d249 Merge pull request #465 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.47.0-beta.0
2026-05-11 21:52:28 +02:00
github-actions[bot] 1c23ec51ec chore(beta): release 3.47.0-beta.0 2026-05-11 19:44:45 +00:00
Paul Nothaft 4f3db923a6 Merge pull request #464 from Luca-Timo/feat/email-templates-reorg
Feat/email templates reorg
2026-05-11 21:44:17 +02:00
Luca 2343a162df fix(email-templates): backfill subcategory + customer password reset translations 2026-05-11 21:08:16 +02:00
Luca e3150e4213 feat(email-templates): seed missing locale translations + post-075 templates 2026-05-11 20:56:58 +02:00
Luca 53eecb6f83 feat(email-templates): group Templates UI by category with core sub-sections 2026-05-11 20:56:50 +02:00
Luca 2cae3fe47d feat(email-templates): categorise + sub-categorise + link to feature flags 2026-05-11 20:56:28 +02:00
Luca 358f7ee99e feat(email-templates): seed missing nl/pt/ru/fr translations 2026-05-11 20:42:28 +02:00
Luca 5ec26fc998 feat(email-templates): group Templates UI by category + Feature off chip 2026-05-11 20:42:16 +02:00
Luca 84c06affb7 feat(email-templates): categorise + link to feature flags 2026-05-11 20:41:50 +02:00
Paul Nothaft a125ea2ada Merge pull request #463 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.46.3-beta.0
2026-05-11 20:19:43 +02:00
github-actions[bot] b31fc2140b chore(beta): release 3.46.3-beta.0 2026-05-11 18:18:34 +00:00
Paul Nothaft bd2288e6a0 Merge pull request #461 from the-luap/fix/branding-socials-not-loaded
fix(branding): socials + promo round-trip from DB to form (#460)
2026-05-11 20:18:09 +02:00
Paul Nothaft ae64a6acbc fix(branding): socials + promo round-trip from DB to form (#460)
formatBrandingSettings was updated when the BrandingSettings
interface added the footer-overhaul fields (#441 / #440), so the
admin BrandingPage initialised them as empty strings on every load.
Saving any other field then sent the form's empty socials /
promo_markdown / promo_position back to the backend and wiped the
saved values from the DB. The public gallery footer kept rendering
the old values until the next save, which is why the bug appeared
asymmetric (visible to galleries, gone from the admin form).

Add the missing read mappings for the seven branding_* keys so the
form round-trips them correctly.

Reported by @Rekoo-PS in #460 (split out of #447).
2026-05-11 20:11:19 +02:00
Paul Nothaft 4e0d26d3f8 Merge pull request #462 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.46.2-beta.0
2026-05-11 20:09:57 +02:00
github-actions[bot] eddcdbf4d8 chore(beta): release 3.46.2-beta.0 2026-05-11 18:07:41 +00:00
Paul Nothaft 9776d8a6fc Merge pull request #458 from Luca-Timo/fix/customer-functions
fix(customer-portal): post-merge fixes for event save, theme fonts, and customer→gallery handoff
2026-05-11 20:07:11 +02:00
Luca c0c6b4c0e8 chore(customer-portal): align flag-gate comments with new dual-enforcement 2026-05-11 19:38:18 +02:00
Luca 2a7ae0702d fix(events): CustomerAccountPicker hooks order crashed /admin/events/new 2026-05-11 19:37:52 +02:00
Paul Nothaft 06733f841a Merge pull request #457 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.46.1-beta.0
2026-05-11 19:08:13 +02:00
Luca 8d9d0bea83 fix(customer): customer sidebar active state matches admin pattern 2026-05-11 16:48:29 +02:00
Luca eb0c45e0f4 chore(i18n): drop dead navigation.customers keys 2026-05-11 16:39:30 +02:00
Luca 9091ed4012 feat(clients): scaffold top-level Clients section with sub-nav around Accounts 2026-05-11 16:17:14 +02:00
Luca 35f5b86d0f fix(theme): 'Same as body' heading font no longer inherits stale value 2026-05-11 11:50:09 +02:00
Luca 7ac1d14738 fix(customer): preserve slug-scoped gallery tokens on auth provider mount 2026-05-11 11:32:59 +02:00
Luca bf7ef14626 fix(settings): readable contrast on accent-tinted icon tiles + pills 2026-05-11 11:27:16 +02:00
Luca 2f00bbdd90 fix(settings): neutralize sidebar icons for a consistent palette 2026-05-11 11:11:23 +02:00
Luca 15d01f3756 fix(features-tab): icon tiles + preview pills follow CI accent 2026-05-11 11:07:55 +02:00
Luca 75e41eba03 feat(branding): toggle login-page logo frame + size 2026-05-11 11:02:33 +02:00
Luca dde72a1b1b fix(events): strip customer_account_ids from update spread 2026-05-11 10:53:12 +02:00
github-actions[bot] 3ea7eea4b7 chore(beta): release 3.46.1-beta.0 2026-05-11 08:07:45 +00:00
Paul Nothaft 5b148542e6 Merge pull request #455 from the-luap/fix/photo-dimensions-and-default-fit
fix(import): capture photo dimensions in fileWatcher + s3AutoImporter (#447)
2026-05-11 10:07:07 +02:00
Paul Nothaft 88a865c813 Merge pull request #456 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.46.0-beta.0
2026-05-11 10:06:41 +02:00
Paul Nothaft 2f63188a34 fix(events): TDZ ReferenceError on /admin/events from #442 fix (#454)
The pagination-clamp useEffect added in #448 (commit 9c4a96f) was
inserted at the top of the component body, BEFORE the useQuery that
declares `data`. Because the useEffect's dependency array
`[data?.pagination, page]` is evaluated immediately when that line
executes, every render hit a temporal dead zone access on `data` and
threw `ReferenceError: Cannot access 'data' before initialization`
— minified to "Cannot access 'I' before initialization" in the
production bundle, crashing the entire page.

TypeScript caught this at the time
(`Block-scoped variable 'data' used before its declaration`) but the
project's build doesn't fail on TS errors so it shipped anyway.

Move the effect to immediately after the useQuery so `data` is in
scope. Behavior unchanged otherwise — same dep array, same setPage
clamp logic.

Reported by @derooijmnl on v3.45.1-beta.0.
2026-05-11 10:03:40 +02:00
Paul Nothaft 49b36a0352 chore(migrations): renumber 090 → 096 + small notes from #403 review
Post-merge cleanups after #403 (customer portal):

- Renumber 090_backfill_photo_dimensions_v2.js → 096 to follow #403's
  090_add_customer_accounts ... 095_add_customer_portal_flag chain.
- customerAccountsService.js: TODO note on must_change_password
  documenting that the column is decorative until an admin
  pre-loaded-password flow ships (mirrors what adminAuth does for
  must_change_password today).
- customerAuth.js: doc-comment on the /login route explaining why the
  customerPortal feature flag deliberately doesn't gate it (toggle off
  hides UI, doesn't revoke existing-customer access; deactivate
  individual accounts to lock out).
- 095_add_customer_portal_flag.js: header comment said "Migration 094"
  (copy-paste from 094) — now matches the filename.
2026-05-11 09:59:47 +02:00
Paul Nothaft 936a277eb8 fix(import): capture photo dimensions in fileWatcher + s3AutoImporter (#447)
The aspect-aware gallery layouts (masonry / mosaic / justified) read
photo.width and photo.height to size each card to the source's real
proportions. Two import paths were inserting rows without those
fields, which forced MasonryGalleryLayout to fall back to a hard-coded
800×600 default — every card came out the same shape, so users
reported masonry as "always cropped to 1:1ish" no matter which
thumbnail fit mode they chose.

- fileWatcher.js: extract dims with sharp.metadata() before insert.
- s3AutoImporter.js: same, materialising a tmp local copy via
  withLocalCopy so it works in S3 mode.
- migration 090: backfill any pre-existing rows with NULL dims
  (skips videos, skips S3 deployments — those need the writer fix
  alone since migrations cannot reach the storage backend).
- imageProcessor.js: change DEFAULT_THUMBNAIL_FIT from 'cover' to
  'inside' (only kicks in when the seed setting is missing — existing
  installs keep their saved value). Add UI tooltip recommending
  'inside' for masonry/mosaic/justified, 'cover' for uniform grids.

i18n covers all six locales.
2026-05-11 09:58:08 +02:00
github-actions[bot] 87dfae0074 chore(beta): release 3.46.0-beta.0 2026-05-11 07:57:50 +00:00
Paul Nothaft fe5295373b Merge pull request #403 from Luca-Timo/feat/user-accounts
feat: customer accounts (#354) — recurring logins, profile, password reset, branded customer surface
2026-05-11 09:57:15 +02:00
Luca 032a43ad01 i18n(customers): add nl / pt / ru / fr translations for customer portal
Previously these locales fell through to en for every customer.* /
customers.* / settings.customerSurface / settings.features.customerPortal
key. Machine-translated and flagged in the PR description as needing
native review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 02:13:08 +02:00
Luca fd46c171ce chore(branding): move Customer dashboard card between Company Info and Gallery Theme
Keeps the customer-surface branding toggles adjacent to the other
brand-visibility controls instead of floating at the bottom of the
page, where they were easy to miss.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 01:59:30 +02:00
Luca b252cb67eb feat(branding): Customer dashboard header toggles in Branding page
Adds back the "Show logo" / "Show company name" toggles for the
customer dashboard, scoped to /customer/* surfaces only. Lives as a
dedicated card at the bottom of Settings → Branding, gated by the
customerPortal feature flag so admins who haven't enabled the portal
don't see it.

* Backend: restored GET/PUT /admin/settings/customer-surface
  endpoints, whitelisted only to the two branding keys
  (customer_show_logo, customer_show_company_name). The
  calendar/quotes/bills feature globals that used to live on this
  endpoint are now driven by the Features tab (feature_flags table).
* customerAccountsService.getCustomerSurfaceGlobals() reads from
  app_settings again so /api/customer/auth/session honours the
  toggles in its branding payload.
* New CustomerDashboardBrandingCard component with its own save
  flow — separate from the main BrandingPage payload so flipping a
  toggle doesn't replay the full branding mutation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 01:52:27 +02:00
Luca da08a5828a fix(customer): unwrap /customer/* from RequireFeature gate
RequireFeature calls useFeatureFlags(), which throws unless mounted
inside FeatureFlagsProvider — and that provider only wraps
AdminLayout. So unauthenticated visitors hitting /customer/login
crashed into the React error boundary with 'Oops! Something went
wrong'.

The customerPortal flag continues to hide every admin-side surface
(sidebar entry, /admin/customers routes, CustomerAccountPicker on
event forms), which is what the flag is actually for. The
customer-side tree stays reachable so existing customers can still
log in even if the admin flips the flag off temporarily.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 01:30:19 +02:00
Luca f048011324 fix(server): mount /api/admin/feature-flags route
The route was registered in upstream/beta's server.js but dropped
during the rebase squash — the Features tab GET/PUT both 404'd, so
the customerPortal flag (and every other flag) couldn't be toggled.
Restored the mount in its upstream/beta position.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 01:13:17 +02:00
Luca 4fa7225732 fix(server): drop missing requireCustomerPortal middleware import
server.js was still requiring ./src/middleware/requireCustomerPortal
— a file deleted during the AdvancedFeaturesTab cleanup — which
crashed the backend on boot in production (MODULE_NOT_FOUND).

The customerPortal feature flag is now enforced on the frontend via
<RequireFeature flag="customerPortal" /> route guards (App.tsx) and
AdminSidebar visibility. Defence in depth is provided by
customerAccountsService.isCustomerPortalEnabled() in adminEvents.
Routes themselves are still protected by adminAuth / customerAuth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 00:48:13 +02:00
Luca adfa29e91e fix(auth): restore COOKIE_SECURE='auto' default for production
The customer-portal squash inadvertently reverted the upstream/beta
fix from PR #427: production NODE_ENV was flipping the cookie Secure
flag back to hard `true`, which broke admin login on
HTTPS-frontend → HTTP-backend reverse-proxy stacks (browser drops
the Secure cookie over HTTP, login loops indefinitely).

Restored upstream/beta's tokenUtils.js verbatim and re-layered only
the customer cookie helpers (CUSTOMER_COOKIE_NAME,
setCustomerAuthCookie, clearCustomerAuthCookie,
getCustomerTokenFromRequest) on top.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 00:29:04 +02:00
Luca 087ef45942 feat(customers): customer portal (#354) on top of feature-flags reorg
Implements the recurring-customer login surface from
the-luap/picpeak#354 plugged into the maintainer's
new feature-flag infrastructure (PR #443) instead of
a parallel toggle.

* New `customerPortal` feature flag (foundation flag for the
  not-yet-built calendar/quotes/bills/messaging customer
  surfaces). Defaults FALSE on fresh installs, TRUE on existing
  installs (events > 0) via migration 095 so live customer
  accounts don't disappear mid-deployment.
* Foundation schema: customer_accounts, customer_invitations,
  event_customer_assignments, customer_password_resets, plus
  RBAC permissions customers.view / .create / .delete granted
  to super_admin + admin system roles.
* Backend: /api/admin/customers (invite, list, search, assign,
  deactivate, reset password) + /api/customer/auth/* +
  /api/customer/* (login, dashboard, accept-invite, reset).
  Customer JWT bypass minted via
  /api/customer/events/:slug/access-token so existing gallery
  middleware stays untouched.
* Frontend: /customer/* route tree gated by RequireFeature flag
  customerPortal, with login / dashboard / accept-invite /
  reset pages and a customer-side sidebar layout.
  /admin/customers and /admin/customers/:id gated identically.
* Settings → Features grows a "Customers" section with a
  Customer portal card. The maintainer's Features tab stays the
  single source of truth — no parallel Advanced features tab.
* CustomerAccountPicker on event create/edit forms hides itself
  when the flag is off; backend ignores customer_account_ids in
  that case instead of erroring the whole event save.

Translations: en + de hand-translated. nl/pt/ru fall through to
en — flagged here as needing native review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 00:05:20 +02:00
Paul Nothaft f2f48f31b0 Merge pull request #453 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.45.1-beta.0
2026-05-10 22:09:48 +02:00
github-actions[bot] 55e6395e9e chore(beta): release 3.45.1-beta.0 2026-05-10 20:09:09 +00:00
Paul Nothaft 37d487db86 Merge pull request #452 from the-luap/fix/create-event-branding-default-race
fix(create-event): branding-default theme survives eventTypes refetch (#323-B)
2026-05-10 22:08:42 +02:00
Paul Nothaft d62c529b02 fix(create-event): branding-default theme survives eventTypes refetch
The "apply recommended preset on event-type change" effect was firing on
the initial mount AND every time the eventTypes API resolved (because
availableEventTypes is recomputed when that query settles). The first
fire matched the wedding default and clobbered the global Branding
theme that the previous effect had just applied.

Track the previous event_type in a ref and bail out when it hasn't
actually changed. The Branding-default effect now wins on first paint,
and the recommended-preset behaviour still kicks in when the user
manually picks a different event type.

Restores the green state of smoke spec 07 (#323-B regression).
2026-05-10 22:02:53 +02:00
Paul Nothaft 6ddb8e34d2 Merge pull request #451 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.45.0-beta.0
2026-05-10 21:58:21 +02:00
github-actions[bot] 421354bc5a chore(beta): release 3.45.0-beta.0 2026-05-10 19:57:54 +00:00
Paul Nothaft f3505c2631 Merge pull request #450 from the-luap/feat/footer-overhaul-441-440
feat(footer): hideable legal links + socials + promo banner (#441 + #440)
2026-05-10 21:57:35 +02:00
Paul Nothaft be207d7120 Merge pull request #449 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.44.2-beta.0
2026-05-10 21:54:23 +02:00
github-actions[bot] d2864854e7 chore(beta): release 3.44.2-beta.0 2026-05-10 19:51:03 +00:00
Paul Nothaft b4e30a4293 Merge pull request #448 from the-luap/fix/issue-442-bulk-delete-pagination
fix(events): clamp page state when totalPages drops below current page (#442)
2026-05-10 21:50:36 +02:00
Paul Nothaft 3a731e7c95 feat(footer): hideable legal links + socials + promo banner (#441 + #440)
Combined footer overhaul:

- Per-CMS-page show_in_footer toggle (#441) — admins can hide
  Impressum / Datenschutz from the gallery footer when an external
  privacy / imprint URL is enough.
- Five social-media URL fields in branding settings (#441) — Facebook,
  Instagram, WhatsApp, X/Twitter, YouTube. Empty string hides each
  icon individually; the row is omitted when none are set.
- Promotional banner slot above or below the gallery footer (#440) —
  global default authored as markdown in branding settings, plus a
  three-way per-event override on the Edit Event form
  (inherit / custom / off). Backend nulls promo_markdown automatically
  when mode != 'custom' so stale text never persists.

Sanitization: marked with gfm/breaks → DOMPurify with a tight
allowlist (no img, no tables, no inline html). Post-process forces
target=_blank rel="noopener noreferrer nofollow" on every link so
admin-set URLs can't tab-nap the gallery context.

i18n covers all six locales (en/de/nl/pt/ru/fr).

Targets the beta branch.
2026-05-10 21:46:20 +02:00
Paul Nothaft 9c4a96fe97 fix(events): clamp page state when totalPages drops below current page (#442)
Bulk-deleting all events on the current page left the list empty until
manual reload. After the React Query refetch returned `events: []` with
a smaller `totalPages`, the page state was stuck on the old (now
out-of-range) page index — the backend correctly serves an empty page
for `page > totalPages`, but the UI had no logic to step back.

Add a useEffect that watches `data.pagination.totalPages` against the
current `page` and resets `page = max(1, totalPages)` whenever the
result count shrinks. Fires after every refetch so it covers bulk
delete, individual delete, archive, and any filter change that
shrinks the result set — same one-line guarantee.

Reported by @Rekoo-PS in #442.
2026-05-10 21:18:10 +02:00
Paul Nothaft c52c1c8419 Merge pull request #446 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.44.1-beta.0
2026-05-10 21:02:45 +02:00
github-actions[bot] 481f8c2f9f chore(beta): release 3.44.1-beta.0 2026-05-10 19:01:51 +00:00
Paul Nothaft 3fd8af3d56 Merge pull request #445 from the-luap/fix/issue-426-edit-allows-clearing-expiry
fix(events): admins can clear expiration on edit even when 'Require expiration' is ON (#426)
2026-05-10 21:01:28 +02:00
Paul Nothaft e54456135c fix(events): admins can clear expiration on edit even when "Require expiration" is ON (#426)
iSchumi6210 reported that with the global "Require expiration date"
toggle ON, an admin couldn't clear the expiration on an existing event
via the Edit Event form. The PUT returned 400 "Expiration date is
required."

The cause was intentional in the original code: the global setting was
enforced on both create AND edit, so once flipped ON, no event could
ever be cleared of its expiration — not even by admins editing one-by-
one. Reproduced the exact scenario byte-for-byte against beta:

  Toggle ON → POST /admin/events {expiration_days: 30} → 200 created
  Toggle ON → PUT /admin/events/:id {expires_at: null} → 400 rejected

The setting now controls only the create-time default. On edit, an
admin can clear the field and the value persists as NULL ("never
expires"). Matches CMS-style admin tool conventions where field-
required-by-default doesn't lock the field after creation.

Backend: drop the `getEventFieldRequirements()` enforcement on the
expires_at branch in PUT /admin/events/:id. Empty/null on edit
normalizes to NULL.

Frontend: drop the matching `requireExpiration && !editForm.expires_at`
toast in EventDetailsPage. The variable is no longer referenced, so
remove its declaration too.

Verified end-to-end with toggle ON:
  STEP 1: create with expiration → ok (unchanged)
  STEP 2: create without expiration → backend auto-applies default 30d
          (create-time enforcement intact)
  STEP 3: PUT {expires_at: null} on existing → "Event updated
          successfully" (was 400)
  STEP 4: DB column expires_at is NULL
  STEP 5: PUT {expires_at: ''} also accepted (matches what an HTML
          date input sends when cleared)

Smoke 13/13 green; no regressions.
2026-05-10 20:54:36 +02:00
Paul Nothaft f1866e39c6 Merge pull request #444 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.44.0-beta.0
2026-05-10 20:49:16 +02:00
github-actions[bot] f6be07ed4b chore(beta): release 3.44.0-beta.0 2026-05-10 18:47:55 +00:00
Paul Nothaft c3798e19c8 Merge pull request #443 from the-luap/feat/feature-flags-settings-reorg
feat(settings): Features tab + sidebar reorg with feature-flag gating
2026-05-10 20:47:30 +02:00
Paul Nothaft 15e333681f feat(settings): Features tab + sidebar reorg with feature-flag gating
Reorganises the admin sidebar around what users actually do, and adds a
single Features page that gates which feature surfaces appear in the
nav. Shrinks the main sidebar from 11 items to 4-6 (depending on
feature flags) and groups configuration screens into a single Settings
home with six logical sections.

Why
---
The current sidebar mixes three concerns: workspaces (Dashboard, Events,
Archives), feature surfaces (Analytics, Users), and configuration
screens that get touched maybe once a month (Email Settings, Branding,
Event Types, Backup, CMS Pages). That's 11 items, half of them config.

Backend
-------
- New `feature_flags` table (key, value, updated_at, updated_by).
  Migration 088 detects existing-vs-fresh installs from the events
  table:
    * Existing install (events>0)  → all 9 flags TRUE so nothing
      vanishes from an admin's UI on upgrade.
    * Fresh install      (events=0) → spec defaults: galleries,
      reminderEmails, analytics, userManagement TRUE; calendar,
      calendarBooking, quotes, bills, messaging FALSE.

- New `/api/admin/feature-flags` (GET/PUT) under `settings.view` and
  `settings.edit`. Server enforces the same dependency rules the
  frontend does (galleries always TRUE, quotes=false → bills=false,
  calendar=false → calendarBooking=false). PUT writes one
  `feature_flags_updated` activity log row with the diff.

Frontend
--------
- `FeatureFlagsContext` provides `useFeatureFlags()` (with staged/save/
  reset/isDirty) and `useFeatureEnabled(key)`. Mounted inside
  AdminLayout so flag fetches carry the auth cookie. Source of truth
  is the server response; staged is a local copy that the Features tab
  edits and the Save button PUTs.

- `RequireFeature` route guard for /admin/analytics and /admin/users —
  redirects to /admin/dashboard when the corresponding flag is OFF.

- AdminSidebar dropped from 11 to 6 items. Removed: Email Settings,
  Branding, Event Types, Backup, CMS Pages (now Settings tabs).
  Feature-gated: Analytics, Users.

- Old top-level routes (/admin/email, /admin/branding, /admin/event-
  types, /admin/backup, /admin/cms) kept as <Navigate> redirects to
  /admin/settings?tab=<key> so existing bookmarks don't 404.

- SettingsPage rewritten with a 6-group inner-nav (General /
  Content & Appearance / Communication / Privacy & Security /
  Integrations / System) and 19 tabs. New Features tab is the
  default landing tab. URL ?tab=<key> roundtrips with state — deep
  links and the back button work.

- FeaturesTab renders 9 cards across 5 sections. Toggles enabled for
  Analytics + User Management (the two flags that gate sidebar items
  in this PR). All other toggles disabled with a "Not yet available"
  lockedReason — the cards still render so admins see the roadmap, but
  the flag has no UI effect until the surface ships in its own PR. The
  galleries card is locked TRUE per spec (foundation, can't be off).

- Live SidebarPreview reflects unsaved staged changes — admins see
  what their sidebar will look like before they save.

- New i18n keys across all 5 locales (en, de, nl, pt, ru) for the
  Features tab copy, the new Settings group labels, and the lifted
  tab titles.

Verified end-to-end
-------------------
- Migration on this dev DB (existing install, 977 events): all 9 flags
  set to TRUE.
- Migration on simulated fresh install (events table emptied): spec
  defaults applied (5 OFF, 4 ON).
- Backend round-trip: GET → PUT → audit-log entry written, dependency
  rule enforced (bills forced false when quotes=false even when bills=
  true requested).
- UI Playwright spec: sidebar dropped 5 items, old top-level routes
  redirect, Features tab is default, Galleries+Calendar+Quotes+Bills+
  Messaging+ReminderEmails toggles disabled, Analytics+Users toggles
  enabled, toggling Analytics off + saving updates the sidebar +
  redirects /admin/analytics to /admin/dashboard.
- Smoke 13/13 still green; no regressions on existing flows.
2026-05-10 20:36:32 +02:00
Paul Nothaft b6aaea21ca Merge pull request #439 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.43.3-beta.0
2026-05-09 21:01:30 +02:00
github-actions[bot] fab0fcdde3 chore(beta): release 3.43.3-beta.0 2026-05-09 19:00:52 +00:00
Paul Nothaft d3007b0dd2 Merge pull request #438 from the-luap/fix/gallery-s3-serving-432
fix(gallery): serve thumbnails / photos / hero via storage abstraction (#432)
2026-05-09 21:00:31 +02:00
Paul Nothaft 40d4c96998 Merge pull request #437 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.43.2-beta.0
2026-05-09 20:58:14 +02:00
Paul Nothaft 83d79f4d39 fix(gallery): serve thumbnails / photos / hero via storage abstraction (#432)
Three gallery serving routes bypassed the getStorage() abstraction and
used fs.* directly against local paths. Worked in local-fs mode, 500'd
in S3 mode because the files only exist in the bucket. Reported by
@w1ll-i-code with a precise root-cause pointer at gallery.js:1138.

The admin photo serving route (adminPhotos.js) had already been
converted to use storage.stat + storage.get; the gallery side hadn't.
This PR brings the gallery routes in line.

Changes:

- Add getRange(relPath, start, end) to the StorageBackend interface +
  LocalFsStorage (fs.createReadStream with start/end) + S3StorageBackend
  (downloadStream with Range header). Needed for video range requests
  on S3 — previously the photo route did fs.createReadStream(filePath,
  {start, end}) which is local-only.

- /:slug/thumbnail/:photoId — read mtime via storage.stat, stream bytes
  via storage.get. Watermark application path materializes the source
  via withLocalCopy (no-op in local mode, downloads to a tmp file then
  cleans up in S3 mode) so applyWatermark's sharp + fs.readFile still
  works.

- /:slug/photo/:photoId — branches on source_origin: external/reference
  photos still use the local fs path (NAS mounts are local), managed
  photos use the storage abstraction. Video range requests pass through
  to storage.getRange. Pre-generated watermarks served via storage too.
  On-the-fly watermark generation uses withLocalCopy for managed photos.

- /:slug/hero/:photoId — hero images are always managed-storage keys
  (imageProcessor.generateHeroImage writes via the storage abstraction),
  so this just switches to storage.stat + storage.get. Watermark via
  withLocalCopy.

Verified end-to-end against minio in dev:
  POST /api/admin/photos/N/upload         → photo + thumbnail land in S3
  GET /api/gallery/<slug>/thumbnail/<id>  → 200, JPEG 300x300 ✓
  GET /api/gallery/<slug>/photo/<id>      → 200, JPEG 1200x800 ✓
  GET /api/gallery/<slug>/hero/<id>       → 200, JPEG 1920x1080 ✓
  ETag round-trip (If-None-Match)         → 304 ✓
  Backend logs                            → no errors

LocalFs regression: 13/13 smoke tests pass.

Closes #432.
2026-05-09 20:56:20 +02:00
github-actions[bot] 197524a918 chore(beta): release 3.43.2-beta.0 2026-05-09 18:36:38 +00:00
Paul Nothaft ed37caf3d8 Merge pull request #434 from PiR1/doc/update-contributing
docs(contributing): update branch reference from main to beta
2026-05-09 20:36:20 +02:00
Paul Nothaft 02bbc5c30d Merge pull request #436 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.43.1-beta.0
2026-05-09 20:35:01 +02:00
github-actions[bot] 9ca9563dbe chore(beta): release 3.43.1-beta.0 2026-05-09 18:34:26 +00:00
Paul Nothaft b314bb21d3 Merge pull request #428 from PiR1/fix/update-event-access
Fix/update event access
2026-05-09 20:33:59 +02:00
PiR1 916580adef fix(event): ensure client share token is generated only when necessary 2026-05-09 20:28:16 +02:00
PiR1 479f16085b chore(changelog): update unreleased section with event access fix 2026-05-09 20:28:16 +02:00
PiR1 d00f6fa7de fix(event): correct updating client access 2026-05-09 20:26:55 +02:00
Paul Nothaft 087d71e8a7 Merge pull request #435 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.43.0-beta.0
2026-05-09 20:23:00 +02:00
github-actions[bot] 967ade7a9a chore(beta): release 3.43.0-beta.0 2026-05-09 18:22:30 +00:00
Paul Nothaft de871b32cc Merge pull request #425 from PiR1/feat/improve-localization
Feat/improve localization
2026-05-09 20:22:04 +02:00
PiR1 c114749921 docs(contributing): update branch reference from main to beta 2026-05-09 20:00:25 +02:00
PiR1 46b99c6292 feat(localization): improve English translations for clarity and consistency 2026-05-09 19:13:45 +02:00
PiR1 2c1288583f feat(localization): add French translations for fit options in thumbnails 2026-05-09 19:03:30 +02:00
PiR1 5fc427c74b feat(localization): update thumbnail settings and add fit options translations 2026-05-09 18:42:15 +02:00
PiR1 d1bc5e030f docs(localization): enhance French language support and improve i18next configuration 2026-05-09 18:23:12 +02:00
PiR1 e7228b0780 feat(localization): add i18next extraction helper & refactor backup configuration component to tsx 2026-05-09 18:23:12 +02:00
PiR1 86ee6c80aa feat(localization): add missing translations 2026-05-09 18:23:11 +02:00
PiR1 74e87b968b feat(localization): add i18next configuration and CLI commands for localization management 2026-05-09 18:23:11 +02:00
PiR1 a5db4bd46e feat(translations): add French language support and improve localization handling 2026-05-09 18:23:11 +02:00
Paul Nothaft fd98a78123 Merge pull request #431 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.7-beta.0
2026-05-09 16:03:38 +02:00
github-actions[bot] 72a7a43a4a chore(beta): release 3.42.7-beta.0 2026-05-09 14:00:44 +00:00
Paul Nothaft e1c93823c4 Merge pull request #429 from the-luap/fix/cookie-secure-auto-default-427
fix(auth): default COOKIE_SECURE to 'auto' in production + first-install UX (#427)
2026-05-09 16:00:19 +02:00
Paul Nothaft 63c1b028bf Merge pull request #430 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.6-beta.0
2026-05-09 16:00:02 +02:00
github-actions[bot] 715a806991 chore(beta): release 3.42.6-beta.0 2026-05-09 13:59:36 +00:00
Paul Nothaft e2ffd9f93d Merge pull request #424 from the-luap/fix/external-thumbnails-423
fix(external-media): pre-generate thumbnails so reference-mode galleries load fast (#423)
2026-05-09 15:59:17 +02:00
Paul Nothaft 5c7de96b7f fix(auth): default COOKIE_SECURE to 'auto' in production + first-install UX (#427)
Two intertwined bugs reported in #427 by @iSchumi6210:

1. Login silently fails over HTTP. Backend defaulted COOKIE_SECURE to true
   when NODE_ENV=production. Over plain HTTP the browser drops the Secure
   cookie → next /auth/session request returns 401 → redirect back to
   /admin/login → no error shown. picpeak-setup.sh writes
   NODE_ENV=production but never writes COOKIE_SECURE, so every first-time
   install without a reverse proxy hits this.

2. Admin password is generated but admins can't find it. The 001_init.js
   migration writes the generated password to data/ADMIN_CREDENTIALS.txt
   inside the backend container, but picpeak-setup.sh only copies it out
   when --reset-admin-password is passed. Default-path users never see it
   and resort to manual bcrypt updates in psql.

Changes:

- tokenUtils.js: production default goes from `true` to `'auto'`. On real
  HTTPS req.secure is true → Secure flag is still emitted (no security
  regression for reverse-proxy deployments). On plain HTTP req.secure is
  false → Secure flag omitted → login works. Users who explicitly want
  the strict HTTPS-only behaviour can still set COOKIE_SECURE=true.

- .env.example: rewrite the COOKIE_SECURE block to make the new default
  obvious and explain when to override (set =true for strict, =false to
  skip the per-request check, leave unset for the auto behaviour).

- picpeak-setup.sh (both Docker and native paths):
  - Write COOKIE_SECURE=auto explicitly to the generated .env (defense in
    depth so the right behaviour is preserved even if the backend default
    flips again later)
  - After migrations, ALWAYS copy ADMIN_CREDENTIALS.txt out of the
    backend container/data dir to the host data dir, chmod 600, and print
    the email + password to the install output. The credentials file
    remains as a backup record that the operator should delete after
    noting the password.

Verified locally with all 4 permutations of NODE_ENV × COOKIE_SECURE:

  production, unset      → HTTPS: secure=true ✓  HTTP: secure=false ✓ (was both true)
  production, =true      → both: secure=true (strict opt-in preserved)
  production, =auto      → HTTPS: secure=true   HTTP: secure=false (already-correct)
  development, unset     → both: secure=false (dev unchanged)
2026-05-09 15:55:09 +02:00
Paul Nothaft f3d0f161c9 fix(external-media): pre-generate thumbnails so reference-mode galleries load fast (#423)
External (source_origin='external') photos always had thumbnail_path=NULL,
so the gallery returned thumbnail_url=null and every layout fell back to
streaming the full original from the NAS via the secure-image route.
With ~100 NAS-mounted photos that meant minutes of wall-clock load time,
sequential per tile.

Two halves:

1. import-external route generates the thumbnail right after each
   successful insert and writes thumbnail_path on the row. Best-effort:
   a single failure logs a warning and leaves thumbnail_path=NULL —
   ensureThumbnail will retry lazily on first view. Synchronous in the
   loop adds ~100-300ms per image; for the worst-case 1000-photo import
   that's still under the typical request timeout.

2. ensureThumbnail() in imageProcessor handles external photos too —
   resolves the local NAS mount path via resolvePhotoFilePath instead of
   the storage-backend key. This covers existing externals already in
   the database that were imported before this fix: first gallery view
   per photo regenerates the thumbnail, subsequent views are fast.

Filename-collision protection: external thumbnails use
`thumb_ext<photoId>_<basename>` so two events both referencing
e.g. `IMG_0001.jpg` on different NAS subtrees can't clobber each other's
thumbnail. generateThumbnail accepts a new options.outputBasename to
support this without changing the managed-photo behaviour.

Verified locally with a 3-photo external dir and a real NAS-style import:
  POST /api/admin/external-media/events/N/import-external
  → {imported:3, thumbnailsGenerated:3, thumbnailsFailed:0}
  /api/gallery/<slug>/photos returns thumbnail_url for every photo
  Lazy-regen path: clearing thumbnail_path + deleting the file, then
  hitting /thumbnail/N regenerates and repopulates the row in 42ms.

Closes #423.
2026-05-08 19:17:15 +02:00
Paul Nothaft f6cf470291 Merge pull request #422 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.5-beta.0
2026-05-08 10:16:11 +02:00
github-actions[bot] 08707eee9e chore(beta): release 3.42.5-beta.0 2026-05-08 08:15:24 +00:00
Paul Nothaft 9326a427b3 Merge pull request #420 from the-luap/fix/update-notification-test-email-418
fix(admin): test email always sends, regardless of update availability (#418)
2026-05-08 10:15:01 +02:00
Paul Nothaft e54401930d Merge pull request #421 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.4-beta.0
2026-05-08 10:14:48 +02:00
github-actions[bot] 9ade631e13 chore(beta): release 3.42.4-beta.0 2026-05-08 08:14:16 +00:00
Paul Nothaft e165ee5d9f Merge pull request #419 from the-luap/fix/bulk-delete-typed-confirm-417
fix(events): typed-DELETE confirmation for bulk delete (#417)
2026-05-08 10:13:52 +02:00
Paul Nothaft c2b1854df6 fix(admin): test email always sends, regardless of update availability (#418)
The "Send Test Email" button on the Update Notifications settings page
called sendUpdateNotificationNow() — which bailed out with "No updates
available" when the instance was already on the latest version. Admins
on a current install had no way to verify their SMTP / recipient list
was working until an update happened to be pending. Reported in #418
by @Rekoo-PS.

Changes:

- Add migration 087: insert a dedicated `version_update_test` email
  template (EN + DE, matching the existing version_update_available
  convention) with copy that reads as a config-check rather than as a
  real update notice. Subject prefixed with [TEST] so it's unambiguous
  in the inbox. Variables: current_version, channel, recipient_email.

- Replace sendUpdateNotificationNow() with sendTestUpdateNotification()
  in updateNotificationService.js. The new path:
    - Always sends — no updateAvailable bail-out.
    - Uses the version_update_test template.
    - Falls back gracefully if checkForUpdates fails (so a transient
      GitHub API hiccup doesn't block a config-check email).
    - Does NOT update last_notified_version — that field stays owned by
      the real-update path so a test send doesn't shadow a future
      genuine notification for the same version.

- Wire /admin/system/updates/notifications/send to the renamed function.
  No frontend change needed (the button already calls this endpoint).

Verified locally with the dev mailhog: clicking Send Test Email on a
3.42.3-beta.0 instance (which has no pending update) delivers 4 emails
to all admin recipients with subject "[TEST] PicPeak Update Notification
— configuration check" and body interpolated correctly. Returns
{success: true, successCount: 4, ...} — previously would have returned
{success: false, message: "No updates available"}.
2026-05-08 09:57:31 +02:00
Paul Nothaft 99e420b1b9 fix(events): typed-DELETE confirmation for bulk delete (#417)
The bulk-delete modal previously used a password input as a confirmation
gate, with an Enter-to-submit handler. Windows Hello / passkey flows
that target password fields were able to autofill and synthesise an
Enter keystroke, which submitted the form and triggered the destructive
delete without an explicit click on the red Delete button (Rekoo's
report in #417).

Replace the password gate with a GitHub-style typed-literal pattern:
the user types the literal "DELETE" (English, case-sensitive) into a
plain text input. The Delete button stays disabled until the input
matches, and there is no Enter-to-submit handler — only an explicit
click on the red button proceeds. Plain text inputs aren't subject to
password autofill or passkey ceremony so the auto-submit class of bug
is gone.

Server side, drop the bcrypt password verify on /admin/events/bulk-delete
and the related INVALID_PASSWORD response. The server's auth boundary
remains adminAuth + requirePermission('events.delete'); this matches
DELETE /admin/events/:id which has never required a re-entered password.
The client-side typed gate is the safeguard against accidental clicks.

i18n: drop password-related keys, add confirmLabel + confirmHelp across
en, de, nl, pt, ru. The literal "DELETE" stays English in all locales
to keep the gesture immune to translation drift and unambiguous.

Verified locally: typed-DELETE sanity spec covers the gate (wrong case
disabled, correct enables, Enter-on-input no-ops, click submits, events
deleted). Existing 03-bulk-archive smoke remains green.
2026-05-08 09:46:24 +02:00
Paul Nothaft f57429faf2 Merge pull request #415 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.3-beta.0
2026-05-07 23:05:40 +02:00
github-actions[bot] e8df3de3fb chore(beta): release 3.42.3-beta.0 2026-05-07 21:04:20 +00:00
Paul Nothaft 7abfeb91cc Merge pull request #414 from the-luap/security/scan-cleanup-2026-05-08
fix(security): scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage
2026-05-07 23:03:48 +02:00
Paul Nothaft 401abf7a27 fix(create-event): re-apply Branding theme on stale→fresh settings (#323-B)
CreateEventPage's branding-default effect used a boolean ref guard that
locked in whichever theme_config arrived first. React Query can hand the
observer a cached (stale) copy on initial render and then push fresh data
once the network call resolves — the boolean ref meant the form kept the
stale theme and ignored the fresh one.

Replace the ref with a stringified-hash check: re-apply when the source
actually changes (including stale → fresh) but skip when nothing has.
User edits via the customizer aren't disturbed because settings.theme_config
only refreshes on a real Branding save, not on form state.

This unblocks the local pre-push smoke gate's 07-branding-default test,
which was test.fixme'd against this exact React Query staleness.
2026-05-07 22:42:16 +02:00
Paul Nothaft 6b6191a426 fix(security): scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage
Triage of an external SAST/SCA scan run on 2026-05-06. Most loud findings
were already resolved by PR #412 (the 18-CVE backport); this PR addresses
the residual real items:

* Drop unused `handlebars` from backend deps. The runtime require was
  removed in PR #367 (#367) but the package.json line stayed. handlebars
  was the source of two flagged criticals (CVE-2026-33937 RCE,
  GHSA-2w6w-674q-4c4q AST injection) plus 8 highs — all now gone.

* `npm audit fix` on backend + frontend. Bumps transitive picomatch,
  flatted, postcss, brace-expansion via lockfile, and direct dompurify,
  lodash, vite, i18next-http-backend within their existing semver ranges.
  Both audits now report 0 vulnerabilities.

* Add `event.origin === window.location.origin` check to the THEME_PREVIEW
  message listener in PreviewPage. The branding page posts from the same
  origin, so nothing legitimate is rejected; without the check, any third
  party that window.open()'d the preview could push arbitrary
  branding/theme payloads (semgrep
  insufficient-postmessage-origin-validation).

* nginx: `proxy_hide_header` for X-Frame-Options, X-Content-Type-Options,
  Referrer-Policy, Content-Security-Policy, Permissions-Policy,
  Strict-Transport-Security at server level. nginx adds these itself, but
  helmet on the backend was also emitting them — clients were seeing
  duplicates (testssl flagged "Multiple X-Frame-Options / CSP /
  Permissions-Policy / Referrer-Policy headers" on the live origin).
  Single source of truth now.

* Dockerfile hardening (checkov):
  - HEALTHCHECK on backend/Dockerfile, backend/Dockerfile.dev,
    frontend/Dockerfile.dev. Frontend production Dockerfile already had
    one.
  - USER node in frontend/Dockerfile.dev (was running as root).

* GitHub Actions docker-build.yml: explicit top-level
  `permissions: contents: read`. Per-job blocks already declare
  `packages: write` where needed; this stops future steps from
  inheriting unintended privileges (CKV2_GHA_1).

Backend npm audit: 4 vulns -> 0.
Frontend npm audit: 6 vulns -> 0.
Backend unit tests: 13 suites, 131/132 passing (1 pre-existing skip).
Frontend type-check + lint: clean.

The pre-existing integration-test failures (live DB / S3 required) and
the ThemeCustomizerEnhanced QueryClientProvider failures are unrelated
and reproduce on origin/beta without these changes.
2026-05-07 22:31:51 +02:00
Paul Nothaft dfe1023161 Merge pull request #411 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.2-beta.0
2026-05-07 14:14:26 +02:00
github-actions[bot] 8ab6fc6bbd chore(beta): release 3.42.2-beta.0 2026-05-07 12:14:07 +00:00
Paul Nothaft 523f49916b Merge pull request #409 from the-luap/fix/security-deps-2026-05
fix(security): patch 18 dependency CVEs (axios + transitives)
2026-05-07 14:13:40 +02:00
Paul Nothaft b7d6ca0b65 fix(security): patch 18 dependency CVEs (axios + transitives + nodemailer + i18next-http-backend)
Closes the open Trivy code-scanning alerts for app-side dependencies.
The npm-bundled CVEs in /usr/local/lib/node_modules/npm (picomatch,
brace-expansion, ip-address inside the Node image itself) are deferred
to a separate Node-base-image PR — they're build-environment-side and
need their own compatibility testing.

## Direct dependency bumps

| Package | From | To | CVEs cleared |
|---|---|---|---|
| axios (backend + frontend) | 1.14.0 | 1.15.2 | CVE-2026-42264 (HIGH), CVE-2026-42043 (HIGH), CVE-2026-42035 (HIGH), CVE-2026-42033 (HIGH), CVE-2026-42044, CVE-2026-42042, CVE-2026-42041, CVE-2026-42040, CVE-2026-42039, CVE-2026-42038, CVE-2026-42037, CVE-2026-42036, CVE-2026-42034, CVE-2026-40175, CVE-2025-62718 |
| nodemailer (backend) | ^7.0.13 | ^8.0.5 | GHSA-vvjj-xcjg-gr5g, GHSA-c7w3-x93f-qmm8 |
| i18next-http-backend (frontend) | ^3.0.2 | ^3.0.5 | CVE-2026-41691 |
| uuid (backend) | ^11.1.0 | ^11.1.1 | CVE-2026-41907 |
| postcss (frontend, devDep) | ^8.4.21 | ^8.5.10 | CVE-2026-41305 |

## Transitive bumps (npm overrides)

For transitives whose direct parents haven't released a version that
picks up the patched range, pinned via npm overrides:

| Package | Min | CVE |
|---|---|---|
| follow-redirects (backend + frontend) | >=1.16.0 | GHSA-r4q5-vmmm-2653 |
| fast-xml-parser (backend) | >=5.7.0 | CVE-2026-41650 |
| @tootallnate/once (backend) | >=3.0.1 | CVE-2026-3449 |
| ip-address (backend) | >=10.1.1 | CVE-2026-42338 |

## Why axios is now safe to bump past 1.14.0

PR #268 originally pinned axios to 1.14.0 to avoid a supply-chain
attack on a specific compromised version range. The 1.15.x series
are post-incident upstream releases — clean. Confirmed with the
maintainer before bumping.

## Verified

* `npx tsc --noEmit` (frontend) — clean
* `npx vite build` (frontend) — clean (~4s, existing bundle-size
  warning, not new)
* Backend module-load smoke test — all critical modules load
  (`auth`, `adminAuth` middleware, `emailProcessor`, `recaptcha`,
  `storage`) with the new axios + nodemailer
* Lockfile re-verification — every targeted CVE now resolves to
  the patched version range

## Remaining out of scope

* npm-bundled CVEs inside `/usr/local/lib/node_modules/npm/` —
  picomatch CVE-2026-33671 (HIGH), CVE-2026-33672, brace-expansion
  CVE-2026-33750, ip-address (npm-internal) CVE-2026-42338. These
  live in the Node base image and require a Node base image bump
  with its own compatibility testing — separate PR.

Targeting `beta` so the bumps go through the normal release-please
flow before promotion to `main`.
2026-05-07 13:51:55 +02:00
Paul Nothaft 25e6c8034c Merge pull request #406 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.1-beta.0
2026-05-07 12:37:28 +02:00
github-actions[bot] ba405c1dd2 chore(beta): release 3.42.1-beta.0 2026-05-07 10:36:15 +00:00
Paul Nothaft 04e928d762 Merge pull request #405 from the-luap/fix/gallery-download-cta-followup
fix(gallery): WCAG-safe Download button text + extract HeaderDownloadButton (#401 follow-ups)
2026-05-07 12:35:46 +02:00
Paul Nothaft 0c80abd57b fix(gallery): WCAG-safe Download button text + extract HeaderDownloadButton (#401 follow-ups)
Two follow-ups from PR #401's review:

1. Download button text was hardcoded `color: '#ffffff'`. Once admins
   start picking palettes via #400's expanded customizer, a pale accent
   (yellow, pastel blue, etc.) leaves the button unreadable — white
   text on near-white background.

   Fix: derive the foreground colour from the accent's WCAG relative
   luminance and expose it as the new `--color-accent-fg` CSS variable
   in ThemeContext.applyTheme. Light backgrounds (L >= 0.5) get black
   text; dark backgrounds get white. Same treatment applied to
   `--color-accent-dark-fg` for the filled-CTA token.

   The Download button now reads `var(--color-accent-fg, #ffffff)` so
   any future component that paints on accent gets the same treatment
   for free, and legacy deployments before the variable is set fall
   back to the previous hardcoded white.

   Threshold-based (rather than "highest contrast ratio") to preserve
   how saturated mid-tone accents have always rendered. The Picpeak
   default green (#5C8762, L≈0.20) keeps white text — same visual
   identity as before. Only genuinely pale accents flip to black,
   which is the actual scenario the review flagged.

2. The Download button JSX was duplicated three times in
   GalleryLayout.tsx (standard/banner, minimal, hero — ~15 lines
   each). Extracted into a small inline `HeaderDownloadButton`
   component above the GalleryLayout export. Three call sites now
   collapse to a 5-line component invocation each. Markup,
   accessibility, and styling live in one place — future tweaks
   only need to happen once.

## Files

- `frontend/src/utils/contrast.ts` — new helper module:
  `relativeLuminance(hex)` (WCAG 2.x sRGB luminance) and
  `getReadableForeground(hex)` (white-or-black picker).
- `frontend/src/utils/__tests__/contrast.test.ts` — 10 cases:
  fallbacks, saturated mid-tones, pale accents, near-black,
  shorthand `#RGB`, no-leading-`#`, case-insensitive, anchors
  (black/white luminance).
- `frontend/src/contexts/ThemeContext.tsx` — wire the helper into
  `applyTheme`: set `--color-accent-fg` from `accentColor` and
  `--color-accent-dark-fg` from `accentDarkColor`/`primaryColor`.
- `frontend/src/components/gallery/GalleryLayout.tsx` — extract
  `HeaderDownloadButton` component above `GalleryLayout`, replace
  three inline button blocks with the component, update its inline
  style to read `--color-accent-fg` (with the legacy `#ffffff` as
  the CSS-variable fallback).

## Verified

- `npx vitest run src/utils/__tests__/contrast.test.ts` — 10/10 pass
- `npx tsc --noEmit` — clean
- `npx eslint` clean on every touched file
- Default PicPeak green still renders white text (no regression)
- Pale accent (#fef9c3 yellow-100) now correctly renders black text
2026-05-07 11:42:25 +02:00
Paul Nothaft 183e3117b6 Merge pull request #404 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.0-beta.0
2026-05-07 11:35:02 +02:00
github-actions[bot] 9cf92f3e8e chore(beta): release 3.42.0-beta.0 2026-05-07 09:34:05 +00:00
Paul Nothaft 876b35b4a5 Merge pull request #401 from Luca-Timo/feat/gallery-header-cleanup
feat(gallery): icon-only menu, accent Download CTA (#386)
2026-05-07 11:33:34 +02:00
Paul Nothaft 8c69e950c6 Merge pull request #402 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.41.0-beta.0
2026-05-06 20:26:59 +02:00
github-actions[bot] 4a5395b3ac chore(beta): release 3.41.0-beta.0 2026-05-06 18:26:41 +00:00
Paul Nothaft 8050927607 Merge pull request #400 from Luca-Timo/feat/darkmode-color-improvement
feat(branding): 8-token CI palette + force color mode + dark-mode consistency
2026-05-06 20:26:09 +02:00
Paul Nothaft 28154da64d Merge pull request #366 from filpgame/feat/add-pt-br
Add Brazilian Portuguese (pt-BR) translation
2026-05-06 20:05:05 +02:00
Luca de8ad5fdd5 feat(gallery): icon-only menu, accent Download CTA, logo aligned (#386)
Addresses the-luap/picpeak#386 — gallery header layout cleanup.

- Drop the redundant "Menu" text label; menu button is icon-only with
  tight padding (p-2).
- Absolute-position the menu icon at the very left of the header so it
  no longer pushes the logo right with every other action. Logo wrapper
  picks up pl-12 sm:pl-14 only when a menu button is rendered, so the
  icon and logo don't overlap. When no menu button (controlsStyle:
  classic), logo is flush with .container.
- New accent-coloured "Download" CTA placed immediately left of Logout.
  Always visible when downloads are allowed; replaces the previous
  primary-coloured "Download All" header button. Same CTA appears in
  standard, hero, and minimal headers. Intentionally NOT shown in the
  no-header variant (chromeless by design).
- Coloured via var(--color-accent) inline so the button automatically
  tracks whatever palette the admin has chosen — works on plain beta
  today (#22c55e) and auto-upgrades to the CI accent when #400 lands.

The sidebar's own Download All is untouched. Old showDownloadAll prop
stays on GalleryLayout for back-compat; GalleryView now passes
showDownloadAll={false} so only the new accent button renders in the
header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 13:01:09 +02:00
Luca 21188f48d7 fix(theme): centralise force-mode enforcement inside ThemeContext so every gallery flips 2026-05-06 02:27:05 +02:00
Luca a76ecf8496 chore(theme): remove LBM-specific preset (private to maintainer instance) 2026-05-06 02:22:25 +02:00
Luca bdbe7b80a1 feat(events): Sync from Branding button in gallery theme customizer + clarified default inheritance 2026-05-06 01:59:02 +02:00
Luca 47b6b39f3a feat(email): expand email palette to 8 tokens + Sync from Branding button 2026-05-06 01:41:45 +02:00
Luca 565ae45ca7 fix(admin): tab underlines use accent (not accent-dark) for proper highlight color 2026-05-06 01:29:54 +02:00
Luca 578a1745b8 fix(branding): comprehensive sweep — replace remaining primary-* legacy colors with accent tokens 2026-05-06 01:16:52 +02:00
Luca fc2bce3a01 fix(branding): admin sidebar uses accent-dark, primary buttons follow CI token 2026-05-06 00:57:54 +02:00
Luca b19bb0c620 fix(branding): working tooltips, high-contrast selected states, gallery chrome follows accent 2026-05-05 17:29:15 +02:00
Luca 5b410ed9f8 fix(branding): selected-state accent colors, force-mode actually flips galleries, compact color picker layout 2026-05-05 16:48:47 +02:00
Luca 67d7d8d3fa feat(branding): inline force color mode with auto-save + clearer palette help text 2026-05-05 16:29:00 +02:00
Luca d2a10f6523 fix(cms): apply dark mode to CMS editor, public CMS, and admin modals 2026-05-05 16:07:21 +02:00
Luca 5a162fc8be feat(branding): force color mode (dark or light) site-wide 2026-05-05 16:06:38 +02:00
Luca 114aab5777 feat(theme): expand color settings to 8-token CI palette + alt button 2026-05-05 16:04:43 +02:00
filpgame f25559c0e7 feat(i18n): improve pt locale with pt-BR phrasings, remove duplicate pt-BR file 2026-05-05 10:53:29 -03:00
filpgame 375f51285b feat(i18n): add Brazilian Portuguese (pt-BR) locale 2026-05-05 10:49:35 -03:00
Paul Nothaft ee7de6f6a1 Merge pull request #399 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.40.1-beta.0
2026-05-05 00:22:28 +02:00
github-actions[bot] 01f5e44353 chore(beta): release 3.40.1-beta.0 2026-05-04 22:19:57 +00:00
Paul Nothaft c8e09c2a2a Merge pull request #398 from the-luap/fix/auth-session-timeout-symmetry
fix(auth): /auth/session must enforce session timeout symmetrically (#350 recurrence)
2026-05-05 00:19:31 +02:00
Paul Nothaft b106da1ede fix(auth): /auth/session must enforce session timeout symmetrically (#350 recurrence)
Third loop fix in the same /admin/login → /admin/dashboard → /admin/login
pattern as #355 and #363. Reported on v3.39.1-beta.0 — the loop returns
after a server restart or after an idle gap longer than the configured
session timeout.

## Root cause (server)

`sessionTimeoutMiddleware` is mounted on `/api/admin` (server.js:411). It
rejects with `401 SESSION_TIMEOUT` when either:
  - the in-memory `lastActivity` for the token is older than the timeout, or
  - this is the first request with this token AND the token's `iat` is
    older than the timeout (post-restart guard).

`/auth/session` lives under `/api/auth/session`, NOT under `/api/admin`,
so the middleware never runs for it. Result: an idle/old-iat admin token
returns `valid: true` from `/auth/session` while every protected
endpoint immediately rejects it with `401 SESSION_TIMEOUT`. Frontend's
401 interceptor hard-redirects to `/admin/login`, `/auth/session` says
valid again, loop closes — exact same shape as the previous two
asymmetries the symmetry pass missed.

Fix: add a non-mutating `isSessionExpired(token, decoded)` helper to
`middleware/sessionTimeout.js` that reads the same in-memory map and
applies the same lastActivity / iat-vs-timeout logic as the middleware,
without updating the map (the middleware is the only place that records
activity; `/auth/session` is read-only by design). `/auth/session`
calls the helper for `decoded.type === 'admin'` after the existing
admin-existence and password-change checks. Same try/catch fall-through
pattern as the prior fixes so a missing/broken helper doesn't fail-closed
during early bootstrap or in test stubs.

## Root cause (client race amplifying the loop)

Even with the server fix, the previous `useSessionTimeout` hook called
`AdminAuthContext.logout()` which dispatches `POST /auth/logout`
fire-and-forget AND has its own `finally { window.location.href }`,
then immediately set `window.location.href = '/admin/login?session=expired'`
on top. Two consequences:
  - The cookie wasn't reliably cleared before the new page loaded —
    if any /auth/session asymmetry slipped through, the loop replayed
    inside the same tab. New-tab and "refresh several times" "fixes"
    were just the logout request eventually completing.
  - Two redirects raced; sometimes the `?session=expired` query was
    dropped, breaking the login-page toast.

Fix: rewrite the hook to (a) await `POST /auth/logout` so the cookie
is guaranteed cleared, (b) clear `sessionStorage.admin_user` directly
instead of going through AdminAuthContext.logout (which has the
side-effect redirect we don't want), and (c) navigate exactly once
with the `?session=expired` query.

## Tests

- `__tests__/routes/authSession.symmetry.test.js` — 4 new cases under
  a `session-timeout symmetry` describe block: helper says expired →
  valid:false; helper says active → valid:true; helper not called for
  gallery tokens; helper throws → fall through to valid:true (defensive).
  Existing 9 tests still pass (mock now includes
  `isSessionExpired: jest.fn(() => Promise.resolve(false))` as the
  default).
- `__tests__/middleware/sessionTimeout.isSessionExpired.test.js` — 7
  new unit tests for the helper itself: fresh token / old-iat /
  recently-active / null-input / no-mutation / 60-min default
  boundary cases.

20 cases total, all green. Lint clean on every touched file.
2026-05-05 00:12:01 +02:00
Paul Nothaft 045620e999 Merge pull request #396 from the-luap/fix/fonts-test-mock
test(fonts): fix mock bypass and case-insensitive FS skip (#390 follow-up)
2026-05-04 23:30:44 +02:00
Paul Nothaft dedb05ae28 Merge pull request #395 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.40.0-beta.0
2026-05-04 23:30:35 +02:00
Paul Nothaft 51f28d7330 test(fonts): fix mock bypass and case-insensitive FS skip (#390 follow-up)
Two issues in the fonts service test suite added by #390 — the behaviour
assertions all passed, but 5 of 24 tests had assertions that silently
no-op'd, so any regression in those code paths would not have been
caught.

## Issue 1: jest.resetModules() bypassed the logger mock

`beforeEach` called `jest.resetModules()` then re-required `fontsService`.
After resetModules, the `jest.mock('../../src/utils/logger', ...)` factory
at the top of the file no longer applied to subsequent requires — so the
freshly-required `fontsService` captured the REAL logger while the test
file's `logger` variable still pointed at the mocked one. The 4
"warning logged" / "info logged" assertions resolved as 0 calls and
silently passed-as-noop.

The resetModules call wasn't necessary in the first place — module-level
state in fontsService is just the cache, which clearFontsCache() already
resets. And both getBundledFontsRoot() and getUserFontsRoot() read
process.env at call-time, not at module load, so the env vars set in
beforeEach are picked up without needing a fresh require.

Fix: require fontsService once at module top (inside the jest.mock
hoisting scope) and drop resetModules + the per-test re-require.

## Issue 2: case-insensitive filesystem (macOS / Windows)

The "case-insensitive duplicate within the same root" test created
`Inter/` and `INTER/` to trigger the dedup warning. On a case-sensitive
FS (Linux ext4) both directory entries exist and the dedup branch fires;
on macOS APFS or Windows NTFS the second mkdir resolves to the same
folder as the first, so only one ever exists and the dedup is
unreachable from this test setup. Test failed on macOS dev, passed on
Linux CI.

Fix: probe at load time by creating a lowercase file and checking if
its uppercase variant resolves to the same inode, then conditionally
test.skip the affected test on case-insensitive hosts. Comment in the
test body explains why.

## Result

23 of 24 tests now pass on macOS; the case-sensitive-only test runs on
Linux CI. All previously-no-op'd assertions now exercise their code
paths.
2026-05-04 23:26:25 +02:00
github-actions[bot] 3d8fe2ec6e chore(beta): release 3.40.0-beta.0 2026-05-04 21:23:32 +00:00
Paul Nothaft d04bf28808 Merge pull request #390 from Luca-Timo/feat/self-hosted-fonts
feat(branding): self-hosted webfonts with filesystem scanner
2026-05-04 23:23:04 +02:00
Luca b609a7a88c test(fonts): unit-test scanner edge cases and meta.json handling 2026-05-04 22:29:57 +02:00
Luca bd0e052b1a docs(fonts): cache rollout, stale-list note, meta.json 2026-05-04 22:28:41 +02:00
Luca 5703fcb806 fix(fonts): drop immutable Cache-Control to allow font replacement rollout 2026-05-04 22:28:17 +02:00
Luca dcff451572 feat(branding): per-family generic fallback via meta.json 2026-05-04 22:27:52 +02:00
Paul Nothaft e382aed4d1 Merge pull request #394 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.39.1-beta.0
2026-05-04 22:18:29 +02:00
github-actions[bot] ac05230867 chore(beta): release 3.39.1-beta.0 2026-05-04 19:37:59 +00:00
Paul Nothaft c60ab74ae2 Merge pull request #393 from the-luap/docs/contributors
docs(readme): add Contributors section with @Luca-Timo and @Rekoo-PS
2026-05-04 21:37:37 +02:00
Paul Nothaft dbe0a3055b docs(readme): add Contributors section with @Luca-Timo and @Rekoo-PS
The Acknowledgments block had a generic "thanks to all contributors"
line but no actual recognition by name. Two people in particular have
moved the project meaningfully forward and should be called out:

- @Luca-Timo — code contributor across multi-arch Docker, the external-
  URL CMS toggle, folder tree picker, admin email picker, self-hosted
  webfonts, the gallery header/banner decoupling, and typed-API
  refactors. Consistent quality.

- @Rekoo-PS — bug reporter and feedback loop. Filed the issues that
  drove the login-loop fix, gallery loading skeleton, redirection
  cleanup, mobile lightbox overhaul, admin events search-counter fix,
  photo-count column, and bulk-delete workflow. Also a BuyMeACoffee
  supporter.

Closes the implicit recognition gap and sets up the section so future
contributors can be added with a one-line PR.
2026-05-04 21:35:27 +02:00
Paul Nothaft 99392c5888 Merge pull request #392 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.39.0-beta.0
2026-05-04 21:28:06 +02:00
github-actions[bot] 6fe1d8de9e chore(beta): release 3.39.0-beta.0 2026-05-04 19:16:41 +00:00
Paul Nothaft 1f1a856083 Merge pull request #385 from Luca-Timo/beta
feat(gallery): decouple header style from layout, add banner option
2026-05-04 21:16:13 +02:00
Paul Nothaft d0d283a13e Merge pull request #391 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.38.0-beta.0
2026-05-04 21:01:06 +02:00
github-actions[bot] 0a48646529 chore(beta): release 3.38.0-beta.0 2026-05-04 19:00:37 +00:00
Paul Nothaft 647aea21ae Merge pull request #389 from the-luap/feat/events-bulk-delete
feat(events): bulk delete with password confirmation (#384)
2026-05-04 21:00:06 +02:00
Paul Nothaft 7491eb21c4 Merge pull request #388 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.37.0-beta.0
2026-05-04 20:59:53 +02:00
Paul Nothaft 48d538f94f feat(events): bulk delete with password confirmation (#384)
Adds the bulk-delete half of #384 — admins can select multiple
events from the list and delete them in one batch, gated by
re-entering their password.

## Why password confirmation

Bulk delete is destructive and irreversible (cascades across 5 DB
tables and 3 filesystem paths per event). Re-entering the password
matches the pattern already used by /auth/admin/change-password and
makes accidental clicks much harder than a plain "type DELETE to
confirm" — the muscle-memory required to type your real password is
a stronger gate than typing a literal word.

## Changes

### Backend (adminEvents.js)

- Extracted the per-event cascade-delete logic into a module-private
  `deleteEventCascade(eventId, adminContext)` helper. The DELETE /:id
  route now calls it instead of inlining 60 lines of cascade — same
  behaviour, no drift between the per-event and bulk paths.
- New `POST /admin/events/bulk-delete`. Body: `{ eventIds, password }`.
  Permission: `events.delete`.
  - Validates `eventIds` array length (1–100) and that each id is an
    integer. The 100-cap keeps request time bounded; the per-event
    cascade touches DB + filesystem so 1000 events at once would risk
    timing out the request.
  - Verifies `password` against the calling admin's bcrypt hash via
    `bcrypt.compare()` (same as /auth/admin/change-password). Wrong
    password → 401 `{ error, code: 'INVALID_PASSWORD' }` and no
    events are touched.
  - Loops via `deleteEventCascade`, returns
    `{ results: { successful, failed } }` with the same shape as
    /bulk-archive so the frontend can show partial-failure feedback.
  - Logs `bulk_delete_completed` activity with totals.

### Frontend

- `events.service.ts`: `bulkDeleteEvents(eventIds, password)`.
- New `BulkDeleteModal.tsx`. Red/destructive variant of the
  bulk-archive modal:
  - Lists the events to be deleted (so the admin can verify).
  - Password input with show/hide toggle, autofocus, Enter-to-submit.
  - Inline `passwordError` prop surfaces the 401 INVALID_PASSWORD
    response without losing the modal state — admin can retry
    without re-typing the event list.
  - "Processing" state replaces the form with a spinner + "Deleting
    N events. This may take a few minutes — please don't close this
    window." (i18n) so admins know not to abandon the page during
    a slow operation.
- `EventsListPage.tsx`: "Delete Selected" button next to "Archive
  Selected" in the bulk-actions bar (red-styled to signal danger),
  bulkDeleteMutation that maps the 401 to the modal's inline error
  and any other failure to a generic toast.

### i18n

12 new keys under `events.bulkDelete.*` in all 5 locales
(en/de/nl/pt/ru): title, warning, password label/placeholder/help,
submit, processing, incorrectPassword, successAll, successPartial,
errorGeneric, plus `events.deleteSelected` for the button. Hand-
written for de; nl/pt/ru should get a native-speaker pass at some
point but read naturally.

### Verified

- `npx tsc --noEmit` clean
- `npx eslint` clean on every touched file (4 pre-existing errors in
  adminEvents.js for unused vars unrelated to this PR)
- All 5 locale JSON files parse cleanly
- `node -e "require('./src/routes/adminEvents')"` loads the module

Closes the bulk-delete half of #384. The Photos-column half lands
separately in PR #387.
2026-05-04 20:56:00 +02:00
github-actions[bot] 67999999d8 chore(beta): release 3.37.0-beta.0 2026-05-04 18:52:09 +00:00
Paul Nothaft d561db802b Merge pull request #387 from the-luap/feat/events-photos-column
feat(events): add Photos column to admin events list (#384)
2026-05-04 20:51:44 +02:00
Paul Nothaft ffb4318a1f feat(events): add Photos column to admin events list (#384)
The admin events table didn't surface how many photos each event
contained — admins had to click into the event to find out. The
backend already computes `photo_count` for every row in the
GET /admin/events list response (adminEvents.js:794-796), so this
is a frontend-only display change.

- Insert a "Photos" column between Date and Status — groups with
  the "what's in this event" info.
- Right-aligned, tabular-nums for clean numeric alignment in the
  column.
- Reuses the existing `events.photos` i18n key already shipped in
  all 5 locales for the EventDetailsPage tab list ("Fotos" / etc.) —
  no new translations needed.
- Updates the empty-state colSpan from 7 to 8.

Closes the column-add half of #384. The bulk-delete request from
the same issue lands separately.
2026-05-04 20:47:13 +02:00
Luca f410207b2d revert(branding): per-option font preview (defer to follow-up) 2026-05-04 20:39:00 +02:00
Luca e6c03e4b6e fix(nginx): proxy /fonts requests to backend 2026-05-04 20:07:13 +02:00
Luca b4f9b65f1d feat(branding): preview each font in its own face in the picker dropdown 2026-05-04 19:44:20 +02:00
Luca bac51fe69a feat(branding): self-hosted webfonts with filesystem scanner 2026-05-04 19:15:47 +02:00
Luca 894e151255 i18n(gallery): nl/ru wording polish for standard header style 2026-05-04 16:58:26 +02:00
Luca 05dadff493 fix(gallery): preserve sidebar controlsStyle on banner migration 2026-05-04 16:58:17 +02:00
Luca 91ad6bba99 style(theme-customizer): match header-style picker grid to layout picker (3 cols) 2026-05-04 15:48:12 +02:00
Luca 045e9ea486 fix(gallery): default controls to inline for every layout 2026-05-04 15:46:41 +02:00
Luca 24d727752c Merge pull request #1 from Luca-Timo/feat/decouple-gallery-header
feat(gallery): decouple header style from layout, add banner option
2026-05-04 15:30:52 +02:00
Paul Nothaft 0adec25fe7 Merge pull request #382 from Luca-Timo/refactor/external-media-types
refactor(events): type external-media list response, drop any casts
2026-05-04 14:46:35 +02:00
Luca d823dcda26 Merge branch 'beta' into refactor/external-media-types 2026-05-04 14:40:01 +02:00
Luca 0bd61be5a6 i18n(gallery): translate banner header strings (nl/pt/ru) 2026-05-04 14:34:55 +02:00
Luca aff29c91bb feat(gallery): decouple header style from layout, add banner option 2026-05-04 14:34:42 +02:00
Paul Nothaft 4d99eb672d Merge pull request #383 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.36.0-beta.0
2026-05-04 13:44:35 +02:00
github-actions[bot] 66281f9790 chore(beta): release 3.36.0-beta.0 2026-05-04 11:33:17 +00:00
Paul Nothaft 3fe8e61bd1 Merge pull request #379 from Luca-Timo/feat/admin-email-picker
feat(events): prefill admin email + admin picker on event creation
2026-05-04 13:32:53 +02:00
Paul Nothaft 06f2b75ec3 Merge pull request #381 from the-luap/chore/external-folder-picker-types
chore(events): type FolderTreeNode entries with ExternalEntry
2026-05-04 13:31:11 +02:00
Luca ffbb0e659f refactor(events): type external-media list response, drop any casts 2026-05-04 12:28:46 +02:00
Paul Nothaft 98c6f6cf06 chore(events): type FolderTreeNode entries with ExternalEntry
Follow-up to PR #378 — drops the (e: any) / (d: any) casts in the
external-folder-tree picker. ExternalEntry is already exported from
externalMedia.service.ts; the call site just wasn't using it.

- Import the type alongside the service.
- Annotate the dirs filter callback so `e.type` is the union 'dir' |
  'file' instead of any.
- Drop the (d: any) annotation from the map — TypeScript infers
  ExternalEntry from the typed `dirs` array.

No behaviour change, no test impact. `npx tsc --noEmit` clean,
`npx eslint` clean.
2026-05-04 12:24:21 +02:00
Paul Nothaft bfd7169fc4 Merge pull request #380 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.35.0-beta.0
2026-05-04 12:21:35 +02:00
github-actions[bot] 7e4f1acb88 chore(beta): release 3.35.0-beta.0 2026-05-04 10:21:13 +00:00
Paul Nothaft cdd40acb45 Merge pull request #378 from Luca-Timo/beta
feat(events): tree view for external media folder picker
2026-05-04 12:20:54 +02:00
Luca 40d23e24cb i18n(events): translate admin email picker strings (nl/pt/ru) 2026-05-04 12:10:23 +02:00
Luca ee56b6762f feat(events): prefill admin email + admin picker on event creation 2026-05-04 12:10:23 +02:00
Luca bd42ee1ce0 fix(events): match scrollbar to theme in external folder tree picker 2026-05-04 11:30:46 +02:00
Luca 954ab00495 i18n(events): translate external folder picker strings (nl/pt/ru) 2026-05-04 11:02:00 +02:00
Luca f927b09c70 feat(events): tree view for external media folder picker 2026-05-04 11:01:49 +02:00
Paul Nothaft 048870804f Merge pull request #377 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.34.2-beta.0
2026-05-04 09:15:29 +02:00
github-actions[bot] 13e3a05386 chore(beta): release 3.34.2-beta.0 2026-05-04 07:14:40 +00:00
Paul Nothaft 3ab8a64a24 Merge pull request #376 from the-luap/fix/ffmpeg-on-alpine
fix(docker): install system ffmpeg on Alpine, drop broken bundled binary
2026-05-04 09:14:16 +02:00
Paul Nothaft 96818c7ae8 fix(docker): install system ffmpeg on Alpine, drop broken bundled binary
Video uploads on production fail with "missing ffmpeg" because the
backend container ships nothing usable for the video pipeline.

Two compounding causes:

1. **Alpine + glibc mismatch.** The npm `@ffmpeg-installer/ffmpeg`
   dependency added with the video-support PR (commit 68a9dc5)
   ships per-platform binaries via optionalDependencies. The Linux
   binaries are built against glibc, but the backend image runs on
   `node:22-alpine` (musl libc) — known to either fail to execute
   or fail on shared-library lookups on Alpine.

2. **`ffprobe` missing entirely.** `@ffmpeg-installer/ffmpeg`
   bundles only the `ffmpeg` binary. There's a separate
   `@ffprobe-installer/ffprobe` package that the codebase never
   depended on. But `videoProcessor.js:21` calls
   `ffmpeg.ffprobe(videoPath, …)` — the very first step of the
   video pipeline shells out to a `ffprobe` binary that doesn't
   exist in the image. Even if (1) worked, every video upload
   would 500 here.

The fix is to install Alpine's `ffmpeg` package via apk. It ships
both `ffmpeg` and `ffprobe` built natively against musl, ~70MB
extra image size, single line in the Dockerfile, no per-arch
handling needed (apk pulls the right binary for both linux/amd64
and linux/arm64 — works with the multi-arch infra from #349).

- `backend/Dockerfile`: add `ffmpeg` to the apk install line.
- `backend/Dockerfile.dev`: same for dev parity.
- `backend/src/services/videoProcessor.js`: remove the
  `setFfmpegPath(require('@ffmpeg-installer/ffmpeg').path)` line
  — without removing it, fluent-ffmpeg would prefer the broken
  bundled binary over the working apk one. Letting fluent-ffmpeg
  fall back to PATH lookup picks up the apk binary in the
  container and the developer's locally-installed binary on dev
  hosts (Homebrew on macOS, apt on Debian).
- `backend/package.json`: drop the now-unused
  `@ffmpeg-installer/ffmpeg` dependency. `npm install` removes
  2 packages from the lockfile.

Verified: `videoProcessor.js` still loads cleanly (`node -e
"require('./src/services/videoProcessor')"`); lint clean.
2026-05-04 09:04:38 +02:00
Paul Nothaft 8f2639bdca Merge pull request #375 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.34.1-beta.0
2026-05-04 00:19:37 +02:00
github-actions[bot] eb6791792e chore(beta): release 3.34.1-beta.0 2026-05-03 22:18:02 +00:00
Paul Nothaft 08d046276b Merge pull request #374 from the-luap/fix/cms-external-url-i18n-and-api-shape
fix(cms): nl/pt/ru i18n + gate external_url in public response
2026-05-04 00:17:40 +02:00
Paul Nothaft bce5c1f725 fix(cms): nl/pt/ru i18n + gate external_url in public response
Two follow-ups to PR #372 (external-URL toggle for imprint /
privacy CMS pages):

1. **i18n.** PR #372 added 6 new `cms.*` keys to the en + de
   locales but the project ships 5 locales total. Adds the missing
   nl / pt / ru translations so the admin CMS page renders in the
   active language for those users instead of falling back to
   English literals next to the German/Dutch/Portuguese/Russian
   surrounding strings.

2. **API shape.** `publicCMS.js` returned `external_url`
   unconditionally — even when `use_external_url` is false the URL
   value was still emitted in the public response. The frontend
   correctly gated on both flags so it worked, but the API surface
   was leaking a value the admin had explicitly disabled. The
   value still lives in the DB (so the toggle can be flipped back
   on without losing it), but the public endpoint now returns
   `null` whenever the toggle is off.

   Note: kept the existing `logo_url` shape unchanged. Its semantics
   are different — null means "fall back to global branding" and
   consumers rely on always having the field, so emitting it
   unconditionally is intentional there.

No frontend change needed: both `GalleryLayout` and `LegalPage`
already gate on `use_external_url && external_url`, so the
short-circuit handles `external_url: null` correctly.
2026-05-04 00:14:07 +02:00
Paul Nothaft ccd3fd9349 Merge pull request #373 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.34.0-beta.0
2026-05-04 00:11:41 +02:00
github-actions[bot] cf04c0b2dc chore(beta): release 3.34.0-beta.0 2026-05-03 22:11:01 +00:00
Paul Nothaft b2c8161a43 Merge pull request #372 from Luca-Timo/beta
feat(cms): add external URL toggle for imprint and privacy pages
2026-05-04 00:10:44 +02:00
Paul Nothaft aade003564 Merge pull request #371 from the-luap/i18n/password-reset-modal
i18n(events): translate PasswordResetModal across 5 locales
2026-05-03 23:29:09 +02:00
Paul Nothaft c270bcfc9f i18n(events): translate PasswordResetModal across 5 locales
The rebuilt modal in this PR shipped with hard-coded English strings.
That made the reset flow untranslated for German/Dutch/Portuguese/
Russian customers — toasts, confirm dialog, success screen all
fell back to English regardless of the active locale.

- New `events.passwordReset.*` namespace in en/de/nl/pt/ru with 22
  keys covering both modal screens, the warning banner, validation
  errors, and the toast messages.
- Modal uses `useTranslation()` for every previously hard-coded
  string. Reuses `common.cancel`, `events.copy`, `events.copied`
  where they already exist across all locales.
- The {{eventName}} interpolation uses i18next's standard variable
  syntax so the description line reads naturally in each language.

No behaviour change. TypeScript clean (`npx tsc --noEmit`), ESLint
clean. JSON validity checked for all 5 locale files.
2026-05-03 23:16:21 +02:00
Luca c5bba505ac feat(cms): redirect legal links to external URL when configured 2026-05-03 23:06:16 +02:00
Luca a4e3d10fb0 feat(cms): admin UI for external imprint/privacy URL 2026-05-03 23:05:51 +02:00
Luca 66423bb65e feat(cms): add per-page external URL override — backend 2026-05-03 23:05:18 +02:00
Paul Nothaft c0ca5ed687 Merge pull request #370 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.33.2-beta.0
2026-05-03 22:49:02 +02:00
github-actions[bot] d552f45b20 chore(beta): release 3.33.2-beta.0 2026-05-03 20:48:36 +00:00
Paul Nothaft 0d1f82d31a Merge pull request #369 from the-luap/fix/admin-set-password-and-full-url-emails
fix(events): admin-set password on reset, full-URL gallery_link in all emails
2026-05-03 22:48:17 +02:00
Paul Nothaft ff50c74e19 fix(events): admin-set password on reset, full-URL gallery_link in all emails
Two related defects on the same gallery-email surface that PR #367
opened, addressed together:

1. Reset-password endpoint was a one-way auto-generate.
   `POST /admin/events/:id/reset-password` always called
   `generateReadablePassword()` and ignored any client-supplied value;
   the modal only offered a confirm + a forced auto-generated result.
   Admins who wanted to set a memorable customer-supplied password
   had no way to do it.

   Backend: route now reads optional `password` from the body. If
   present, validates with `validatePasswordInContext('gallery', …)`
   (same rules as create-event) and uses it; if absent, falls back to
   the existing generator, so old callers / cron stay functional.
   Switched the bcrypt rounds from a hard-coded `10` to
   `getBcryptRounds()` to match the create flow.

   Frontend: rebuilt `PasswordResetModal.tsx`. Typed input with
   show/hide, confirm-password field that appears on type, the same
   `<PasswordGenerator>` used by `CreateEventPage` (event-context-
   aware, fills both fields when used), send-email checkbox,
   client-side validation, server-side validation feedback inline.
   Submit empty → server auto-generates and the success screen shows
   the value with a copy button (legacy one-click flow preserved);
   submit with a typed password → success toast + close (no need to
   re-show what the admin already typed).

   Service layer: `events.service.resetPassword(id, sendEmail,
   password?)` only sends `password` in the body when set.

   Caller: `EventDetailsPage` now passes `eventDate` + `eventType`
   into the modal so the generator has event context.

2. `gallery_link` was the path-only `event.share_link` in three
   email-queue sites, so customer mail showed
   `/gallery/<slug>/<token>` instead of the full
   `https://example.com/gallery/<slug>/<token>` URL.

   - `adminEvents.js` reset-password queue (#1437)
   - `adminEvents.js` resend-creation-email queue (#1502)
   - `expirationChecker.js` expiration_warning queue (#82)

   All three now derive `shareUrl` from `buildShareLinkVariants`
   (the same helper already used by create-event, publish-from-
   draft, and event-rename). The other 4 callers
   (`adminEvents.js:651/913`, `events.js:187`,
   `eventRenameService.js:231`) already used the full URL — this
   closes the gap.

Verified: TypeScript clean (`npx tsc --noEmit`), ESLint clean on
every touched file (the 4 lint errors that remain in
`adminEvents.js` are pre-existing and predate this branch).
2026-05-03 22:44:22 +02:00
Paul Nothaft d4d8833d82 Merge pull request #368 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.33.1-beta.0
2026-05-03 22:12:19 +02:00
github-actions[bot] 65d8032960 chore(beta): release 3.33.1-beta.0 2026-05-03 20:10:16 +00:00
Paul Nothaft 07672038d4 Merge pull request #367 from the-luap/fix/email-template-rendering-and-caller-data
fix(email): render conditionals, localise password placeholders, fix caller/template variable drift
2026-05-03 22:09:58 +02:00
Paul Nothaft e8052adf1d fix(email): render conditionals, localise password placeholders, fix caller/template variable drift
Bundle of email-renderer and email-caller fixes triggered by a
reproducer on picpeak.nothaft.cloud (gallery_created mail showing
literal `{{#if welcome_message}}` markers and `Passwort: (set at
creation)`). The audit that followed surfaced six more user-visible
defects in the same surface; all are fixed here so customer-facing
mail renders cleanly.

Renderer (`backend/src/services/emailProcessor.js`)

- `safeTemplateReplace` now resolves `{{#if VAR}}…{{/if}}` blocks
  before flat `{{var}}` substitution. The shipped templates have used
  Handlebars-style conditionals since migration 026; the renderer
  ignored them, so the markers leaked verbatim into every mail with
  an empty welcome_message. Lifted to module scope and exported so
  the conditional contract is unit-testable. Single-pass, non-nested
  (commented).

- Added `passwordSetAtCreationI18n` next to the existing two i18n
  password sentinels so `(set at creation)` (sent by the publish-
  from-draft flow when only the bcrypt hash remains) is localised
  to "Das bei der Erstellung der Galerie gesetzte Passwort" /
  equivalent in EN/DE/NL/PT/RU instead of the raw English string.

- Added an opt-in `{ escapeHtml: true }` mode to `safeTemplateReplace`
  so admin-supplied free text (`event_name`, `host_name`, …) is
  HTML-escaped on substitution into the HTML body. Allowlist of
  passthrough keys (`welcome_message` already-HTML, server-generated
  URLs `gallery_link` / `client_link`). Subject and text body keep
  the legacy unescaped behaviour. `formatWelcomeMessage` now escapes
  before nl2br so the welcome_message allowlist is safe.

- New `htmlToText()` strips `<style>` and `<script>` blocks (and
  their content) before tag-stripping, decodes common entities, and
  collapses whitespace. Used by the textBody fallback in
  `sendTemplateEmail` — without this, every template missing a
  `body_text` produced a "plain-text" mail starting with the 100+
  lines of CSS embedded by `wrapEmailHtml()`.

- The client-access section (#172) now mirrors its HTML block into
  `textBody` using the same per-language strings, so plain-text
  recipients see the link / PIN / warning. `pinLabel = 'PIN'` moved
  into `clientAccessI18n` (RU uses ПИН-код).

- Added `getSupportEmail()` exported helper that reads
  `branding_support_email` from `app_settings` (JSON-decoded), with
  the SMTP from-address as fallback. Used by the gallery_expired and
  archive_complete callers below.

- Removed dead `require('handlebars')` (unused since the regex
  renderer landed; pre-existing lint error in this file).

Callers (data the templates already reference)

- `expirationChecker.js queueExpirationWarning`: send `expiry_date`
  (templates use this, the old code sent `expiration_date` —
  typo'd key, never read), drop the hard-coded `.de`/`en` sniff
  (the processor formats with the recipient's resolved language),
  add the `{{password_security_message}}` sentinel for
  `gallery_password` (plaintext is gone by warning time, so
  customers used to see literal `{{gallery_password}}` in the mail).

- `expirationChecker.js handleExpiredEvent`: both queueEmail calls
  now supply `host_name`, `event_date`, `expiry_date`,
  `support_email` so the EN/DE/NL/PT/RU `gallery_expired` template
  doesn't render literal `{{host_name}}, your gallery expired on
  {{expiry_date}}`. Skip the duplicate admin send when
  admin_email == customer_email.

- `archiveService.js`: `archive_complete` queue now supplies
  `host_name`, `photo_count` (from `photoEntries.length`),
  `archive_date`, `support_email` — the previous payload had only
  `event_name` and `archive_size`, so most of the mail was
  unfilled placeholders.

Tests

- `__tests__/services/emailProcessor.safeTemplateReplace.test.js`:
  16 cases — flat substitution, conditional truthy/falsy/missing/
  multi-line/sibling/numeric-0, plus 5 cases for the new
  `escapeHtml` option (default off, escape on, allowlist
  passthrough for welcome_message and gallery_link).

- `__tests__/services/emailProcessor.htmlToText.test.js`: 7 cases —
  the regression scenario (full wrapped body with embedded `<style>`
  block), tag-stripping, entity decoding, paragraph spacing.

- `__tests__/utils/formatters.test.js`: 12 cases for `escapeHtml`,
  `nl2br`, and the now-escaping `formatWelcomeMessage`.

35 cases total, all green. Lint clean on every touched file
(also fixes a pre-existing `no-prototype-builtins` warning in the
process). Pre-existing failures in
`__tests__/services/backupService.enhanced.test.js` are unrelated
and pre-date this branch.
2026-05-03 22:06:06 +02:00
Paul Nothaft 297960698a Merge pull request #365 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.33.0-beta.0
2026-05-02 23:04:13 +02:00
github-actions[bot] 4207207c5e chore(beta): release 3.33.0-beta.0 2026-05-02 21:02:15 +00:00
Paul Nothaft df3061893d Merge pull request #349 from Luca-Timo/feat/apple-silicon-support
feat: native multi-arch Docker images (Apple Silicon, ARM64 Linux)
2026-05-02 23:01:54 +02:00
Paul Nothaft 4765804645 Merge pull request #364 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.5-beta.0
2026-05-02 22:59:17 +02:00
Paul Nothaft 907bcf1eb2 Merge pull request #363 from the-luap/feat/upload-redesign-and-auth-loop-fix
feat(upload): async photo processing + fix(auth): /auth/session symmetry (loop fix)
2026-05-02 22:59:03 +02:00
github-actions[bot] 214d8a1899 chore(beta): release 3.32.5-beta.0 2026-05-02 20:59:02 +00:00
Paul Nothaft f529c9e3d7 Merge pull request #362 from the-luap/fix/issue-358-theme-aware-skeleton
fix(theme): kill initial white frame + theme-aware skeleton tiles (#358 follow-up)
2026-05-02 22:58:43 +02:00
Paul Nothaft b30010eb6f test(upload): unit tests for backgroundProcessor + processPhoto
Cover the two new pieces of the async pipeline:

backgroundProcessor.claimNextPhoto
  - returns null when no pending rows
  - returns the row + flips status under postgres FOR UPDATE SKIP LOCKED
  - returns null when SQLite UPDATE-with-guard loses the race
  - returns the row when the SQLite guard wins

photoProcessor.processPhoto
  - happy path: writes thumbnail / dimensions / EXIF capture date and
    marks 'complete'; fires watermark queue + photo.uploaded webhook
    with the right payload
  - video path: writes ffmpeg duration / codec / dimensions; does NOT
    queue watermark (image-only)
  - throws cleanly when the photo row no longer exists

Mocks db / imageProcessor / videoProcessor / storage / sharp /
watermarkGeneratorService / webhookService / logger so the tests run
without a real DB or any image library calls — fast and deterministic.
2026-05-02 22:56:04 +02:00
Paul Nothaft 3b827b80d5 feat(upload): async photo processing — frontend (PR-B part 2)
Live processing-state UI that complements the backend async pipeline.
Modal stays open through the processing phase and surfaces real
progress (X of N photos processed); the admin grid renders placeholder
cards for in-flight photos and auto-refreshes via polling until the
queue drains.

services/uploads.service.ts (new)
  - getStatus(uploadId)        — JSON snapshot from /admin/uploads/:id/status
  - retryPhoto(photoId)        — POST /admin/photos/:id/retry
  - streamUrl(uploadId)        — SSE upgrade URL

hooks/useUploadProgress.ts (new)
  - Tracks N concurrent upload IDs (one per chunk POST) and merges
    counters into a single aggregate.
  - Always polls every 1.5s; opportunistic SSE upgrade on top of that.
    SSE failure (proxy buffering, etc.) silently downgrades to polling
    only — no reconnect storms.
  - Auto-stops both channels when every tracked group is in a terminal
    (complete/failed) state.

components/admin/PhotoUpload.tsx
  - Captures upload_id from each chunk's 202 response, feeds them into
    useUploadProgress.
  - Phase machine extended: stays in 'processing' until the worker
    drains the queue (not just until bytes-on-wire). Progress UI shows
    real "X of N done" with a determinate bar fed by the aggregate.
  - "You can leave this page" hint kept — closing the modal is now
    actually safe, work continues server-side.
  - Side-effect refactor: invokes onUploadComplete twice — once early
    so the user sees photos appearing immediately, once on terminal
    so the parent grid sees final state.

components/admin/AdminPhotoGrid.tsx
  - Photos with processing_status pending/processing render an amber
    placeholder card with a spinning Cog instead of the missing
    thumbnail.
  - Photos with status='failed' render a red card with the error message
    and a "Retry" button that POSTs /admin/photos/:id/retry.

pages/admin/EventDetailsPage.tsx
  - Photo list query gains refetchInterval that polls every 2s while
    any photo is non-terminal, then stops. Keeps the grid auto-fresh
    during ongoing processing.
2026-05-02 22:56:04 +02:00
Paul Nothaft 851744c3c4 feat(upload): async photo processing — backend (PR-B part 1)
Move thumbnail / EXIF / dimensions / watermark / webhook work off the
upload request thread and into a background worker pool. Upload
requests now return 202 in seconds even on NFS-backed storage; the
worker(s) drain the pending queue independently and update each
photo's processing_status to 'complete' or 'failed' on its own.

Schema (migration 085_async_photo_processing.js):
  - photos.processing_status     enum default 'complete' (existing
                                 rows are already done)
  - photos.processing_error      populated on 'failed'
  - photos.processing_started_at timestamp for janitor recovery
  - photos.upload_id             groups all photos from one upload
                                 request so the frontend can poll
                                 status by group
  - indexes on processing_status and upload_id for queue lookups

services/photoProcessor.js
  - queueFilesForProcessing(files, options) — shared helper used by
    the admin and gallery upload routes. Moves files to final storage
    + inserts pending rows; returns { uploadId, photos, errors }.
  - processPhoto(photoId) — worker-mode: reads original from storage
    via withLocalCopy (transparent local/S3), generates thumbnail and
    EXIF/dimensions or video metadata, queues watermark, fires
    photo.uploaded webhook, marks 'complete'. Throws => caller marks
    'failed' with the error message.
  - processUploadedPhotos kept untouched — chunkedUploadService still
    uses the synchronous path.

services/backgroundProcessor.js (new)
  - N independent worker loops per backend instance (default 2,
    UPLOAD_PROCESSOR_CONCURRENCY env override).
  - Multi-pod safe: postgres SELECT FOR UPDATE SKIP LOCKED, sqlite
    UPDATE-with-status-guard. Pods race on rows, exactly one wins.
  - Janitor every minute resets photos stuck in 'processing' for >10
    minutes (worker died, pod restarted) back to 'pending'.
  - UPLOAD_PROCESSOR_DISABLED=true opt-out for CI/test.
  - Started from server.js after the other long-running workers.

routes/adminPhotos.js — POST /:eventId/upload
  - Replaced batch-of-25 sync processing loop with per-file
    move-to-storage + insert-pending. Response is now 202 with
    upload_id, count, photo_ids in addition to the legacy
    successCount / replacedCount fields the existing frontend reads.
  - Per-request temp directory cleanup is now a single idempotent
    handler on res.finish/res.close (was three inline blocks for
    error paths only, leaking dirs on success — original bug from
    contributor analysis).
  - GET /uploads/:upload_id/status — JSON snapshot of pending /
    processing / complete / failed counts plus per-photo state.
  - GET /uploads/:upload_id/stream — SSE upgrade. Polls internally
    every 1.5s, emits on snapshot change, ends when all photos
    reach a terminal state.
  - POST /photos/:photoId/retry — flips a 'failed' photo back to
    'pending' so the worker picks it up again.
  - GET /:eventId/thumbnail/:photoId now returns 503 with Retry-After
    while the photo is still pending/processing, and 422 on 'failed'.
    The admin grid renders placeholders accordingly.

routes/gallery.js — POST /:eventId/upload (guest)
  - Refactored to use queueFilesForProcessing instead of the synchronous
    processUploadedPhotos. Same 202 + upload_id shape.
  - GET /:slug/photos now filters processing_status to 'complete' (or
    NULL for pre-migration rows) so guests never see in-flight photos.

Side-effect timing change:
  - photo.uploaded webhook now fires from the worker after the photo
    is actually processed (thumbnail + dimensions populated) instead
    of from inside the upload request. Same payload fields. Worth a
    one-line note in the changelog.
2026-05-02 22:56:04 +02:00
Paul Nothaft 86dfcc4f11 feat(upload): two-state UI + temp dir cleanup (PR-A of async processing)
Phase 1 of the upload-progress redesign. Two changes that ship UX wins
without any architectural surgery — they're a stepping stone for the
full async-processing rework that follows in subsequent commits.

1. Two-state progress bar (PhotoUpload.tsx, UserPhotoUpload.tsx)

   When axios.onUploadProgress reports loaded === total, the request is
   on the server and the bytes have left the browser. Today the bar sits
   at 100% for the chunk while the backend runs sharp/ffmpeg/EXIF (often
   minutes on NFS-backed storage) and users assume the upload froze.

   The component now distinguishes two phases:
   - 'transferring' — bytes-on-wire, determinate progress bar.
   - 'processing'   — bytes done, waiting for response. Indeterminate
                      spinner + an explanatory hint that the backend is
                      generating thumbnails / reading metadata and the
                      user can leave the page.

   Same pattern in UserPhotoUpload (gallery): the per-file checkmark
   icon is replaced by a Loader2 spinner while the request is in flight
   after bytes-on-wire finished.

2. Temp directory cleanup (adminPhotos.js)

   Multer creates temp/upload_<ts>_<rand>/ per request. Files inside it
   are individually unlinked after they're moved to storage on the
   success path, but the empty directory was never removed. On error
   paths three different inline blocks each tried to clean up; the
   success path was missed entirely. Result: the orphan-empty-dirs
   accumulation reported in the issue (70+ on the affected instance).

   Replace the inline cleanup blocks with a single idempotent
   cleanupTempDir() registered on res.finish + res.close, so it fires
   exactly once on every exit path (validation 4xx, server 5xx, multer
   error, success).

New translation keys (en/de): upload.transferring, upload.processing,
upload.processingHint, upload.processingProgress, upload.processingFailed,
upload.retryFailed.
2026-05-02 22:56:04 +02:00
Paul Nothaft f905f7e733 fix(auth): /auth/session must reject tokens that adminAuth/galleryAuth would reject
Second loop fix in the same /admin/login → /admin/dashboard → /admin/login
pattern as #355. The frontend trusts /auth/session as the source of
truth for "is the user authenticated?". When that endpoint is more
lenient than the protected middleware, every admin endpoint 401s
right after /auth/session said valid:true, the response interceptor
hard-redirects to /admin/login, /auth/session says valid again, and
the cycle closes — exactly the loop reported on v3.32.4-beta.0.

#355 fixed the issuer-claim asymmetry. This commit fixes the
remaining asymmetries: /auth/session was missing the admin-existence,
admin-active, password-change-after-iat, and gallery-existence /
gallery-archived / gallery-expired checks that adminAuth and
galleryAuth perform on every protected request.

The fix is to mirror those checks in /auth/session, scoped by token
type, and degrade gracefully when the underlying tables aren't
present (test fixtures, early bootstrap) so the endpoint never
fails-closed because of a missing table.

Reproducer that the new test covers:
  1. Admin logs in (token issued at T).
  2. Admin (or another admin) changes their own password at T+1.
  3. Browser still has the cookie from T.
  4. /auth/session says valid:true (no password-change check).
  5. /admin/dashboard fires queries; adminAuth rejects with
     PASSWORD_CHANGED 401.
  6. Frontend redirects to /admin/login.
  7. /auth/session says valid:true again. → loop.

Other surfaces this also covers:
  - admin user deactivated (admin_users.is_active = false)
  - admin user deleted
  - gallery token whose event is archived
  - gallery token whose event has expired

Tests live in __tests__/routes/authSession.symmetry.test.js — 9 cases,
mocking db / tokenRevocation / tokenUtils / recaptcha / sessionTimeout
so the suite runs without a real database.
2026-05-02 22:56:04 +02:00
Paul Nothaft 7b2f75e6ae chore: bump @playwright/test to ^1.57.0; drop unused root dotenv
The root devDependencies still pinned an older Playwright (1.48.2)
plus a stray `dotenv` that nothing in the e2e suite or root scripts
actually requires (verified via grep across tests/). Updates the
Playwright version to match the current upstream stable and removes
the unused dotenv to keep the root install lean.

Originated from a local stash that picked up these changes; landing
them as a small dedicated commit so they don't blend into the auth
fix that follows.
2026-05-02 22:56:04 +02:00
Paul Nothaft 1a530aeaa2 fix(theme): kill initial white frame + theme-aware skeleton tiles (#358 follow-up)
Two further fixes for the gallery loading sequence shown in
@Rekoo-PS's frame breakdown on issue #358 — both about colours that
didn't track the active theme.

1. Initial white frame (frame f1)

   The pre-React bootstrap script in #359 sets the cached background
   on documentElement, but the browser may paint the very first frame
   *before* that <script> tag runs (synchronous parse-time JS in the
   <head> is still slightly later than CSS apply-time). On first-visit
   dark-OS devices that meant a single white frame before the script
   resolved.

   Fix: move the OS-preference default into a <style> block that
   precedes the script. CSS @media (prefers-color-scheme) is applied
   before paint, so dark-OS devices land on dark from frame zero.
   The script keeps the per-gallery cache hit on top, and now also
   stamps the colour onto document.body in case the body element has
   already mounted by the time the script runs.

2. "Most annoying" skeleton tile frame (frame f4)

   Skeleton placeholders rendered as bright `bg-neutral-200` light grey
   regardless of theme. On a dark gallery that's the highest-contrast
   thing on screen during loading — the exact frame Rekoo-PS labelled
   "the most annoying" in the issue.

   Fix: the Skeleton component's background now reads
   `var(--color-surface-border)`, which ThemeContext already wires up
   per active theme (`#e5e5e5` light / `#2e2e2e` dark by default; per-
   event themes can override). The bare `<div>` no longer carries any
   colour utility class — the inline style supplies the active value.
   Also dropped the leftover `bg-white` on SkeletonCard / SkeletonTable
   in favour of `var(--color-surface)` for the same reason.

Tests:
   New src/components/common/__tests__/Skeleton.test.tsx covers
   - Skeleton uses var(--color-surface-border, ...)
   - bg-neutral-200 is no longer present
   - SkeletonGalleryGrid tiles all inherit the theme colour
   - SkeletonCard surface uses var(--color-surface)
2026-05-02 22:52:09 +02:00
Luca c7ac9ddfe5 refactor: rename mac override to amd64 override for arch accuracy 2026-05-02 01:34:46 +02:00
Luca 3440ecc999 ci: lowercase image names for GHCR compatibility on forks 2026-05-02 01:26:47 +02:00
Luca ede5193e58 Update docker-build.yml 2026-05-02 01:26:47 +02:00
Luca ec2eaf76ea ci: build multi-arch images on every channel via native arm64 runners 2026-05-02 01:26:47 +02:00
Luca c282a72bd3 feat: support Apple Silicon natively via multi-arch images 2026-05-02 01:26:47 +02:00
Paul Nothaft ac040fbef8 Merge pull request #361 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.4-beta.0
2026-05-02 00:11:05 +02:00
github-actions[bot] 1fae9b6099 chore(beta): release 3.32.4-beta.0 2026-05-01 22:07:34 +00:00
Paul Nothaft af2b0628cb Merge pull request #360 from the-luap/fix/issue-hero-logo-position
fix(events): stop mapping branding_logo_position onto hero_logo_position
2026-05-02 00:07:24 +02:00
Paul Nothaft 07b41e691d Merge pull request #359 from the-luap/fix/issue-358-theme-flash
fix(theme): pre-React bootstrap to kill white-flash on dark galleries (#358)
2026-05-02 00:07:04 +02:00
Paul Nothaft ef1c875f6e fix(events): stop mapping branding_logo_position onto hero_logo_position
Two settings with overlapping names but different value sets were being
conflated:

- branding_logo_position (header bar, horizontal): 'left'|'center'|'right'
- hero_logo_position    (hero block, vertical):   'top'|'center'|'bottom'

getBrandingDefaults() copied the global branding value over the per-event
hero value when seeding new events. Any admin with branding logo set to
'left' (the most common choice) created events with hero_logo_position
= 'left' written to the DB. Subsequent PUTs to /admin/events/:id then
failed validation with "Invalid value (field: hero_logo_position)" — the
validator only accepts top/center/bottom.

Fix:

1. Drop the bogus mapping. branding_logo_position is no longer read by
   getBrandingDefaults — it doesn't belong there. The fallback default
   ('top') is used unless the request body explicitly provides
   hero_logo_position, which is independently validated.

2. Migration 084_fix_hero_logo_position normalises any existing rows
   whose hero_logo_position is outside ('top','center','bottom') back
   to 'top'. Without this, affected events would continue to 400 on
   every save until the admin manually picks a valid option.

Reproduction: admin sets branding logo position to 'left' under global
branding, creates an event, opens the event detail page, clicks Save
without changing anything → 400. After this fix, save succeeds and new
events default to 'top' regardless of branding-bar position.
2026-05-02 00:04:06 +02:00
Paul Nothaft f81a8728e6 fix(theme): pre-React bootstrap to kill white-flash on dark galleries (#358)
Opening a gallery with a dark theme briefly painted a white background
between the initial HTML render and React applying the per-event theme.
The HTML shipped with no theme info, so the first paint used the
default (#fafafa) before /gallery/:slug/info resolved.

Two-part fix.

1. Inline bootstrap script in index.html runs synchronously before React
   mounts. Reads the URL, looks up a per-slug background colour from
   localStorage (gallery-theme-bg-<slug>), and applies it to
   documentElement immediately. Falls back to #171717 when no cache
   exists and the OS prefers dark, so first visits with dark OS still
   land on a dark background.

2. ThemeContext.applyTheme writes the resolved background to
   localStorage keyed by slug whenever a gallery theme loads. Revisits
   then hit the bootstrap cache and never see a flash.

Added a 200ms transition on html.background-color so the rare
cache→API drift (e.g. theme palette changed admin-side since last
visit) is a smooth fade instead of a snap.

Limitation: first visit on a light-OS device to a dark gallery still
flashes once. Killing that case requires a server-rendered theme hint,
out of scope for an SPA bootstrap fix.

The empty-skeleton-grid part of the same report is already addressed
by the 300ms lazy render in #352 — Rekoo-PS just needs to update from
v3.32.1-beta.0 to v3.32.2-beta.0+.
2026-05-01 23:53:55 +02:00
Paul Nothaft 9597333ddc Merge pull request #357 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.3-beta.0
2026-05-01 23:44:57 +02:00
github-actions[bot] 03dd99a8b1 chore(beta): release 3.32.3-beta.0 2026-05-01 21:29:08 +00:00
Paul Nothaft e5712d8ffe Merge pull request #356 from the-luap/fix/issue-create-event-expiry-date-coercion
fix(events): coerce expires_in_days to Number before addDays
2026-05-01 23:28:40 +02:00
Paul Nothaft 83dedbcd45 Merge pull request #355 from the-luap/fix/issue-350-jwt-verify-symmetry
fix(auth): /auth/session must verify issuer claim like adminAuth (#350)
2026-05-01 23:28:26 +02:00
Paul Nothaft db29d0e278 fix(events): coerce expires_in_days to Number before addDays
The "Expires on" preview under the days-after-event input rendered
nonsense dates (e.g. 25.04.2026 + 120 days → 08.01.2095, ~68 years
out). Cause: handleInputChange stores e.target.value verbatim, which is
a string for <input type="number">, so formData.expires_in_days is "120"
not 120. date-fns addDays does:

  _date.setDate(_date.getDate() + amount)

When amount is a string, the + is string concatenation:
25 + "120" = "25120". setDate("25120") then sets day-of-month to 25120,
which carries over by ~68 years.

Fix: cast to Number at the call site. The validation/API-payload
codepaths already work because the comparisons at line 330 and the
JSON payload coerce numerically through different paths — only addDays
was actually broken.

The TypeScript type FormData.expires_in_days: number is a lie because
handleInputChange's [field]: e.target.value sets a string regardless.
Tightening that handler is a separate cleanup; this commit only fixes
the visible date bug.
2026-05-01 23:22:56 +02:00
Paul Nothaft 88a6c6a7fb fix(auth): make /auth/session verify the issuer claim like adminAuth (#350)
Asymmetric JWT verification was causing a /admin/login → /admin/dashboard
→ /admin/login redirect loop for users carrying admin cookies issued
before the iss: 'picpeak-auth' claim was added (commit 23cd9cb,
"address Shannon security assessment findings (37 vulnerabilities)").

The frontend uses GET /auth/session as the source of truth for "is the
user authenticated?". That endpoint called jwt.verify(token, JWT_SECRET)
with no issuer option, so it accepted pre-issuer tokens and reported
valid: true. AdminLoginPage then redirected to /admin/dashboard, every
protected endpoint went through adminAuth which DOES verify the issuer,
each one rejected the token with 401, the response interceptor
window.location.href'd back to /admin/login, and the loop closed.

Fix: pass { issuer: 'picpeak-auth' } to /auth/session's jwt.verify so it
matches adminAuth and galleryAuth. Tokens without the claim now correctly
return valid: false from the session check, AdminLoginPage shows the
login form, and a fresh login mints a properly-issued cookie.

The other intentionally-lax verify call sites (logout-flow logging,
photoAuth, sessionTimeout, rateLimit) are unrelated to the loop and stay
lax — their callers don't gate "authenticated?" decisions on the result.

Reproducer: open a removed/archived gallery URL with a stale admin
cookie from before the issuer claim was added, click "Back to home" on
the gallery-not-found page → loop.
2026-05-01 23:12:20 +02:00
Paul Nothaft 8f7258bfc8 Merge pull request #353 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.2-beta.0
2026-05-01 22:48:31 +02:00
github-actions[bot] 34fdddef51 chore(beta): release 3.32.2-beta.0 2026-05-01 20:37:32 +00:00
Paul Nothaft 6229b38bac Merge pull request #352 from the-luap/fix/issue-321-346-348-and-discussions
fix: events search/counters (#346), lazy gallery skeleton (#321), smooth lightbox swipe (#348)
2026-05-01 22:37:16 +02:00
Paul Nothaft 743086d3cb fix(lightbox): smooth carousel swipe + drop instructional hint (#348)
Two fixes for discussion #348.

Carousel-style swipe
The lightbox previously snapped to the next photo on swipe, then showed
a loading spinner while the new image fetched — choppy compared with
the reference video the reporter shared. The current photo is now
rendered inside a 3-slide track (prev/current/next). As the finger
drags, the track follows; on release the track animates to the
neighbouring slot or springs back if the gesture didn't pass the
threshold. Because the prev/next AuthenticatedImages render up front,
the browser starts fetching them while the user is still on the
current photo, so there's no loader flash on commit.

- Phase machine ('idle' | 'dragging' | 'committing' | 'springing')
  drives the track's transform/transition. Commit + spring use a 280ms
  cubic-bezier ease.
- Percentage-based transforms avoid measuring container width before
  the first paint. Commit threshold (read from the ref on demand) is
  max(60px, 20% of width) OR a fast flick (>0.5 px/ms with at least
  40px of movement).
- transitionend advances currentIndex with wrap-around and resets the
  track in one batch — slot contents rotate and the track snaps from
  the commit position back to centered with transition: none, so the
  visible image stays put. No flicker.
- Vertical-cancel (>24px dy) abandons the drag and springs back so the
  user keeps the gesture they intended.
- touch-action: none on the carousel container stops the browser
  fighting us with edge-swipe back navigation and native pinch-zoom.
- Pinch starting mid-drag springs the track back smoothly so the image
  doesn't jerk under the second finger.
- onTouchCancel covers system-interrupted gestures (incoming call etc).
- dragX === 0 short-circuits to 'idle' instead of 'springing' so taps
  don't get stuck waiting for a transitionend that never fires.
- Neighbour slides use a simplified AuthenticatedImage render (no
  canvas/fragment-grid pipeline) since they're only on screen during
  the swipe; the current slide keeps the full protection chain.
- Neighbour videos render their thumbnail rather than spinning up a
  VideoPlayer. When the *current* photo is a video, the carousel is
  bypassed entirely — single VideoPlayer + no swipe handlers — because
  sliding a video element during a drag is awkward and adds nothing.
- Removed the now-redundant imageLoaded state + spinner;
  AuthenticatedImage already shows a placeholder while loading.

Keyboard arrows and the on-screen Prev/Next buttons still snap (no
animation) — animating them would have required input queuing for
fast double-presses, and the request was specifically about swipe.

"Swipe to navigate" hint
Removed the mobile-only overlay text. Swipe is universal in image
viewers; the instruction read like training wheels and competed with
the photo for attention.
2026-05-01 20:55:42 +02:00
Paul Nothaft d9d81372b8 fix(gallery): lazy-render skeleton grid for fast loads (#321 follow-up)
The gallery loading skeleton now renders the header bars immediately but
delays the 12-tile placeholder grid by 300ms. Galleries that load
quickly (the common case) never flash the empty grid before the real
photos render — addressing the follow-up reported on #321 — while
slower loads still get a placeholder so the page doesn't sit blank.
2026-05-01 20:55:19 +02:00
Paul Nothaft a5b20ca3fe fix(events): server-side search/pagination to remove first-100 cap (#346)
Counters and search on Admin → Events were bounded to the first 100 rows
returned from /admin/events?page=1&limit=100, so on instances with more
events the totals were wrong and search couldn't find anything outside
that window. The dashboard's expiring list had the same first-100 issue.

Backend
- adminEvents.js: extend search to include customer_email so the column
  shown in the table is actually queryable.
- adminDashboard.js: add totalEvents to /dashboard/stats so the events
  page can render an accurate "All (N)" / Total Events counter without
  walking the full table on the client.

Frontend
- events.service.ts: getEvents() now accepts search + the full status
  enum (active|inactive|archived|draft|expiring); response type matches
  the actual {events, pagination} shape.
- admin.service.ts: DashboardStats gains totalEvents.
- EventsListPage.tsx: rewired around server-side pagination, status
  filter, and 300ms-debounced search; Prev/Next + range/page indicator
  below the table; placeholderData keeps the previous page visible
  during fetches; stat cards and "All (N)" pull from /dashboard/stats so
  totals stay accurate regardless of the visible page; archive/delete
  invalidates dashboard-stats so cards refresh.
- AdminDashboard.tsx: expiring list now fetches getEvents(1, 5,
  'expiring') directly instead of slicing the first 100 client-side. As
  a side effect the dashboard's "expiring" definition now matches the
  backend (was excluding events expiring within the next 24h).
2026-05-01 20:55:13 +02:00
Paul Nothaft 92847bc06b Merge pull request #345 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.1-beta.0
2026-04-30 09:09:37 +02:00
github-actions[bot] 7e65921ba6 chore(beta): release 3.32.1-beta.0 2026-04-30 07:08:45 +00:00
Paul Nothaft 02ed5d4007 Merge pull request #344 from the-luap/chore/move-docs-to-picpeak-app
docs: move documentation to docs.picpeak.app, drop in-repo copies
2026-04-30 09:08:16 +02:00
Paul Nothaft 0faf9b3281 docs: move documentation to docs.picpeak.app, drop in-repo copies
The full documentation now lives at https://docs.picpeak.app — built
from the picpeak-docs Nextra repo. The README, SIMPLE_SETUP, and the
v1 OpenAPI generation flow all point there now.

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

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

Updated:
- README.md — replaced six in-repo doc links with docs.picpeak.app
  pointers, restructured the Documentation section as a curated link
  list to the new site
- SIMPLE_SETUP.md — single deployment-guide link redirected
- .gitignore — docs/openapi.{json,yaml} are now build artifacts, not
  tracked
- backend/src/routes/v1/events.js — comment clarifies the OpenAPI flow
2026-04-29 22:24:15 +02:00
Paul Nothaft 39af382eb2 Merge pull request #343 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.0-beta.0
2026-04-29 20:43:46 +02:00
github-actions[bot] 784c92fc4d chore(beta): release 3.32.0-beta.0 2026-04-29 18:35:09 +00:00
Paul Nothaft 7ea4801544 Merge pull request #342 from the-luap/feat/webhook-payload-enrichment
feat(webhooks): enrich event.* payloads with customer contact + share_token (#341)
2026-04-29 20:34:46 +02:00
Paul Nothaft 1e69d5ff71 feat(webhooks): enrich event.* payloads with customer contact + share_token (#341)
The event.published webhook reporter wired into n8n to send WhatsApp
gallery links was missing the data needed to actually message the
customer — only event_name + share_url were in the payload, no
customer_name / customer_email / customer_phone, and no bare share
token to construct alternate URLs.

Adds a single canonical event subject helper (webhookService.buildEventSubject)
so every event.* webhook returns the same shape:

  { id, slug, event_name, event_type, event_date,
    share_url, share_token,
    customer_name, customer_email, customer_phone }

Fields the caller does not have in scope come back as null — keys are
always present so receivers do not have to distinguish "field missing"
from "field null". Pure addition: existing receivers continue to work,
existing templates ${data.event.event_name} keep working, and new
templates can now reference ${data.event.customer_phone} etc.

Wired into all five firing sites:
- routes/events.js — public event create (created + published)
- routes/adminEvents.js — admin create + draft→publish
- routes/v1/events.js — public v1 API (created + published)
- services/expirationChecker.js — event.expired (extra: expires_at)
- services/archiveService.js — event.archived (extra: archive_path)

PII surface area widens (customer email/phone now flow to webhook
receivers), so:
- Settings → Webhooks UI gets an amber Callout above the create form
  warning admins to only point webhooks at receivers they trust.
- Docs page updated with the new payload sample, the always-present
  null contract, and a Callout warning.

Verified end-to-end against the local dev webhook receiver — delivered
payload contains all 10 fields. webhookDelivery integration suite
remains 8/8 green.
2026-04-29 20:32:20 +02:00
Paul Nothaft 1b1d816009 Merge pull request #340 from the-luap/refactor/settings-nav-grouped
refactor(settings): grouped left-rail nav replaces overflowing tab bar
2026-04-29 00:03:04 +02:00
Paul Nothaft f171f6b974 refactor(settings): grouped left-rail nav replaces overflowing tab bar
The Settings page packed 13 tab buttons into a single horizontal nav
that overflowed even at 1440px — items wrapped or got clipped, and
"Webhooks" disappeared off the right edge entirely. Pattern was the
right call at 5 tabs and broken at 13.

Replaces the flat row with the macOS Settings / Stripe / GitHub pattern:

- **Desktop (lg+)**: 220px sticky left rail with five labelled groups —
  General, Display, Privacy & Security, Integrations, System — and a
  lucide icon next to every item. Active state uses the existing primary
  token. Adds a section header on the right pane that echoes the active
  item so the context is obvious after a switch.
- **Mobile (< lg)**: native <select> with <optgroup> per category. One
  tap to switch, no horizontal scroll, screen-reader friendly.

Categories chosen to be balanced (avg 2.6 items/group) and to map to
how admins actually think about these settings rather than alphabetical
or insertion order. Ports the existing inline-fallback i18n pattern for
the new group labels.
2026-04-28 23:59:54 +02:00
Paul Nothaft 625711af96 Merge pull request #339 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.31.1-beta.0
2026-04-28 22:15:23 +02:00
github-actions[bot] c5a2ec3842 chore(beta): release 3.31.1-beta.0 2026-04-28 16:06:38 +00:00
Paul Nothaft 1e4067713c Merge pull request #338 from the-luap/fix/post-329-bug-triage
fix: mobile lightbox + share previews + customer phone bug triage
2026-04-28 18:06:15 +02:00
Paul Nothaft 42a7ae4be8 fix(lightbox): mobile toolbar clipping + iOS safe-area + viewport-fit (#336)
When feedback was enabled the lightbox bottom toolbar packed counter +
zoom + download + like + 5-star + comments into a single row that
overflowed the viewport on iPhone-class widths, putting the rating
stars under the screen edge and below the iOS home indicator.

Changes:
- Bottom toolbar now uses flex-wrap with reduced gap/padding on mobile,
  so all controls fit (375px viewport: max-right 363 < 375; 390px:
  max-right 378 < 390; 393px: max-right 393 < 393).
- pb computed as max(0.75rem, env(safe-area-inset-bottom)) so the row
  sits above the iOS home indicator on devices with a gesture bar.
- Close button top/right now use max(1rem, env(safe-area-inset-*)) so
  it doesn't disappear under the notch / dynamic island.
- "Swipe to navigate" hint moved from bottom-20 to bottom-40 so it
  clears the now-taller wrapped toolbar.
- index.html viewport meta gains viewport-fit=cover to enable
  env(safe-area-inset-*) on iOS Safari.

Verified in mobile emulation across iPhone SE (375x667), iPhone 13/14
(390x844), iPhone 14 Pro (393x852) portrait, and 14 Pro landscape
(852x393) — toolbar fits, photo centered, no clipping.
2026-04-28 16:50:44 +02:00
Paul Nothaft fcddfe094b fix(gallery): use ref for swipe-start to avoid stale-closure miss (#332)
Found via real-browser verification: with useState the prior commit's
handleTouchEnd captures swipeStart from its render closure, so when
touchstart and touchend fire inside the same React batch (fast swipe,
synthetic events, or a tight render cycle) the end handler reads the
stale null and skips navigation. useRef sidesteps the closure entirely
and is the right primitive for cross-event scratchpad state anyway.

Verified in a 4-photo gallery on mobile-emulation (390x844 touch):
- left swipe (-200px) advances 1/4 → 2/4
- right swipe (+200px) returns 2/4 → 1/4
- 20px swipe (under threshold) does not navigate
- vertical swipe (dy 300, dx 20) does not navigate
2026-04-28 15:55:09 +02:00
Paul Nothaft 5275621fcd fix(share): OG/Twitter-card metadata for gallery share URLs (#333)
WhatsApp / Slack / Facebook / Twitter previews showed nothing useful for
shared gallery links — the SPA's stub index.html has no OG tags and the
meta-injection in DynamicFavicon happens at runtime, which crawlers
never see (they don't execute JS).

Add a backend OG handler at /og/gallery/:slug that returns minimal HTML
with proper og:* and twitter:* meta sourced from the event row + branding
settings (event name, formatted date, welcome_message excerpt as
description, configured logo as the preview image, FRONTEND_URL-based
canonical). Honours slug redirects so renamed galleries still get rich
previews.

Wire crawler detection in both nginx configs (production and dev) — UA
match against the standard list (facebookexternalhit, WhatsApp, Slackbot,
Twitterbot, Discordbot, LinkedInBot, etc.) triggers an internal
rewrite to /og/gallery/:slug, while humans fall through to the SPA via
try_files. The OG endpoint is also wired into the native-install SPA
fallback in server.js for setups that bypass nginx.

The OG image is intentionally the brand logo, not a gallery photo —
crawlers fetch it without auth, and password-protected gallery photos
must not leak via share previews.
2026-04-28 15:05:44 +02:00
Paul Nothaft 4c8eba0cb4 fix(gallery): single-finger swipe nav in mobile lightbox (#332)
The lightbox showed a "Swipe to navigate" hint on mobile, but the touch
handlers only implemented pinch-to-zoom (2-finger). Single-finger swipe
fell through and the user could only navigate with the on-screen arrows.

Add a 1-finger swipe detector: track the initial touch position, and on
touchEnd compute deltaX/deltaY/duration. Trigger goToPrevious /
goToNext when the horizontal swipe exceeds 50px, dominates over
vertical motion (1.2x), and completes within 600ms. Suppressed while
zoomed in so the user can pan the image instead.
2026-04-28 15:05:30 +02:00
Paul Nothaft 4c73d228ed fix(events): show customer phone in event details view (#331)
The phone field added in #322 was wired into the edit form but never
rendered in the read-only event-info panel, so admins could only see the
number while editing. Add a phone row gated on event_phone_field_enabled
(same toggle the form uses), and tighten the Event type so customer_phone
is no longer accessed via `(event as any)`.
2026-04-28 15:05:22 +02:00
Paul Nothaft ca8acacd43 Merge pull request #335 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.31.0-beta.0
2026-04-28 14:53:06 +02:00
github-actions[bot] f58b52a9d1 chore(beta): release 3.31.0-beta.0 2026-04-28 12:51:00 +00:00
Paul Nothaft 06d54bec4d Merge pull request #334 from the-luap/feat/post-319-fixes-and-features
feat: S3 storage + webhooks + settings dedupe + backup fixes
2026-04-28 14:50:35 +02:00
Paul Nothaft e232f9f2cf fix(backup): incremental backups against S3 + jsonb stats parsing
Three fixes uncovered while bringing the backup-s3 integration suite to
12/12 against MinIO + Postgres:

- backupService.getDatabaseBackupInfo: pg's jsonb driver auto-parses
  `statistics` / `table_checksums` to objects; the old JSON.parse() then
  threw "[object Object]" is not valid JSON and the manifest dropped
  database info silently. Accept both string and object inputs.

- backupService.runBackup: incremental path called
  backupManifest.loadManifest() with an s3:// URI directly, which falls
  through to fs.readFile() and ENOENTs — every "incremental" backup
  silently downgraded to a full one. Added loadManifestFromAnywhere()
  helper that downloads s3:// to a tmp file before delegating.

- backupManifest.generateIncrementalManifest: attached the `incremental`
  section AFTER generateManifest() had already stamped
  verification.total_checksum, so every incremental manifest failed
  validateManifest() on read-back. Recompute the checksum after.

Test side: updated assertions to the current manifest shape
(`incremental.changes.modified_files_count`), Number()-coerce bigint
columns from pg, and gate the logger mock on UNMOCK_LOGGER for
diagnosing similar silent-failure modes in the future.
2026-04-28 10:52:49 +02:00
Paul Nothaft ab4095f592 fix(backup): cron schedule mapping + manifest format detection + bigint coerce
Three pre-existing bugs surfaced by re-running the backup-s3 integration
suite. backup-s3 went 0/12 → 7/12 (storage-refactor session bootstrap
fixes) → 10/12 with this commit.

1. Backup service crashes on backend startup with
   `TypeError: Cannot read properties of undefined (reading 'replace')`
   from node-cron's expression parser.

   Root cause: `backup_schedule` stores a UI label like "weekly", while
   `backup_schedule_cron` stores the actual cron expression. Startup
   code read the label and passed it straight to cron.schedule() —
   "weekly" is not a cron expression.

   Fix in startBackupService(): read backup_schedule_cron first; fall
   back to mapping known labels (hourly/daily/weekly/monthly) to cron
   expressions; back-compat for deployments that wrote a cron expression
   into the legacy backup_schedule field.

2. Backup manifest retrieval fails with
   `SyntaxError: Unexpected token 'a', "applicatio"...` when the
   manifest format is YAML.

   Root cause: getBackupManifest() downloads the s3:// manifest to a
   tmp file hardcoded as `manifest-N.json`. loadManifest() then
   detects format from extension only — sees .json, runs JSON.parse on
   YAML content (which starts with "application: …"), fails.

   Fix in backupManifest.loadManifest(): detect format from BOTH the
   extension AND the content's first non-whitespace character. JSON
   starts with { or [; anything else falls through to yaml.load.
   Backwards compatible — extension is still authoritative when present
   AND content matches.

3. Test assertion `expect(backupRun.total_size_bytes).toBeGreaterThan(0)`
   fails with "received value must be a number or bigint" because pg
   driver returns bigint columns as strings. Coerce via Number() in
   the test.

Remaining 2 failures (out of scope here, both are spec-level drift):
- "should include database backup" expects the runBackup() flow to
  upload the database backup file at S3 key `database/db-backup.sql`.
  Current implementation reads db backup metadata for the manifest but
  does not upload the file itself. Missing feature, not a test bug.
- "should only upload changed files" expects manifest.incremental.
  modified_files_count. Implementation writes backupType: 'incremental'
  on the run row but no per-run incremental subobject in the manifest.
  Field shape mismatch.
2026-04-28 10:15:46 +02:00
Paul Nothaft 446d80a4cc feat: presigned download UI + S3 prefix walker auto-importer (follow-ups)
Closes the user-facing surface for the two #328 follow-ups previously
landed in code form (presigned route + S3 mode notes), plus the schema
migration that backs both #328 and #327 follow-ups.

Migration 083
- events.allow_presigned_download — per-event opt-in for the
  presigned-URL "Download All" path. Off by default because it bypasses
  watermarks; admins flip it knowingly. Mutually exclusive with
  watermark_downloads.
- webhooks.filter (jsonb default {}) — dot-path equality predicate
  evaluated at fire time. Empty object = no filter, fire always.
  Backs the filter logic that shipped with #327.
- webhooks.template (text nullable) — optional ${dot.path} string
  substitution applied at delivery time. NULL = use the default JSON
  envelope (back-compat). Backs the template logic from #327.

S3 prefix walker (services/s3AutoImporter.js)
- Replaces the chokidar file-watcher in S3 mode (where there's no
  inotify equivalent on remote objects).
- Polls every active event's S3 prefix every 5 min by default
  (STORAGE_AUTO_IMPORT_INTERVAL_MS overridable).
- Eventual-consistency gate: an object is only imported after it's
  been seen for two consecutive polls. Avoids flapping when S3 returns
  a freshly-uploaded object that disappears on the next list (a
  documented S3 behavior on certain backends).
- Skips generated artifacts (thumb_*, hero_*, dot-files).
- Inserts photos rows + fires photo.uploaded webhooks the same way
  the local fileWatcher does.
- Opt-in via STORAGE_AUTO_IMPORT=true. Off by default because it adds
  API call cost.

EventDetailsPage UI (frontend)
- Round D queryKey alignment for #325 dedup — replaces useQuery on
  publicSettingsService with the shared usePublicSettings() hook so
  the page joins the same React Query cache as every other consumer.
- Per-event "Allow direct S3 download (no watermark, S3 mode only)"
  toggle in Download Protection. Disabled when watermark_downloads is
  on; tooltip explains the bandwidth/watermark trade-off. Toggling
  watermark_downloads on automatically clears allow_presigned_download
  to keep the two mutually exclusive in the UI.

Verified live against MinIO
- Presigned: GET /api/gallery/.../download-all → 302 with
  Location: http://minio:9000/...?X-Amz-Signature=...&X-Amz-Expires=300.
  Following the URL inside the docker network → HTTP 200, valid
  PK ZIP archive containing the photo.
- Auto-importer: dropped a file via `mc cp` directly into the bucket;
  watcher imported it after 2 polls; webhook subscribed to
  photo.uploaded fired with source=s3-auto-import; receiver got POST
  with valid HMAC, status=success, 3ms latency.
2026-04-28 10:08:21 +02:00
Paul Nothaft c488f481ca feat: outbound webhooks for event/photo lifecycle (#327)
PicPeak POSTs lifecycle notifications to admin-configured URLs. Each
delivery is signed HMAC-SHA256 in the X-PicPeak-Signature header.
Verified end-to-end: 1/1 Playwright spec, 8/8 backend integration
tests, full UI click-through via Chrome DevTools.

Schema (migration 082)
- webhooks: id, name, url, secret (plaintext — required to compute HMAC
  for every outbound POST), secret_preview, events[], active, filter,
  template, created_by, timestamps, last_success_at/last_failure_at.
- webhook_deliveries: webhook_id (FK CASCADE), event_type, payload,
  attempt_count, status (pending|success|failed), response_status,
  response_body (truncated to 1KB), latency_ms, next_retry_at,
  last_error, created_at, completed_at. Composite index
  (status, next_retry_at) serves the worker's hot-path query.

Service + worker
- webhookService.fire(eventType, data) — non-throwing entry point used
  by lifecycle hooks. Looks up active webhooks subscribed to the event
  and applies their per-webhook filter (dot-path equality predicate)
  before enqueueing one webhook_deliveries row per match. Filter and
  template logic ship in this commit; admin surfaces in the follow-up.
- webhookDeliveryWorker — setInterval(5s) poller; fetches up to 5
  pending rows; per delivery: re-validates URL via networkValidation
  (DNS-rebinding mitigation, opt-out via WEBHOOK_ALLOW_PRIVATE_URLS),
  signs body with HMAC-SHA256, POSTs with 10s timeout, records outcome.
  Backoff schedule: 1m → 5m → 30m → 2h → 12h, max 5 attempts. Response
  body truncated to 1KB before storage. If a webhook has a template,
  the rendered string replaces the JSON envelope as the request body
  (signature is computed over the bytes actually sent).

Lifecycle wiring
- adminEvents.js POST /events → event.created (+ event.published when
  not draft); POST /:id/publish → event.published.
- routes/events.js (legacy public POST) → event.created + event.published.
- routes/v1/events.js (#322 API) → event.created + event.published on
  create, photo.uploaded on photo POST.
- archiveService.archiveEvent() → event.archived. Per-photo
  photo.deleted intentionally NOT fired during cascade — receivers
  infer from event.archived to avoid flooding (issue spec).
- expirationChecker.handleExpiredEvent() → event.expired BEFORE the
  cascading archive (so receivers see expired→archived in order).
- adminPhotos.js — photo.uploaded on each batch row, photo.deleted on
  single + bulk delete.
- photoProcessor.js — photo.uploaded for guest uploads + auto-import
  (covers all entry paths).
- fileWatcher.js — photo.uploaded on add, photo.deleted on unlink
  (local mode only).

Admin endpoints (mirrors adminApiTokens.js pattern)
- /api/admin/webhooks: GET list, POST create (returns plaintext secret
  exactly once), GET :id, PUT :id, DELETE :id, POST :id/test (synthetic
  fire), GET :id/deliveries (paginated, filter by status), GET
  :id/deliveries/:deliveryId, POST :id/deliveries/:deliveryId/replay.

Frontend
- Settings → Webhooks tab (mirrors API Tokens layout): name + URL +
  event checkboxes + "Advanced" expander for filter (JSON) and template.
  Plaintext secret shown once on creation with a Copy button. Active/
  Disabled toggle button per row.
- /admin/webhooks/:id/deliveries — operational debug surface. Table
  with timestamp/event/status/attempts/HTTP/latency. Status filter chips
  (all/pending/success/failed). Row click → slide-over with payload +
  signature + response body. Replay button on failed rows. Send-test-event
  dialog. Auto-refresh every 10s.

Dev infrastructure
- dev/webhook-receiver/ — tiny node:alpine HTTP server (~100 LOC) that
  records every POST to an in-memory ring buffer. Exposes GET /requests
  for the E2E spec to assert deliveries landed with the right HMAC.
  Sibling pattern to MinIO. Reachable from the backend at
  http://webhook-receiver:8888 inside the picpeak network.

Tests
- backend/__tests__/integration/webhookDelivery.test.js (8/8) —
  signature verification, headers, retry/backoff, max-attempts → failed,
  response truncation, disabled-mid-flight, SSRF block, start/stop
  idempotency.
- tests/e2e/webhooks-roundtrip.spec.ts (1/1) — create webhook → trigger
  event.published → assert receiver got POST with valid HMAC → visit
  deliveries page → row visible with status=success → API test event →
  API replay → disable webhook → assert no new delivery.

Docs
- README §"Webhooks" — event catalog, payload shape, HMAC verification
  in Node + Python + bash, retry semantics, SSRF protection.
- .env.example — WEBHOOK_ALLOW_PRIVATE_URLS, WEBHOOK_DELIVERY_INTERVAL_MS,
  WEBHOOK_DELIVERY_CONCURRENCY, WEBHOOK_HTTP_TIMEOUT_MS,
  WEBHOOK_MAX_ATTEMPTS.

Out of scope for v1 (per issue): webhook templates' code-eval (the
${dot.path} substitution that ships is pure string replacement, no
expression engine — see follow-up commit), per-webhook rate limiting
beyond the global concurrency cap, synchronous "ask before delete"
webhooks.

Spanning files
- App.tsx pulls in this commit with both the AnalyticsBootstrap
  (#325 dedup) and the WebhookDeliveriesPage route registration.
  Splitting via git add -p was forfeit for sanity; the single 92-line
  diff is honest about both contributions.
- adminEvents.js diff bundles the webhook fires AND the
  allow_presigned_download field plumbing (#328 follow-up). Same
  reasoning.
- The new webhookService/Worker/adminWebhooks files include the filter
  and template logic from the follow-up — they were authored in one
  pass; splitting them post-hoc would have produced fragile partial
  files. The follow-up commit covers the migration and the UI for these.
2026-04-28 10:07:39 +02:00
Paul Nothaft 1b717ce5ed feat: native S3 storage backend (#328) + presigned download follow-up
Lets PicPeak write photos, thumbnails, hero images, watermarks, and
archive zips to any S3-compatible bucket (AWS S3, MinIO, Cloudflare R2,
Backblaze B2, Wasabi, DigitalOcean Spaces) instead of the local
filesystem. Selected via STORAGE_BACKEND=local|s3.

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

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

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

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

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

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

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

Out of scope for v1 (per the issue): presigned URLs for individual
photo display (always streamed for protection middleware), CDN
integration, hybrid hot/cold tiers, S3 → local migration, multi-bucket
per-event.
2026-04-28 10:06:36 +02:00
Paul Nothaft 3d4ae4d7e9 feat(frontend): dedupe /public/settings via shared usePublicSettings hook (#325)
Pre-dedup: 7 calls to /api/public/settings on a single /admin/login page
load — 4 from raw-fetch consumers + 3 from React Query consumers using
inconsistent queryKeys. Captured live in Chrome DevTools.

Post-dedup: 1 call. Verified by tests/e2e/public-settings-dedup.spec.ts.

Adds:
- frontend/src/hooks/usePublicSettings.ts — single React Query hook,
  60s staleTime, queryKey ['public-settings']. Vitest with mocked api
  proves multi-mount dedup.
- Extended PublicSettings interface with seo_meta_* fields used by
  RobotsMetaTags and branding_logo_* fields used by GalleryView/AdminHeader.

Migrates 19 call sites across 4 risk-ordered rounds:
- Round A (high fan-in): GlobalThemeProvider, MaintenanceContext (with
  refetchInterval to preserve maintenance polling), MaintenanceWrapper
  (drops the now-redundant per-route ping; axios interceptor already
  handles 503), AdminHeader.
- Round B (gallery/login): GalleryView, GalleryPage, ClientAccessPage,
  AdminLoginPage, MaintenanceMode.
- Round C (decorative): RobotsMetaTags, DynamicFavicon, CMSContentBlock,
  ReCaptcha, useWatermarkSettings (rips out raw fetch + local state),
  LegalPage.
- Round D (queryKey alignment): useLocalizedDate, UserPhotoUpload,
  CreateEventPage. EventDetailsPage Round D ships in the follow-up
  commit that adds presigned-download UI on the same page.

App.tsx (AnalyticsBootstrap) and EventDetailsPage are deferred to
later commits — both files mix #325 changes with backend feature work.
2026-04-28 10:01:53 +02:00
Paul Nothaft 2794ed6722 Merge pull request #330 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.30.0-beta.0
2026-04-27 23:53:57 +02:00
github-actions[bot] ca0e48eb68 chore(beta): release 3.30.0-beta.0 2026-04-27 20:41:31 +00:00
Paul Nothaft 11de7b65c9 Merge pull request #329 from the-luap/feat/post-319-fixes-and-features
Bugfixes, public API, CMS error pages, BMC sponsor link
2026-04-27 22:41:02 +02:00
Paul Nothaft 46bc894d91 docs: add Buy Me a Coffee badge + Support section
Adds a yellow Buy Me a Coffee badge to the header alongside the existing
License/Docker/Node/React badges, plus a small "Support the Project"
section above Acknowledgments with the standard BMC button image. Also
adds a link in the inline nav row at the top of the README so first-time
visitors can find it.

Link: https://buymeacoffee.com/theluap

Lightweight, opt-in support — explicitly notes that starring, sharing,
filing good bug reports, and opening PRs are equally welcome ways to
help if money isn't in the budget.
2026-04-27 22:38:00 +02:00
Paul Nothaft 038e84cae7 fix: dedupe parallel admin 401 redirects to /admin/login
Visiting /admin/dashboard while logged out caused a navigation storm:
the dashboard fires ~7 /api/admin/* queries on mount, each returns 401,
each axios interceptor call did `window.location.href = '/admin/login'`.

The path-based guard `currentPath.includes('/admin/login')` reads
`location.pathname` *synchronously* — but `location.href = …` is async,
so all 7 parallel handlers saw the still-old pathname and each fired a
fresh navigation. The browser logged 6+ ERR_ABORTED entries and the user
saw a flicker storm. Same shape would bite any admin page that fans out
queries on mount.

Add a module-level `adminLoginRedirectPending` flag set the moment we
kick off the first redirect; subsequent 401s in the same tick see it
and skip. Single navigation, clean transition to login.

Smoke spec 10-admin-redirect-loop locks the regression in by sampling
the URL across 5 ticks — if any tick lands somewhere other than
/admin/login, the spec fails.
2026-04-27 22:38:00 +02:00
Paul Nothaft 2eead52319 fix: theme picker buttons no longer submit the parent form (#326)
Every <button> inside ThemeCustomizerEnhanced was bare — no `type`
attribute, defaulting to `type="submit"`. Inside CreateEventPage's
<form onSubmit={handleSubmit}>, that turned every theme/layout/header/
divider/control/colour-mode/CSS-template click into a form submission.

When the form was empty, validation killed the submit silently — that
showed up earlier as #317.2 ("theme picker unclickable").

When the form was filled (event_name set, etc.), validation passed,
`createMutation.mutate(payload)` ran, and the user was navigated to a
freshly-created event they never asked for — #326's reported symptom.

Fix: add `type="button"` to all 9 unmarked <button>s in the customizer.
Also covered by smoke spec 09-create-event-no-instant-submit which fills
the form, clicks Modern Masonry, and asserts the URL stays on
/admin/events/new and the events count is unchanged.
2026-04-27 22:38:00 +02:00
Paul Nothaft 808b15bafb feat: public v1 API + token management + OpenAPI docs (#322)
Adds a long-lived bearer-token mechanism + scoped REST surface designed
for n8n-style automation: create a gallery, upload photos, fetch the
share URL — all via documented HTTPS endpoints instead of poking at the
admin UI's internal routes.

API
- Migration 081 adds `api_tokens` (hashed_token, scopes, owner FK,
  last_used/expires/revoked timestamps).
- New apiTokenAuth middleware: parses `Authorization: Bearer pp_live_…`,
  resolves to the owner admin user, attaches `req.admin` so existing
  permission decorators (events.create etc.) still work. Token-level
  scope check (read/write/admin) layers on top as defence in depth —
  a leaked read-only token cannot mutate even if its owner is super_admin.
- adminApiTokens route exposes list/create/revoke for admins (cookie-
  authed). Plaintext token is returned exactly once on creation.
- v1 surface mounted at /api/v1: POST/GET /events, GET /events/:id,
  POST /events/:id/photos (multipart, single file), GET
  /events/:id/share-link. Each endpoint annotated with @openapi JSDoc.

Documentation
- swagger-jsdoc + swagger-ui-express produce a live spec at
  /api/openapi.json and a Swagger UI at /api/docs (admin-gated).
- backend/scripts/generate-openapi.js writes docs/openapi.{json,yaml}
  to the repo so the spec is versioned.
- scripts/sync-api-docs.sh runs in pre-push: regenerates the spec and
  copies it into the picpeak-docs Nextra site at app/api/. Writes only,
  never commits or pushes the docs repo (PUSH_SKIP_DOCS=1 to bypass).

Frontend
- New Settings → API Tokens tab: generate, list, revoke. Plaintext
  tokens are shown once with a copy-to-clipboard control.
2026-04-27 22:38:00 +02:00
Paul Nothaft be6cb28c80 feat: optional customer phone field gated by global toggle (#322)
Adds a `customer_phone` column on events plus an `event_phone_field_enabled`
admin setting (default off) that surfaces the input in the create-event
and event-detail forms. Designed for downstream automation tooling — once
exposed via the upcoming public API, n8n / similar can pick it up to
deliver gallery links over WhatsApp, SMS, etc.

- Migration 080 adds the column + seeds the setting as false. Existing
  deployments see no UI change unless the admin opts in via
  Settings → Events.
- Backend strips the field server-side when the toggle is off (defence
  in depth against form bypass).
- Frontend renders the input only when the public-settings flag is true;
  always optional even then.
- publicSettings + EventSettings types extended; CreateEventPage and
  EventDetailsPage wired to read the toggle and submit the value.
2026-04-27 22:38:00 +02:00
Paul Nothaft 4f77905b87 feat: customisable 404 + gallery-not-found pages via CMS (#324)
The 404 catch-all and the "gallery not found" branches in GalleryPage
were hard-coded English strings on a default-themed background — the
one place where a white-labelled deployment leaked the PicPeak default
look. Pluggable now via the existing CMS Pages mechanism.

Backend:
- Seed two new default CMS pages: `not-found` and `gallery-not-found`,
  with sensible English/German copy admins can edit in /admin/cms.
- Add `cms_pages.logo_url` (nullable) for per-page logo override; online
  migration on existing deployments. Null falls back to the global
  branding logo.
- New per-page logo upload (POST /api/admin/cms/pages/:slug/logo) +
  clear endpoint (DELETE …/logo). Reuses the existing /uploads/logos
  storage location with a `cms-<slug>-` filename prefix.
- adminCMS PUT now accepts logo_url; publicCMS GET returns it.

Frontend:
- New <CMSContentBlock slug fallback> component renders the CMS page in
  the standard branded shell (logo precedence: page → branding → bundled
  default), with DOMPurified content and footer/legal links.
- App.tsx: `path="*"` catch-all routes through CMSContentBlock("not-found").
- GalleryPage: collapses the two "gallery not found" branches (invalid
  identifier + infoError archived/missing) into a single
  CMSContentBlock("gallery-not-found"), so admins can edit one source
  of truth.
- Admin CMS Page editor gains an "Upload Logo / Use site default"
  control per page; falls back to the page's own English title in the
  page list when no `legal.<slug>` translation is registered.
2026-04-27 22:38:00 +02:00
Paul Nothaft b63a8774c4 fix: theme-preset match loop ignores extra fields like logoUrl (#323)
The "which preset does this saved theme match?" loop in BrandingPage and
CreateEventPage was doing a full JSON.stringify equality on preset.config
vs the loaded theme. The previous #323 logo-preservation work means the
saved theme legitimately carries a `logoUrl` (and any other fields the
parent maintains), so the equality check would never match and the
preset summary fell back to "Custom Theme" / Classic Grid even when the
saved theme was structurally Dark Modern, etc.

Compare only on the preset's own keys instead. Surfaced by the new
smoke spec 07-branding-default-on-create-event which would otherwise
pass green against the broken state.
2026-04-27 22:38:00 +02:00
Paul Nothaft 793e410554 fix: floor password_changed_at when comparing against JWT iat
JWT `iat` has 1-second resolution; `password_changed_at` is stored with
sub-second precision. The previous comparison rejected tokens whose iat
fell in the same wall-clock second as a password change — e.g. a token
issued by an immediate re-login after a password reset, or by any
script-driven flow that resets and logs in in quick succession. Floor
the stored timestamp to whole seconds before comparing.

Caught while wiring up the local E2E suite: the seeder needed a
"set password_changed_at 10 s in the past" hack to avoid this race;
with the fix in place that hack is gone and the suite is naturally
deterministic.
2026-04-27 22:38:00 +02:00
Paul Nothaft 8d0fb8e157 chore: expose pid + uptime on /health for crash-detection monitors
Adds `pid` and `uptime` fields to the /health response so external monitors
(and the local E2E watchdog) can detect a silent process restart between
two checks — e.g. an unhandled rejection that crashes Node and Docker
quietly relaunches the container.

Also adds .gitignore patterns for a local-only E2E suite that lives in
tests/e2e/local/ on individual machines and is never pushed.
2026-04-27 22:38:00 +02:00
Paul Nothaft 822be9a9b2 fix: theme save without Live Preview, Branding default on new events, gallery loading flicker (#323, #321)
#323-A — Branding colour changes weren't persisting unless "Apply changes
immediately (Live Preview)" was checked. ThemeCustomizerEnhanced was
gating its `onChange` callback on `isPreviewMode`, but the parent
BrandingPage already gates global `setTheme()` on its own copy of that
flag — so the customizer's gate was double-gating and silently dropped
the new values from the parent state that Save reads from. Always
propagate `onChange`; let parents decide what's "live". Removed the now
no-op `isPreviewMode` prop and dropped the unused passers.

#323-B — Default theme set in Branding wasn't applied to new events.
CreateEventPage only inherited the event-type's recommended preset, with
'default' falling back to Classic Grid. Now reads `settings.theme_config`
on first load and uses it as the form's starting theme; the event-type
effect skips the generic 'default' so the Branding default sticks for
event types like "Other".

#321 — Visitors saw four sequential render states when opening a gallery
(full-page "Loading Gallery" → "publicly accessible — loading photos"
card → skeleton grid → real gallery). Extracted the skeleton into a
shared <GallerySkeleton/> and used it for both GalleryView's photos-
loading state and GalleryPage's gallery-info-loading + public-auto-login
phases. The "publicly accessible" interstitial is gone. Net: one
continuous skeleton from URL open until real photos render.
2026-04-27 22:38:00 +02:00
Paul Nothaft 63a6bfebce Merge pull request #320 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.29.1-beta.0
2026-04-26 22:52:44 +02:00
github-actions[bot] 3d5759738f chore(beta): release 3.29.1-beta.0 2026-04-26 20:52:07 +00:00
Paul Nothaft 2f2f405d9b Merge pull request #319 from the-luap/feat/prezip-and-photo-replace
fix: discussion #317 issues and #318 archive crash
2026-04-26 22:51:46 +02:00
Paul Nothaft 6cfff6f6a6 fix: address bugs and feature requests from discussion #317
- Share link: display and copy now use the absolute URL built from the
  current origin instead of the relative path stored in events.share_link.
  Added a Copy Link button to the events list (inline + dropdown).
- Detect dev tools default: event creation now reads the global
  enable_devtools_protection app setting instead of always falling back to
  the column default; admins who disable it globally get new events with
  it disabled too.
- Require password default: added a global "Require password by default"
  setting (event_default_require_password, default true), exposed via
  Settings -> Events. Create-event form initialises from it.
- Filter bar: added gallery_show_filter_bar setting and hide the search/
  sort row in the public gallery when off, or when the gallery has zero
  photos (fixes the empty-state UX from the screenshot).
- Theme picker unclickable on Create Event: memoised availableEventTypes
  so its identity is stable. The "auto-apply event-type recommended
  preset" effect was firing on every render due to the unstable array
  reference and silently overwriting the user's preset selection ~1ms
  after each click.
- Branding logo disappearing on theme change: handlePresetChange and
  handleThemeChange no longer wipe the existing logoUrl when a preset
  config (which carries no logoUrl) is applied; handleSave falls back to
  brandingSettings.logo_url. themeMutation now invalidates the
  admin-settings and public-settings caches so saved theme changes appear
  immediately.
2026-04-26 22:48:59 +02:00
Paul Nothaft e4b0f961b7 fix: prevent backend crash on archive when admin_email is null (#318)
Archiving an event with no admin_email queued an email_queue row with
recipient_email=null, violating the NOT NULL constraint. The error was
thrown inside the output.on('close') callback (detached from the caller),
becoming an unhandled rejection that crashed Node and dropped admin
sessions on bulk archive.

- Skip queueEmail when event.admin_email is null/empty (admin_email has
  been nullable since migration 073).
- Wrap the close handler in try/catch so any post-archive failure logs
  instead of crashing the process.
2026-04-26 22:15:00 +02:00
Paul Nothaft c0989796e4 Merge pull request #315 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.29.0-beta.0
2026-04-23 16:59:00 +02:00
github-actions[bot] 82adcd1f71 chore(beta): release 3.29.0-beta.0 2026-04-23 14:54:21 +00:00
Paul Nothaft d3f1206816 Merge pull request #314 from the-luap/feat/prezip-and-photo-replace
feat: pre-zip download all and photo replacement by name (#312, #313)
2026-04-23 16:49:59 +02:00
Paul Nothaft e18afd3e6b feat: pre-zip download all and photo replacement by name (#312, #313)
Pre-zip downloads:
- Generate ZIP in background after photo mutations (upload/delete/watermark change)
- Serve cached zip with Content-Length for instant downloads and native progress bar
- Falls back to on-the-fly streaming when no cache exists yet
- Frontend uses browser-native download when zip is ready (no blob buffering)
- New downloadZipService with debounced regeneration and in-memory locking

Photo replacement:
- Admin upload form gets "Replace existing photos with same name" checkbox
- Matches by original_filename (case-insensitive) within the same event
- Preserves photo ID, position, feedback, category, and visibility
- Updates file, thumbnail, dimensions, EXIF capture date on replacement
- Ambiguous matches (multiple photos with same name) skip replacement with warning
- New photoReplacementService with findReplacementCandidate and replacePhoto
2026-04-23 16:49:31 +02:00
Paul Nothaft 4353acebf9 Merge pull request #311 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.28.3-beta.0
2026-04-13 13:01:08 +02:00
github-actions[bot] 89f86b9fe4 chore(beta): release 3.28.3-beta.0 2026-04-13 05:30:51 +00:00
Paul Nothaft ceb2a09f48 Merge pull request #310 from the-luap/fix/welcome-message-and-guest-thumbnails
fix: revert /api prefix in adminPhotos.js to avoid double-prefix (#307)
2026-04-13 07:30:28 +02:00
Paul Nothaft 094276d3cc fix: revert /api prefix in adminPhotos.js to avoid double-prefix
AdminPhotoGrid uses AdminAuthenticatedImage which fetches via Axios
(baseURL: /api), so the backend URL must not include /api — Axios
adds it. The adminGuests.js /api prefix is correct because its
consumer (AuthenticatedImage) uses fetch() with buildResourceUrl().
2026-04-13 07:30:08 +02:00
Paul Nothaft 59b56ed3d7 Merge pull request #309 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.28.2-beta.0
2026-04-12 21:01:11 +02:00
github-actions[bot] 0a5b07de5d chore(beta): release 3.28.2-beta.0 2026-04-12 18:58:40 +00:00
Paul Nothaft b05c36ac81 Merge pull request #308 from the-luap/fix/welcome-message-and-guest-thumbnails
fix: display welcome message in gallery and fix guest thumbnail URLs (#306, #307)
2026-04-12 20:58:24 +02:00
Paul Nothaft 9323befdd9 fix: display welcome message in gallery and fix guest thumbnail URLs (#306, #307)
- Render welcome_message in gallery view for all non-fullpage layouts
  (grid, masonry, carousel, timeline, mosaic) as a centered banner
- Add /api prefix to thumbnail/photo URLs in adminGuests.js and
  adminPhotos.js so they route correctly through Nginx proxy
2026-04-12 20:58:02 +02:00
Paul Nothaft 61142c0d0e Merge pull request #305 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.28.1-beta.0
2026-04-12 10:06:43 +02:00
github-actions[bot] 623ab72916 chore(beta): release 3.28.1-beta.0 2026-04-12 08:06:08 +00:00
Paul Nothaft 3716ff5085 Merge pull request #304 from the-luap/fix/gallery-sort-direction-and-feedback-visibility
fix: apply sort direction in gallery and respect show_feedback_to_guests (#302, #303)
2026-04-12 10:05:52 +02:00
Paul Nothaft dffe057772 fix: apply sort direction in gallery view and respect show_feedback_to_guests (#302, #303)
- Gallery now respects the configured sort direction (asc/desc) from
  default_photo_sort setting instead of using hard-coded directions
- Photos endpoint zeroes out feedback fields (like_count, favorite_count,
  average_rating, comment_count, has_feedback) when show_feedback_to_guests
  is disabled, while still showing data to admin/client users
2026-04-12 10:05:24 +02:00
Paul Nothaft 3319a304ce Merge pull request #301 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.28.0-beta.0
2026-04-11 23:24:59 +02:00
github-actions[bot] c303dd51e8 chore(beta): release 3.28.0-beta.0 2026-04-11 21:24:41 +00:00
Paul Nothaft b1dfbe4c2f Merge pull request #300 from the-luap/feat/cookie-secure-auto
feat: add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments (#298)
2026-04-11 23:24:24 +02:00
Paul Nothaft 54badefc51 Merge pull request #299 from the-luap/fix/guest-masonry-lightbox
fix: guest feedback flow bugs in Masonry grid and PhotoLightbox (#292)
2026-04-11 23:24:02 +02:00
Paul Nothaft 15a8ab41fd feat: add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments (#298)
Adds a third value for the COOKIE_SECURE environment variable that
decides the cookie Secure flag per-request based on req.secure. This
unblocks a common self-hosted setup where the same PicPeak deployment
is reachable over both HTTPS (via reverse proxy) and plain HTTP (e.g.
LAN access at http://192.168.x.x:3001).

Behavior

  unset  - legacy default: follows NODE_ENV (production=true, dev=false)
  true   - always set Secure (unchanged)
  false  - never set Secure (unchanged)
  auto   - NEW: use req.secure per request. In practice this means
           Secure on HTTPS requests (when X-Forwarded-Proto: https
           reaches Express via a trusted proxy) and no Secure flag
           on plain HTTP requests.

The existing trust proxy config (`app.set('trust proxy',
'loopback, linklocal, uniquelocal')` in server.js) means
X-Forwarded-Proto is honored when forwarded from local/private-network
proxies, which covers Docker network setups and most self-hosted
deployments behind NPM, Traefik, or Caddy.

auto is strictly opt-in. The default behavior is unchanged, so existing
users see no difference. A follow-up release can consider promoting
auto to the default after real-world feedback.

Also fixed (latent bug, benefits everyone)

Cookie clear operations (clearAdminAuthCookie, clearGalleryAuthCookies)
previously wrote the same `secure` attribute as the set path. When a
cookie was set with Secure=true over HTTPS and the clear request came
over HTTP (or vice versa under auto mode), some browsers would reject
the Set-Cookie delete header, leaving the cookie in place. Browsers
match cookies by (name, domain, path) for deletion and don't care about
Secure, so the new buildClearCookieOptions() helper simply omits the
secure attribute.

Implementation

- secureCookie string is replaced by secureCookieMode which can hold
  true, false, or 'auto'.
- New resolveSecureFlag(res) returns the boolean for a specific
  response, delegating to res.req.secure when in auto mode.
- buildCookieBaseOptions and buildCookieOptionsWithExpiry now take res
  and pass it through.
- New buildClearCookieOptions() deliberately omits `secure`.
- setAdminAuthCookie / setGalleryAuthCookies / clearAdminAuthCookie /
  clearGalleryAuthCookies all updated to thread res where needed.
  Public signatures unchanged — every caller already has res in scope.

Testing

Verified against a real Express instance inside the backend container
with trust proxy configured, covering:

  - (unset) + NODE_ENV=production -> secure: true (legacy)
  - (unset) + NODE_ENV=development -> secure: false (legacy)
  - COOKIE_SECURE=true + req.secure=false -> secure: true (literal wins)
  - COOKIE_SECURE=false + req.secure=true -> secure: false (literal wins)
  - COOKIE_SECURE=auto + X-Forwarded-Proto: https -> secure: true
  - COOKIE_SECURE=auto + plain HTTP -> secure: false
  - clearCookie always omits the secure attribute

Documentation

Added a COOKIE_SECURE block to both .env.example files (root for
docker-compose, backend/.env.example for native install) explaining the
four values, when to use auto, and the two requirements (proxy must
forward X-Forwarded-Proto, proxy IP must be in the trust list). Also
documented COOKIE_SAMESITE and COOKIE_DOMAIN alongside, which were
previously undocumented.
2026-04-11 22:41:15 +02:00
Paul Nothaft 77f07e9329 fix: guest feedback flow bugs in Masonry grid and PhotoLightbox (#292)
Fixes two bugs reported on #292 after 3.27.0-beta.0 shipped:

1. Masonry grid showed no visual feedback after liking a photo.
   MasonryGalleryLayout's Like button had no liked-state plumbing —
   the Heart icon was a static <Heart> regardless of whether the user
   had liked the photo.

2. PhotoLightbox (fullscreen view) silently failed to like photos in
   guest identity mode. submitLike() and submitRating() never called
   ensureIdentity() before firing the API request, so the first
   interaction from a fresh session hit a 401 from the server instead
   of opening the name prompt.

Root causes:

1. MasonryGalleryLayout was missing the 'liked' state pattern that
   GridGalleryLayout already uses (likedPhotoIds Set in the parent,
   passed down as a `liked` prop, updated via onLikeSuccess callback).
   The bug was invisible in simple mode (no personal state) but
   surfaced immediately in guest mode where each guest expects to see
   confirmation of their own action.

2. PhotoLightbox's submit handlers were written before the guest
   identity context existed and only checked the legacy
   require_name_email flag. They were never updated when guest mode
   landed.

Also fixed: z-index conflict where the GuestNamePromptModal (z-50)
was sitting at the same level as PhotoLightbox (z-50), so when the
prompt opened over the lightbox, the fullscreen image intercepted
pointer events and the modal's Continue button was unclickable.
Bumped both guest modals to z-[60].

Changes:

- MasonryGalleryLayout.tsx
  - MasonryPhotoProps gains `liked?: boolean` + `onLikeSuccess?: () => void`.
  - Like button: red bg + filled white Heart icon when liked; aria-label
    toggles between "Like photo"/"Unlike photo"; aria-pressed mirrors state.
  - onClick wires onLikeSuccess() for optimistic UI in both guest-mode
    and simple-mode branches plus the FeedbackIdentityModal onSubmit path.
  - Parent layout holds `likedPhotoIds: Set<number>` and passes it to
    each MasonryPhoto (matches the GridGalleryLayout pattern).

- PhotoLightbox.tsx
  - Consumes useGuestIdentityOptional(); new `isGuestMode` flag.
  - submitLike() and submitRating() get a guest-mode branch that calls
    ensureIdentity() first and submits without body guest_name/email
    (server reads from the verified token).
  - Optimistic UI updates happen after successful submit in guest mode.

- GuestNamePromptModal.tsx, GuestRecoveryModal.tsx
  - z-50 → z-[60] so they render above PhotoLightbox.

Verified end-to-end against local Docker with Playwright MCP on event
168 (Masonry Columns Test layout):

- Fresh session, click Like in Masonry grid → name prompt opens, register,
  feedback persists with guest_id, Heart button turns red with
  aria-pressed and "Unlike photo" label. Subsequent likes on other
  photos also show red state. DB confirms feedback rows.

- Fresh session, open photo in lightbox BEFORE registering → click Like,
  the name prompt correctly opens on top of the lightbox, register,
  feedback persists. Rate 4 stars → works, average 4.0 (1) displayed
  in lightbox, ★ badge appears on toggle-feedback button, grid cell
  shows "1 likes" + "Rating: 4.0" indicators after closing lightbox.

- Backend DB: gallery_guests row created, photo_feedback rows have
  correct guest_id, server reads name from verified token (body values
  ignored).

Out of scope (documented in audit, not reported by the user, no
regression from guest mode): Mosaic/Carousel/Timeline have partial
optimistic-UI issues unrelated to this report; they pre-date guest
mode and behave the same in simple mode. Leaving alone per scope
discipline.
2026-04-11 14:42:28 +02:00
Paul Nothaft 72c0c2d18e Merge pull request #297 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.27.0-beta.0
2026-04-11 09:27:58 +02:00
github-actions[bot] 95d8bc4065 chore(beta): release 3.27.0-beta.0 2026-04-11 06:29:51 +00:00
Paul Nothaft 3856ba25bb Merge pull request #295 from the-luap/feat/guest-selections
feat: guest selections with per-person identity (#292)
2026-04-11 08:27:31 +02:00
Paul Nothaft 9e1ba4f851 Merge pull request #296 from the-luap/release-please--branches--beta
chore(beta): release 3.26.2-beta.0
2026-04-11 08:27:08 +02:00
github-actions[bot] b0efd32f7a chore(beta): release 3.26.2-beta.0 2026-04-11 06:26:39 +00:00
Paul Nothaft 9ed8a2b199 Merge pull request #294 from the-luap/fix/admin-photo-feedback-filters
fix: admin photo feedback filters have no effect (#293)
2026-04-11 08:26:19 +02:00
Paul Nothaft ad4e5a7506 feat: guest selections with per-person identity (#292)
Introduces a new "Per-guest selections" identity mode for event
feedback, letting each visitor register under their own name so their
likes/favorites/comments/ratings are tracked independently. Includes
admin insights (list, per-guest detail, aggregate view, export) and
advanced identity features (forget-me, email recovery, invite tokens,
merge).

New event-level setting
- event_feedback_settings.identity_mode = 'simple' | 'guest' (default
  'simple' → zero behavior change for existing events).
- Admin UI radio under Feedback Settings to toggle per event.

Root cause of the previous "all guests share state" bug
- generateGuestIdentifier() was sha256(ip + userAgent), so every visitor
  on the same WiFi + similar device collided into one identity.
- Now: when a verified guest JWT is present (x-guest-token header),
  req.guest.identifier takes precedence — per-person rate limits and
  per-person deduplication.

Phase 1 — identity layer
- Migration 078: new gallery_guests, guest_invites, guest_verification_
  codes tables; identity_mode column + check constraint; nullable
  guest_id FK on photo_feedback.
- New guest JWT type scoped to (eventId, guestId).
- New middleware guestAuth.resolveGuest (non-blocking) + requireGuest.
- POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me.
- Gallery feedback route enforces guest identity in guest mode and
  reads name/email from the verified token (never from the body).
- Frontend GuestIdentityContext + GuestNamePromptModal; axios
  interceptor injects x-guest-token on gallery API calls.
- Feedback-only blocking: gallery opens freely, prompt only on first
  interactive feedback action.
- Admin "Guests" tab (conditional on identity_mode='guest') with the
  AdminGuestsList component.

Phase 2 — admin insights
- GET /admin/events/:eventId/guests list + aggregated counts.
- GET /admin/events/:eventId/guests/:guestId detail with per-type
  groupings; AdminGuestDetail modal with thumbnail grid + tabs.
- GET /admin/events/:eventId/guests/aggregate sorted by distinct guest
  pick count; GuestSelectionsAggregate component.
- Per-guest export (txt/csv/json) and bulk export-all ZIP.

Phase 3 — polish
- 3.1 Self-service forget-me link in gallery footer.
- 3.2 Email-based identity recovery: POST /guest/recover sends a
  6-digit code via the existing emailProcessor, POST /guest/verify
  exchanges it for a token (rate-limited, enumeration-safe).
- 3.3 Admin invite tokens: pre-mint identities, share URLs with
  ?invite=, single-use redemption stripping the param from history.
- 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources.

Shared helper
- useGalleryFeedbackAction hook wraps the identity-check logic for
  inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/
  Timeline/Premium layouts.

Backwards compatibility
- Existing events default to 'simple' after migration; behavior
  unchanged.
- Legacy photo_feedback rows keep guest_id NULL; admin shows them in
  the generic feedback moderation view as before.
- feedback_count denormalized stat now uses COALESCE(guest_id,
  guest_identifier) so per-guest counts are accurate without touching
  legacy rows.

Verified end-to-end against local Docker
- Migration clean on existing data.
- Simple mode unchanged (no prompt, legacy flow).
- Guest mode: Alice registers on click, tokens persist in
  sessionStorage, feedback rows carry guest_id.
- Carol via invite link auto-redeems, sees Alice's "1 likes" badge.
- Admin Guests tab shows both with correct counts; detail modal
  displays thumbnail grid with badges; aggregate view sorts by picker
  count (photo 227 = 2, others = 1); CSV/JSON export matches DB.
- Merge Carol into Alice: feedback reassigned, Carol soft-deleted,
  Alice count = 4.
2026-04-11 07:48:23 +02:00
Paul Nothaft d4b4dc628f fix: wire admin photo feedback filters into grid query (#293)
The Has Likes / Has Favorites / Has Comments checkboxes in the admin
Event > Photos tab updated local state but never affected the visible
photo grid, because the feedbackFilters state was only wired to the
export menu and the backend /admin/photos/:eventId/photos endpoint had
no support for these params.

Fixes:
- backend/src/routes/adminPhotos.js: extend GET /:eventId/photos to
  accept has_likes, has_favorites, has_comments, min_rating, and logic
  (AND/OR) query params and apply them via where-clause groups using
  the existing denormalized like_count/favorite_count/comment_count/
  average_rating columns.
- frontend/src/services/photos.service.ts: add hasLikes, hasFavorites,
  hasComments, minRating, logic to the PhotoFilters interface and
  append them as query params in getEventPhotos.
- frontend/src/pages/admin/EventDetailsPage.tsx: merge feedbackFilters
  into combinedPhotoFilters (via useMemo) and key the admin-event-photos
  query on it, so toggling any checkbox refetches with the new params.

Verified end-to-end against local Docker: seeded event with a known
feedback distribution and confirmed
- Has Likes → 4 photos
- Has Favorites → 3 photos
- Likes AND Favorites → 1 photo
- Likes OR Favorites → 6 photos
- Has Comments → 2 photos
- network requests carry the exact query params
2026-04-11 07:46:32 +02:00
Paul Nothaft fe46e4268d Merge pull request #288 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.26.1-beta.0
2026-04-09 16:40:53 +02:00
github-actions[bot] 1f3b9c6712 chore(beta): release 3.26.1-beta.0 2026-04-09 14:27:12 +00:00
Paul Nothaft 5295516b67 Merge pull request #290 from the-luap/docs/fix-filesystem-gallery-docs
docs: clarify file system photo import requires existing event (#269)
2026-04-09 16:26:51 +02:00
Paul Nothaft ee0baafc59 docs: clarify file system photo import requires existing event (#269)
The "Method 2: File System" section in SIMPLE_SETUP.md implied you
could create a gallery by just copying files to the storage directory.
In reality, the event must exist in the database first — the file
watcher only adds photos to existing events.

Rewritten to clarify the prerequisite and explain how the file watcher
works (2s stability delay, supported formats, auto-thumbnailing).
2026-04-09 16:26:39 +02:00
Paul Nothaft c63bc47089 Merge pull request #289 from the-luap/fix/password-change-regular-modal
fix: apply password change fix to regular modal + longer toast delay (#263)
2026-04-09 16:06:13 +02:00
Paul Nothaft 147dc28440 fix: apply password change redirect fix to regular modal too (#263)
The redirect loop fix only covered MandatoryPasswordChangeModal.
The regular PasswordChangeModal (profile settings) had the same
issue — onSuccess updated React state but didn't handle the new
JWT cookie, causing the same redirect loop.

Also increase redirect delay from 500ms to 2000ms in both modals
so the success toast is visible before the page reloads.
2026-04-09 16:05:53 +02:00
Paul Nothaft c031b1e863 Merge pull request #287 from the-luap/fix/password-change-iat-timing
fix: resolve JWT iat timing issue in password change (#263)
2026-04-09 16:00:25 +02:00
Paul Nothaft b1d16670d5 fix: set JWT iat after password_changed_at to prevent token rejection (#263)
The new token issued after password change had iat (integer seconds)
that was <= password_changed_at (millisecond precision), causing the
auth middleware's "iat < passwordChangedTime" check to reject it
immediately. Set iat explicitly to 1 second after password_changed_at.

E2E tested: login → mandatory password change → dashboard loads
successfully with no redirect loop and no 401 errors.
2026-04-09 16:00:03 +02:00
Paul Nothaft ba1f010166 Merge pull request #285 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.26.0-beta.0
2026-04-09 15:40:18 +02:00
github-actions[bot] ad64005a80 chore(beta): release 3.26.0-beta.0 2026-04-09 13:11:41 +00:00
Paul Nothaft 8805fa53e6 Merge pull request #286 from the-luap/feat/photo-sort-by-capture-date
feat: sort photos by capture date with configurable default sort (#283)
2026-04-09 15:11:19 +02:00
Paul Nothaft 633d4a0f30 feat: sort photos by capture date with configurable default sort (#283)
Add per-event default photo sort setting with 6 options:
- Upload Date (Newest/Oldest First)
- Date Taken (Newest/Oldest First) — uses EXIF captured_at
- Filename (A-Z / Z-A)

Backend:
- Migration 077 adds default_photo_sort column to events table
- Event create/update handlers accept and validate the setting
- Gallery info endpoint returns default_photo_sort for frontend

Frontend:
- "Date Taken" added to gallery sort dropdown (alongside Date, Name,
  Size, Rating)
- Gallery initializes with event's default sort instead of hardcoded
  "date"
- "Default Photo Sort" dropdown in event create and edit forms
- Photos without EXIF dates fall back to upload date

i18n: All 5 locales (EN, DE, NL, PT, RU) updated with sort labels.

Closes #283
2026-04-09 15:10:51 +02:00
Paul Nothaft b23c51b386 Merge pull request #284 from the-luap/fix/password-change-loop-and-filewatcher
fix: resolve password change redirect loop (#263) and file watcher crash (#269)
2026-04-09 13:54:54 +02:00
Paul Nothaft 835bdf5abb fix: resolve password change redirect loop and file watcher crash
#263: The mandatory password change modal updated React state before
the browser stored the new JWT cookie, causing a race condition where
the auth context checked the session with the old (invalidated) token.
Replace the state update with a full page redirect to /admin/dashboard
after a brief delay, ensuring the new cookie is applied cleanly.

#269: The file watcher service imported isVideoMimeType from
fileSecurityUtils where it doesn't exist. The function is exported
from videoProcessor. Fix the import path.

Closes #269
2026-04-09 13:54:23 +02:00
Paul Nothaft a1b63de251 Merge pull request #279 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.25.0-beta.0
2026-04-08 12:03:29 +02:00
github-actions[bot] 97b1ae5b03 chore(beta): release 3.25.0-beta.0 2026-04-08 09:51:27 +00:00
Paul Nothaft dc98206737 Merge pull request #278 from the-luap/feat/draft-mode-branding-improvements
feat: draft mode, admin branding, and workflow improvements
2026-04-08 11:51:07 +02:00
Paul Nothaft 40332a71db feat: draft mode, admin branding, and workflow improvements
Draft Mode:
- Events are created as drafts by default — no email sent until published
- Add "Publish & Notify Client" button with confirmation dialog
- Draft banner with yellow styling on event details page
- Draft filter tab in events list
- Gallery middleware blocks public access to draft events
- Migration 076 adds is_draft column to events table

Admin Draft Preview:
- Admins can preview draft galleries via JWT preview token (?preview=)
- "View Gallery" link on drafts auto-appends preview token

Admin & Login Page Branding:
- Admin header uses configured company logo/name from branding settings
- Login page shows configured logo instead of hardcoded PicPeak
- Respects logo_display_mode (logo_only, text_only, logo_and_text)

OG Tag Branding:
- DynamicFavicon component updates OG meta tags and page title from
  branding settings

Editable Client Email:
- Customer email is now editable after event creation in edit mode

Branding Inheritance:
- New events inherit hero logo settings (visibility, size, position)
  from global branding configuration

Share Link Full Domain URL:
- New getFrontendBaseUrl() utility with DB fallback to general_site_url
- Used in email processor and share link service
2026-04-08 11:42:38 +02:00
Paul Nothaft 125cd0d003 Merge pull request #274 from the-luap/security/fix-dep-vulnerabilities
security: fix 20 dependency vulnerabilities
2026-04-08 09:04:19 +02:00
Paul Nothaft 83868ffe2f security: fix 20 dependency vulnerabilities (11 error, 7 warning, 2 note)
Update direct dependencies and overrides to address GitHub code scanning alerts:

- handlebars 4.7.8 -> 4.7.9 (5 CVEs: RCE, DoS, XSS, code execution)
- nodemailer 7.0.12 -> 7.0.13 (SMTP command injection)
- tar 7.5.11 -> 7.5.13 override (symlink/hardlink path traversal)
- fast-xml-parser >=5.3.8 -> >=5.5.10 override (entity expansion bypass)
- brace-expansion >=5.0.0 -> >=5.0.5 override (DoS via zero step)
- path-to-regexp 0.1.12 -> 0.1.13 override (ReDoS via malformed URL params)
- lodash 4.17.23 -> >=4.18.1 override (prototype pollution, code execution)

The picomatch CVEs are in npm's own node_modules inside the Docker image
and do not affect application code.
2026-04-08 09:04:06 +02:00
Paul Nothaft 9ddd50f7e4 Merge pull request #266 from the-luap/security/pin-axios-version
security: pin axios to 1.14.0 — supply chain attack prevention
2026-04-05 18:40:47 +02:00
Paul Nothaft bec36fc99f security: pin axios to 1.14.0 to prevent supply chain attack
Axios versions 1.14.1 and 0.30.4 were compromised on March 31, 2026
with a RAT dropper (plain-crypto-js) attributed to North Korean threat
actor UNC1069/Sapphire Sleet. The malicious versions have been removed
from npm but our ^1.12.2 range could have pulled 1.14.1 on next install.

Pin to exact version 1.14.0 (latest safe release) in both frontend and
backend package.json and lock files to prevent any future resolution to
compromised versions.

References:
- https://github.com/axios/axios/issues/10604
- https://snyk.io/blog/axios-npm-package-compromised-supply-chain-attack-delivers-cross-platform/
2026-04-05 18:40:24 +02:00
Paul Nothaft ea50488e99 Merge pull request #265 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.24.1-beta.0
2026-04-05 18:36:13 +02:00
github-actions[bot] 8614c2232c chore(beta): release 3.24.1-beta.0 2026-04-05 16:34:26 +00:00
Paul Nothaft 07fc5e6519 Merge pull request #264 from the-luap/fix/password-change-redirect-loop
fix: resolve redirect loop after mandatory password change (#263)
2026-04-05 18:34:07 +02:00
Paul Nothaft 3c8d344ddd fix: resolve redirect loop after mandatory password change (#263)
After changing password, the backend sets password_changed_at which
invalidates the old JWT token. But the frontend still holds the old
token in the HttpOnly cookie, so the next session check returns 401,
triggering an infinite redirect loop between /admin/login and
/admin/dashboard.

Fix: issue a new JWT token cookie after successful password change
so the session remains valid without requiring re-login.
2026-04-05 18:33:46 +02:00
Paul Nothaft edf8bd54af Merge pull request #262 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.24.0-beta.0
2026-04-05 18:24:36 +02:00
github-actions[bot] 2b7c9b0138 chore(beta): release 3.24.0-beta.0 2026-04-04 21:33:29 +00:00
Paul Nothaft aef9b4ed7f Merge pull request #261 from the-luap/feat/beta-theme-thumbnail-warning
feat: warn about low thumbnail resolution with beta themes
2026-04-04 23:33:12 +02:00
Paul Nothaft ee3f6ae13b feat: warn about low thumbnail resolution when selecting beta themes
Beta themes (Gallery Premium, Gallery Story) display thumbnails at
400-800px, but the default thumbnail size is 300x300px, causing visible
pixelation. Show an amber warning banner with a link to Thumbnail
Settings when a beta layout is active and thumbnails are below 500px.

Warning appears both in the preset selector and the layout selector
sections of the theme customizer.
2026-04-04 23:32:49 +02:00
Paul Nothaft 5025a42bf7 Merge pull request #259 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.23.0-beta.0
2026-04-04 22:28:44 +02:00
github-actions[bot] 0a7a89045b chore(beta): release 3.23.0-beta.0 2026-04-04 20:13:49 +00:00
Paul Nothaft ddefd3a95e Merge pull request #260 from the-luap/fix/backend-dockerfile-npm-version
fix: pin npm to v10 in backend Dockerfile
2026-04-04 22:13:32 +02:00
Paul Nothaft 978e4473b5 fix: pin npm upgrade to v10 in backend Dockerfile
npm@latest resolves to v11 which has a broken promise-retry dependency
on Node 22 Alpine, causing Docker builds to fail. Pin to npm@10 which
stays compatible with the Node 22 base image.
2026-04-04 22:13:11 +02:00
Paul Nothaft 8c5996e4ec Merge pull request #258 from the-luap/feat/email-template-translations
feat: multilingual email templates with translations table
2026-04-04 17:43:29 +02:00
Paul Nothaft f50d7c0c51 feat: multilingual email templates with translations table
Replace column-based email template languages (subject_en/subject_de) with
a normalized email_template_translations table where each language is a row.
This allows adding new languages without schema changes.

- Add migration 075 to create email_template_translations table, migrate
  existing EN/DE data, and seed NL/PT/RU for customer-facing templates
- Update processTemplate() to query translations table with fallback chain
  (requested lang -> en -> first available), with legacy column fallback
- Restructure admin email API to return/accept translations object format
- Update frontend EmailConfigPage with dynamic 5-language tabs, translation
  count badges, and copy-from-language feature for empty translations
- Add Dutch to default language dropdown in general settings
- Add Dutch to clientAccessI18n and password security messages in emails
- Expand email domain detection for NL/BE/BR/PT/RU domains
- Add email i18n keys (copyFrom, noTranslation, etc.) across all 5 locales
2026-04-04 17:43:01 +02:00
Paul Nothaft 4ce8dd297a Merge pull request #257 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.22.0-beta.0
2026-03-26 08:04:59 +01:00
github-actions[bot] 85a4eb90fd chore(beta): release 3.22.0-beta.0 2026-03-25 21:36:31 +00:00
Paul Nothaft e32da68cbd Merge pull request #256 from the-luap/feat/add-dutch-locale
feat: add Dutch locale and fix missing translation keys
2026-03-25 22:36:15 +01:00
Paul Nothaft b54a80d251 feat: add Dutch (nl) locale and fix missing translation keys across all locales
Add complete Dutch translation (2054 keys) with Netherlands flag in the
language selector. Also synchronize all existing locales so every language
has the same set of keys: added 29 missing keys to EN/RU/PT and 95 missing
keys to DE (moderation, analytics, CSS templates, backup, events).
2026-03-25 22:35:57 +01:00
github-actions[bot] 2ac6c51fe5 chore(beta): release 3.21.1-beta.0 (#255)
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-22 12:43:55 +01:00
Paul Nothaft 23cd9cb680 fix: address Shannon security assessment findings (37 vulnerabilities) (#254)
Remediate 4 Critical, 18 High, 10 Medium, and 5 Low vulnerabilities
identified in the Shannon security assessment (2026-03-20).

Critical fixes:
- Command injection via rsync SSH key path (INJ-VULN-01)
- Self-escalation to super_admin role (AUTHZ-VULN-11)
- Invite super_admin backdoor (AUTHZ-VULN-12)
- Handlebars SSTI in email templates (INJ-VULN-05)

Authentication hardening:
- Rate limit on share-link login (AUTH-VULN-01)
- X-Forwarded-For spoofing bypass (AUTH-VULN-02)
- reCAPTCHA fails closed when misconfigured (AUTH-VULN-03)
- Token revocation on admin/gallery logout (AUTH-VULN-04/05)
- Cookie Secure flag defaults true in production (AUTH-VULN-06)
- Remove JWT from admin login response body (AUTH-VULN-07)
- Timing-safe gallery slug validation (AUTH-VULN-09)
- Account lockout fails closed on DB error (AUTH-VULN-12)
- Session endpoint checks token revocation

Path traversal & file access:
- checksums endpoint path containment (INJ-VULN-03)
- manifest validate path containment (INJ-VULN-04)

XSS prevention:
- Block SVG data URIs in CSS sanitizer (XSS-VULN-01)
- Email preview iframe sandbox (XSS-VULN-02)
- SSR branding HTML escaping (XSS-VULN-03)
- User-Agent sanitization in feedback (XSS-VULN-04)

Authorization (IDOR):
- Event ownership middleware for all admin routes
- Cross-admin user profile read restriction (AUTHZ-VULN-10)

SSRF & infrastructure:
- Private IP validation for SMTP, S3, rsync hosts
- Replace inline JWT with standard adminAuth middleware
- CSRF Content-Type enforcement on mutating API endpoints
- CSP headers in nginx location blocks

Token revocation fix:
- Remove overly broad orWhere clause that invalidated all future tokens
- Allow empty-body POST requests (logout) in CSRF middleware

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-22 12:40:01 +01:00
github-actions[bot] a63f1a8dd9 chore(beta): release 3.21.0-beta.0 (#253)
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-18 10:59:14 +01:00
Paul Nothaft 954a0118ba fix: wrap test email with standard email template (#252)
Use wrapEmailHtml() for the test email so it matches the look of all
other emails sent by the platform (logo, footer, etc.).

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-18 10:49:45 +01:00
Paul Nothaft ee46088985 feat: add per-gallery thumbnail scale setting (#172) (#251)
Add a thumbnailScale field (xs/sm/md/lg/xl) to gallery layout settings
that adjusts column counts for Grid, Masonry (columns mode), and Mosaic
layouts. Each scale maps to a column offset applied on top of the
layout's base columns, letting photographers control photo density.

- Add thumbnailScale to GalleryLayoutSettings type
- Apply scale offset in Grid, Masonry, and Mosaic layout components
- Add thumbnail scale dropdown to admin theme customizer
- Conditionally show dropdown only for applicable layouts
- Safelist dynamic grid-cols classes in Tailwind config
- Add i18n keys for EN, DE, PT, RU locales

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-18 10:44:07 +01:00
github-actions[bot] 3742d71535 chore(beta): release 3.20.1-beta.0 (#250)
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-17 17:18:56 +01:00
Paul Nothaft 486239aeb9 fix: address beta feedback - gallery layout fixes, Russian locale, email logo (#249)
- Add Russian (Русский) to admin settings language dropdown
- Fix Premium layout hero using thumbnail instead of hero_url
- Hide "Uncategorized" section header in Story layout for uncategorized photos
- Add PhotoLightbox to Story layout so photo clicks open full-screen view
- Use full-res images in StoryPhotoCard instead of thumbnails
- Defer public gallery auto-login text until settings/locale are loaded
- Add validation and debug logging for email logo URL construction

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-17 17:16:50 +01:00
Paul Nothaft 2c5ae6fbb9 Merge pull request #248 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.20.0-beta.0
2026-03-17 13:14:43 +01:00
github-actions[bot] f9889a93fb chore(beta): release 3.20.0-beta.0 2026-03-17 12:05:29 +00:00
Paul Nothaft 4a93e4e8cb Merge pull request #247 from the-luap/feat/photo-visibility-client-access
feat: photo visibility control with client access (#172)
2026-03-17 13:05:12 +01:00
Paul Nothaft e1b6e43e52 feat: photo visibility control with client access (#172)
Add two-tier gallery access system allowing clients (e.g., wedding couples) to
review and hide photos before the gallery is shared with guests.

Backend:
- Migration 074: add visibility column to photos, client_access_enabled/
  client_password_hash/client_share_token to events
- Client login endpoint (POST /auth/gallery/:slug/client-login) with bcrypt PIN
- Gallery photo list filters hidden photos for guests, shows all for clients
- Visibility toggle endpoints (single + bulk) for client access level
- Admin event CRUD supports client access fields
- Email template includes client access link + PIN (EN/DE/RU/PT)

Frontend:
- ClientAccessPage: PIN entry form at /gallery/:slug/client-access
- GalleryView: client mode banner, visibility counter, toggle controls
- GridGalleryLayout: eye/eye-off overlay per photo for clients
- AdminPhotoGrid: visibility badge, bulk Hide/Show buttons
- EventDetailsPage: Client Access settings section (toggle, PIN, link)
- CreateEventPage: client access toggle + PIN in event creation form
- GalleryAuthContext: accessLevel/isClient/clientLogin support
- New complete pt-BR locale (pt.json) with all translations
- Client access i18n keys for EN, DE, RU, PT
2026-03-17 13:04:41 +01:00
Paul Nothaft 999c66dbbf Merge pull request #244 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.19.2-beta.0
2026-03-16 22:37:45 +01:00
github-actions[bot] f5997892c4 chore(beta): release 3.19.2-beta.0 2026-03-16 21:35:09 +00:00
Paul Nothaft 7ca96315e2 Merge pull request #243 from the-luap/fix/security-session-invalidation
fix(security): token invalidation on password change, session timeout enforcement
2026-03-16 22:34:53 +01:00
Paul Nothaft f3622396e7 fix(security): invalidate tokens on password change, enforce session timeout, fix role update
- Set password_changed_at when changing password via adminAuth route so
  existing JWT tokens are rejected by the auth middleware check
- Enforce session timeout on first request with unseen tokens by checking
  token iat against configured timeout (prevents bypass after server restart)
- Convert camelCase roleId/isActive to snake_case role_id/is_active in
  frontend updateUser service (fixes silent role update failures)

Resolves GHSA-rqg3-47p5-vgwg
2026-03-16 22:34:32 +01:00
Paul Nothaft 56cf60c570 Merge pull request #242 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.19.1-beta.0
2026-03-16 22:32:17 +01:00
github-actions[bot] 2618415aa1 chore(beta): release 3.19.1-beta.0 2026-03-16 21:23:14 +00:00
Paul Nothaft dfae2c2bc6 Merge pull request #241 from the-luap/fix/external-media-dimensions-and-email-colors
fix: external media dimensions, theme race condition, email color customization
2026-03-16 22:22:58 +01:00
Paul Nothaft bbeedd1888 fix: resolve external media dimensions, gallery theme race condition, and add email color customization
- Fix dimension repair for external media by using photoResolver instead of hardcoded paths
- Extract photo dimensions via Sharp during external media import
- Remove duplicate theme useEffect from GalleryView to prevent flash/revert race condition
- Pass event welcome_message to Story layout footer for per-event customization
- Add email_primary_color/email_secondary_color settings with admin UI color pickers
- Add i18n keys for email branding in all 4 locales (en, de, ru, pt)
2026-03-16 22:22:36 +01:00
Paul Nothaft 201965b4b1 Merge pull request #240 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.19.0-beta.0
2026-03-16 20:25:20 +01:00
github-actions[bot] 1468c459ba chore(beta): release 3.19.0-beta.0 2026-03-16 16:24:01 +00:00
Paul Nothaft 088de43f09 Merge pull request #239 from the-luap/feat/photo-cap-and-portuguese-locale
feat: add photo cap per event and Portuguese locale
2026-03-16 17:23:37 +01:00
Paul Nothaft 1fa222e9c4 feat: add photo cap per event and Portuguese (pt-BR) locale
- Add photo_cap column to events table (migration 074) to limit photos per event
- Enforce photo cap in upload route, returning 400 when limit exceeded
- Pass photo_cap through all event CRUD routes and frontend forms
- Add complete Portuguese (pt-BR) translation (2300+ strings)
- Register pt locale in i18n config, language selector, date formatting
- Add photoCap/photoCapHelp translation keys to all locale files (en, de, ru, pt)
2026-03-16 17:22:54 +01:00
Paul Nothaft 6aceb40595 Merge pull request #238 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.18.2-beta.0
2026-03-16 16:26:55 +01:00
github-actions[bot] 431a82eca1 chore(beta): release 3.18.2-beta.0 2026-03-16 15:26:11 +00:00
Paul Nothaft 85a07fcca7 Merge pull request #237 from the-luap/fix/security-dep-updates
fix: resolve code scanning security alerts (multer, tar, Node 22)
2026-03-16 16:25:52 +01:00
Paul Nothaft 1f524f2358 fix: update dependencies to resolve code scanning security alerts
- Upgrade multer to 2.1.1 (CVE-2026-3520, DoS via malformed requests)
- Update tar override to >=7.5.11 (CVE-2026-31802, CVE-2026-29786)
- Upgrade Node base image from 20-alpine to 22-alpine to fix npm
  bundled tar/minimatch CVEs in the Docker image
2026-03-16 16:25:29 +01:00
Paul Nothaft 48a025b915 Merge pull request #236 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.18.1-beta.0
2026-03-16 15:02:04 +01:00
github-actions[bot] c652ae0ead chore(beta): release 3.18.1-beta.0 2026-03-16 14:01:13 +00:00
Paul Nothaft 9a6d2e8e3a Merge pull request #235 from the-luap/fix/email-preview-wrapper
fix: wrap email preview with full styled header/footer template
2026-03-16 15:00:56 +01:00
Paul Nothaft fc0911acf8 fix: wrap email preview with full styled header/footer template
The email template preview modal was showing only raw body HTML without
the styled wrapper (green header bar, logo, footer with company name)
that processTemplate() applies when sending. This made preview not match
what recipients actually receive.

Extract wrapEmailHtml() from processTemplate() and reuse it in the
preview endpoint. Also fix logo URL to use FRONTEND_URL consistently.

Closes #229
2026-03-16 15:00:37 +01:00
Paul Nothaft f77802325a Merge pull request #234 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.18.0-beta.0
2026-03-16 10:22:14 +01:00
github-actions[bot] 74c9a5fbcd chore(beta): release 3.18.0-beta.0 2026-03-16 08:38:17 +00:00
Paul Nothaft 703c03fbee Merge pull request #233 from the-luap/feat/visual-email-editor
feat: visual WYSIWYG email template editor
2026-03-16 09:37:57 +01:00
Paul Nothaft 6f95b8c26c feat: register Russian locale and add to language selector
Import ru.json translations in i18n config and add Russian with flag
to the language selector dropdown.
2026-03-16 09:35:03 +01:00
Paul Nothaft 7250c427b9 fix: shorten Save button label on email template editor
Change "Save Changes" to "Save" for cleaner toolbar layout.
2026-03-16 09:29:24 +01:00
Paul Nothaft 04a7ea80f9 feat: add visual WYSIWYG email template editor (#229)
Replace raw HTML textarea with TipTap-based rich text editor for email
templates. Includes formatting toolbar, variable insertion dropdown,
source/visual toggle, and dark mode support. Add Mailhog service to
docker-compose for local email testing.
2026-03-15 22:05:27 +01:00
Paul Nothaft c0a5cd56c8 Merge pull request #232 from the-luap/i18n/ru-missing-keys
i18n: add missing Russian translations for thumbnails and photo dimensions
2026-03-15 20:12:22 +01:00
Paul Nothaft 908ab08815 Merge beta to resolve conflicts for PR #232 2026-03-15 20:02:27 +01:00
Paul Nothaft 52ab609597 i18n: add missing Russian translations for thumbnails and photo dimensions
Adds 38 missing keys for settings.thumbnails and settings.photoDimensions
that were added after the initial Russian localization PR (#216).
2026-03-15 19:48:41 +01:00
Paul Nothaft fafcfbf4e6 Merge pull request #216 from Ih0rd/russian-localization
basic Russian localization
2026-03-15 19:47:25 +01:00
Paul Nothaft f07602553c Merge pull request #228 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.17.2-beta.0
2026-03-11 21:54:58 +01:00
Paul Nothaft 56f497c5f1 Merge pull request #227 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.6.1
2026-03-11 21:54:39 +01:00
github-actions[bot] d2663bff81 chore(beta): release 3.17.2-beta.0 2026-03-11 19:48:06 +00:00
github-actions[bot] b52cf1f741 chore(main): release 2.6.1 2026-03-11 19:48:05 +00:00
Paul Nothaft 308e086263 Merge pull request #226 from the-luap/fix/security-reporting-policy
fix: update security policy with private reporting channels
2026-03-11 20:47:51 +01:00
Paul Nothaft 7f7736282f Merge pull request #225 from the-luap/fix/security-reporting-policy
fix: update security policy with private reporting channels
2026-03-11 20:47:41 +01:00
Paul Nothaft 67b0f32456 fix: update security policy with proper contact email and private reporting
- Replace placeholder security@example.com with info@picpeak.app
- Add GitHub Private Vulnerability Reporting links
- Update supported versions table to 2.x.x

Closes #223
2026-03-11 20:21:32 +01:00
Paul Nothaft 25b40c03b0 Merge pull request #224 from the-luap/release/beta-to-main
Merge beta into main
2026-03-11 20:19:10 +01:00
Paul Nothaft 28793bba68 Merge main into beta for release/beta-to-main 2026-03-11 20:12:52 +01:00
Paul Nothaft 4ae91142f8 Merge pull request #222 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.6.0
2026-03-11 12:01:51 +01:00
github-actions[bot] c92879fbd3 chore(main): release 2.6.0 2026-03-11 10:57:06 +00:00
Paul Nothaft a0bb080586 Merge pull request #221 from the-luap/fix/video-upload-select-all-dimensions
fix: video upload, select all, and dimension repair (#203, #220, #180)
2026-03-11 11:56:38 +01:00
Paul Nothaft fc75bcdfc3 fix: video upload media type, select all, and dimension repair (#203, #220, #180)
- Fix admin video upload missing media_type/mime_type and video processing (#203)
- Fix Gallery-Premium Select All using atomic callbacks instead of stale closure loop (#220)
- Add photo dimension repair endpoint and admin UI (#180)
- Add E2E tests for all three fixes
2026-03-11 11:50:43 +01:00
Paul Nothaft 9877f63aed Merge pull request #219 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.17.1-beta.0
2026-03-08 15:47:06 +01:00
github-actions[bot] 0c98c6b453 chore(beta): release 3.17.1-beta.0 2026-03-08 14:42:28 +00:00
Paul Nothaft 831ea6a3bc Merge pull request #218 from the-luap/fix/optional-email-event-creation
fix: respect optional email settings in event creation
2026-03-08 15:42:14 +01:00
Paul Nothaft 9c44a0ebfa fix: respect optional email settings in event creation (#217)
When admin/customer emails were configured as optional in Settings >
Event Creation, the backend still rejected empty values because:

1. express-validator .optional() only skips undefined, not empty strings
   — changed to .optional({ values: 'falsy' }) so "" is treated as
   absent
2. DB columns host_email and admin_email had NOT NULL constraints
   — added migration to make them nullable
3. Email queue insert crashed on null recipient_email
   — skip queuing when no customer email is provided
2026-03-08 15:36:38 +01:00
Ih0rd a840ad4594 basic Russian localization 2026-03-06 06:17:30 +03:00
Paul Nothaft 08ac238d0a Merge pull request #215 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.17.0-beta.0
2026-03-05 22:22:58 +01:00
github-actions[bot] 7d967a47ae chore(beta): release 3.17.0-beta.0 2026-03-05 21:21:48 +00:00
Paul Nothaft 9b7495e005 Merge pull request #214 from the-luap/feat/configurable-upload-batch-size
feat: configurable upload batch size for reverse proxy compatibility
2026-03-05 22:21:29 +01:00
Paul Nothaft e1ad4219a5 Merge pull request #212 from the-luap/revert-210-feat/configurable-upload-batch-size
Revert "feat: configurable upload batch size for reverse proxy compatibility"
2026-03-05 22:16:43 +01:00
Paul Nothaft cc4503ad28 Revert "feat: configurable upload batch size for reverse proxy compatibility" 2026-03-05 22:16:28 +01:00
Paul Nothaft 424336340b Merge pull request #210 from the-luap/feat/configurable-upload-batch-size
feat: configurable upload batch size for reverse proxy compatibility
2026-03-05 22:14:41 +01:00
Paul Nothaft a8308a5c02 Merge pull request #209 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.16.0-beta.0
2026-03-05 22:14:28 +01:00
Paul Nothaft 02a46e083d feat: add configurable upload batch size for reverse proxy compatibility (#208)
Users behind Cloudflare Tunnel and other reverse proxies cannot upload
batches >100MB. The upload chunking previously used a hardcoded 500MB
limit. This adds a configurable `max_upload_batch_size_mb` setting
(default 95MB) to the admin General settings, leaving headroom below
Cloudflare's 100MB limit.
2026-03-05 22:12:37 +01:00
github-actions[bot] 98fd6dd8e1 chore(beta): release 3.16.0-beta.0 2026-03-05 20:38:56 +00:00
Paul Nothaft 3a30fea862 Merge pull request #207 from the-luap/fix/github-issues-194-197-main
feat: add thumbnail settings UI to admin panel
2026-03-05 21:38:41 +01:00
Paul Nothaft 7d6d2f5688 feat: add thumbnail settings UI to admin settings page (#206)
Add a new "Thumbnails" tab in the admin settings page allowing users to
configure thumbnail dimensions, quality, format, and fit mode from the UI.
Also fix backend route column name mismatch (key/value → setting_key/setting_value)
that caused a 500 error, and add a button to regenerate all thumbnails.
2026-03-04 22:55:14 +01:00
Paul Nothaft b5074e4e46 Merge pull request #205 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.3-beta.0
2026-03-02 23:18:27 +01:00
github-actions[bot] a1d941f049 chore(beta): release 3.15.3-beta.0 2026-03-02 22:18:06 +00:00
Paul Nothaft 80171713e0 Merge pull request #204 from the-luap/fix/github-issues-194-197-main
fix: issue #203 file type validation + security CVE fixes
2026-03-02 23:17:50 +01:00
Paul Nothaft c0301dcbf4 Merge branch 'beta' into fix/github-issues-194-197-main 2026-03-02 23:15:37 +01:00
Paul Nothaft cbecb9323c fix(security): resolve Docker image CVEs for code scanning alerts
- Upgrade nginx base from 1.27-alpine to 1.28-alpine (Alpine 3.23, OpenSSL 3.5.5)
- Upgrade npm to latest in backend production stage to fix tar, minimatch, brace-expansion CVEs
- Add brace-expansion and minimatch overrides for app-level transitive deps
- Remove incompatible body-parser v2 override (breaks Express 4 JSON parsing)
- Remove npm upgrade from builder stages (npm 11 breaks npm ci with existing lockfile)
2026-03-02 23:06:15 +01:00
Paul Nothaft 4272618b3f fix(security): resolve all npm audit vulnerabilities
Frontend (6 → 0 vulnerabilities):
- axios: update to fix DoS via __proto__ key in mergeConfig (CVE-2026-25639)
- swiper: update to fix prototype pollution (critical)
- rollup: update to fix arbitrary file write via path traversal
- minimatch: update to fix multiple ReDoS vulnerabilities
- ajv: update to fix ReDoS with $data option
- markdown-it: update to fix ReDoS

Backend (32 → 0 vulnerabilities):
- multer: update to fix DoS via incomplete cleanup and resource exhaustion
- minimatch: update to fix multiple ReDoS vulnerabilities
- Add npm overrides for transitive dependencies:
  - fast-xml-parser >=5.3.8 (fixes XSS, DoS, stack overflow via AWS SDK)
  - qs >=6.14.2 (fixes arrayLimit bypass DoS via Express)
  - tar >=7.5.8 (fixes path traversal and hardlink attacks via sqlite3)

Docker:
- Pin nginx base image to 1.27-alpine in Dockerfile.prod
- Update security comments in backend Dockerfile
- Existing apk upgrade --no-cache ensures OpenSSL/libexpat CVEs are
  patched at build time (OpenSSL 3.5.5, Alpine 3.23.3)
2026-03-02 10:36:47 +01:00
Paul Nothaft fe07a148f1 fix: respect allowed_file_types setting for upload validation (#203)
The "Allowed File Types" admin setting was stored in the database but
never actually read during upload validation. Both frontend and backend
used hardcoded MIME type lists, causing video uploads (e.g. MP4) to be
rejected even when explicitly added to the setting.

Changes:
- Add getAllowedMimeTypes() to uploadSettings service that reads the
  general_allowed_file_types DB setting and converts extensions to MIME types
- Backend admin upload route now resolves allowed types from settings
  before multer processes files (via resolveAllowedTypes middleware)
- Backend gallery upload route uses dynamic allowed types from settings
- Expose allowed_file_types in public settings API for gallery clients
- Frontend PhotoUpload and UserPhotoUpload components now derive allowed
  MIME types from settings instead of hardcoded image-only lists
- Add shared fileTypes.ts utility for extension-to-MIME conversion

Closes #203
2026-03-01 14:36:34 +01:00
Paul Nothaft 0ec4190e2e Merge pull request #201 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.5.1
2026-02-23 20:10:21 +01:00
Paul Nothaft 0ec3787150 Merge pull request #200 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.2-beta.0
2026-02-23 20:10:12 +01:00
github-actions[bot] 59faf73f04 chore(main): release 2.5.1 2026-02-22 21:37:40 +00:00
github-actions[bot] 3e0c4fd73e chore(beta): release 3.15.2-beta.0 2026-02-22 21:37:26 +00:00
Paul Nothaft 33af088560 Merge pull request #199 from the-luap/fix/github-issues-194-197-main
fix: resolve issues #194, #195, #196, #197
2026-02-22 22:37:21 +01:00
Paul Nothaft 5ea4ef3cf3 Merge pull request #198 from the-luap/fix/github-issues-194-197
fix: resolve issues #194, #195, #196, #197
2026-02-22 22:37:11 +01:00
Paul Nothaft 33483cf32d fix: resolve issues #194, #195, #196, #197
- #194: Send full date format object instead of just format string to prevent JSON parse errors
- #195: Remove non-functional forgot password link, fix README port 3005 -> 3000
- #196: Use ADMIN_PASSWORD env var in migration, update existing user in create-admin script instead of failing
- #197: Convert camelCase filter keys to snake_case in photo export to match backend PhotoFilterBuilder
2026-02-22 22:34:44 +01:00
Paul Nothaft cd00bc13d4 fix: resolve issues #194, #195, #196, #197
- #194: Send full date format object instead of just format string to prevent JSON parse errors
- #195: Remove non-functional forgot password link, fix README port 3005 -> 3000
- #196: Use ADMIN_PASSWORD env var in migration, update existing user in create-admin script instead of failing
- #197: Convert camelCase filter keys to snake_case in photo export to match backend PhotoFilterBuilder
2026-02-22 22:27:21 +01:00
Paul Nothaft 26ec9666b9 Merge pull request #193 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.5.0
2026-02-21 20:52:30 +01:00
github-actions[bot] f672c1daa6 chore(main): release 2.5.0 2026-02-21 19:48:43 +00:00
Paul Nothaft 5f1f0f253d Merge pull request #192 from the-luap/release/beta-to-main
Release v3.15.1: Merge beta to main
2026-02-21 20:48:01 +01:00
Paul Nothaft 888c4ab209 Merge main into beta for release/beta-to-main
Resolved conflicts in CHANGELOG.md, backend/package.json, and
frontend/package.json. Version set to 3.15.1.
2026-02-21 20:43:46 +01:00
Paul Nothaft 551d9cc66f Merge pull request #191 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.1-beta.0
2026-02-21 20:32:31 +01:00
github-actions[bot] 9045402c9a chore(beta): release 3.15.1-beta.0 2026-02-21 19:31:37 +00:00
Paul Nothaft 0817443e79 Merge pull request #190 from the-luap/feat/new-features
fix: docker compose v2 syntax and add missing ADMIN_PASSWORD to .env.example (#189)
2026-02-21 20:31:20 +01:00
Paul Nothaft a4c624802b fix: update docker-compose to docker compose and add ADMIN_PASSWORD to .env.example (#189)
- Replace deprecated docker-compose (v1) with docker compose (v2) in README
- Add missing ADMIN_PASSWORD to .env.example so new users don't get a
  blank-string warning and can actually log in after first setup
2026-02-21 08:28:49 +01:00
Paul Nothaft 79cf4100a1 Merge pull request #188 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.0-beta.0
2026-02-17 20:48:38 +01:00
github-actions[bot] fe9486e5fa chore(beta): release 3.15.0-beta.0 2026-02-17 19:47:41 +00:00
Paul Nothaft bcf2745ab6 Merge pull request #187 from the-luap/feat/new-features
feat: original filename in admin UI, update dialog, security hardening, and bug fixes
2026-02-17 20:47:25 +01:00
Paul Nothaft c4f16eb76c fix: events without expiration date incorrectly shown as expired
When expires_at is null (no expiration), the status logic defaulted
days to 0, causing all non-expiring events to display as "Expired".
Now returns "Active" immediately when there is no expiration date.
2026-02-17 15:48:57 +01:00
Paul Nothaft 5925ea8406 Merge pull request #186 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.14.0-beta.0
2026-02-17 15:38:34 +01:00
github-actions[bot] 6613f1b088 chore(beta): release 3.14.0-beta.0 2026-02-17 14:38:14 +00:00
Paul Nothaft 3ea9d5b121 Merge pull request #185 from the-luap/feat/new-features
feat: original filename in admin UI, update dialog, and security hardening
2026-02-17 15:37:56 +01:00
Paul Nothaft 0891be197f feat: show original filename in admin UI (#184)
Surface the existing original_filename from the database in the admin
photo grid hover overlay and photo viewer sidebar, so photographers can
correlate uploaded images with their Lightroom/disk originals. Only shown
when it differs from the system-generated filename. Gallery guests remain
unaffected.
2026-02-17 15:30:12 +01:00
Paul Nothaft 2b25d81144 security: comprehensive hardening across frontend, backend, and infrastructure
- Disable production source maps and hide nginx version
- Reduce JSON body limit from 10gb to 50mb (uploads use multer, not JSON)
- Strip database info and error details from health endpoint
- Mask reCAPTCHA secret key in admin settings API responses
- Whitelist sort/order query parameters in events and photos endpoints
- Stop reflecting arbitrary origins in static file CORS headers
- Align nginx security headers with backend Helmet CSP, remove deprecated X-XSS-Protection
- Strip EXIF metadata from generated thumbnails and hero images
- Bind postgres/redis dev ports to localhost in docker-compose configs
- Add safeExec utility (spawn with shell:false) to prevent command injection
- Convert all exec/execAsync calls in backup, restore, and database backup
  services to use safe spawn-based helpers
2026-02-16 22:33:20 +01:00
Paul Nothaft 50c09904a9 feat: add update instructions dialog, email notifications, and capture date sorting
- Add Update Instructions Dialog with environment-specific commands (Docker/Git/Standalone)
- Add email notification settings for new version alerts
- Add "Sort by Capture Date" option using EXIF metadata extraction
- Fix E2E tests by loading environment variables via dotenv
- Add test-images/ and backend/*.db to .gitignore

Closes #181
2026-02-16 16:23:57 +01:00
Paul Nothaft 7aa37b2447 Merge pull request #183 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.13.1-beta.0
2026-02-15 22:53:50 +01:00
github-actions[bot] d239857d9a chore(beta): release 3.13.1-beta.0 2026-02-15 21:49:34 +00:00
Paul Nothaft 3974ba5de5 Merge pull request #182 from the-luap/feat/new-features
fix: restore aspect-ratio layouts and improve hero image quality (#180)
2026-02-15 22:49:20 +01:00
Paul Nothaft 5cef7fdd18 fix: restore aspect-ratio layouts and improve hero image quality (#180)
- Fix masonry/mosaic layout regression where tiles displayed uniform heights
  instead of respecting image aspect ratios. Changed from fixed 150-500px
  height constraints to dynamic constraints based on column width.

- Add hero image optimization pipeline generating 1920x1080 images for
  full-width hero sections instead of using low-quality thumbnails.

- New /hero/:photoId endpoint serves optimized hero images with watermark
  support and automatic generation/caching.

- Add hero_url field to photos API response for frontend consumption.

- Migration 069 adds hero_path column to photos table.
2026-02-15 22:43:18 +01:00
Paul Nothaft 092f007ed3 Merge pull request #179 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.13.0-beta.0
2026-02-07 00:36:35 +01:00
github-actions[bot] edf3a43950 chore(beta): release 3.13.0-beta.0 2026-02-06 23:35:55 +00:00
Paul Nothaft 45d78c0dce Merge pull request #178 from the-luap/feat/new-features
feat: improve hero image UX and live preview (#163, #158)
2026-02-07 00:35:37 +01:00
Paul Nothaft d63f67a2af feat: improve hero image UX and live preview (#163, #158)
- Update hero photo help text to mention category override capability
- Add hint in category manager about default hero photo fallback
- Add placeholder text in gallery preview for hero section
- Ensure live preview updates correctly for header/divider style changes
2026-02-07 00:29:25 +01:00
Paul Nothaft ad00eae251 Merge pull request #177 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.12.0-beta.0
2026-02-06 23:31:17 +01:00
github-actions[bot] 2c35543e73 chore(beta): release 3.12.0-beta.0 2026-02-06 22:29:57 +00:00
Paul Nothaft 7c75736719 Merge pull request #176 from the-luap/feat/new-features
Feat/new features
2026-02-06 23:29:39 +01:00
Paul Nothaft 9c2a0d272a feat: add admin dark mode and SEO/robots.txt settings
Admin Dark Mode:
- Add AdminDarkModeContext with light/dark/system preference
- Update all admin components with Tailwind dark: classes
- Add dark mode toggle in admin header
- Persist preference in localStorage

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

UI/UX Improvements:
- Consistent dark mode styling across all admin pages
- Update gallery components with themed CSS classes
- Fix input, card, and button styling for dark mode
2026-02-06 23:26:01 +01:00
Paul Nothaft 4912e2bccf fix: improve ghost button visibility in admin dark mode
Update ghost button variant to use proper dark mode colors:
- Add dark:hover:bg-neutral-700 for hover state
- Add dark:text-neutral-300 for better icon/text visibility
- Fixes too-dark edit and view gallery buttons in Events table
2026-02-06 23:23:34 +01:00
Paul Nothaft f8c8abd70b fix: resolve mixed light/dark mode styling in admin UI (#175)
- Update .card class to use explicit Tailwind colors instead of CSS
  variables, preventing gallery theme from affecting admin UI
- Add .card-themed and .input-themed classes for gallery components
  that need to use theme CSS variables
- Add dark mode support to CardHeader and CardFooter components
- Update .input class to use explicit colors for proper light/dark mode
- Update dark mode selectors for consistency (.dark .class)
2026-02-06 23:13:15 +01:00
Paul Nothaft 7726adeff0 Merge pull request #174 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.11.0-beta.0
2026-02-06 21:46:42 +01:00
github-actions[bot] e05fd64760 chore(beta): release 3.11.0-beta.0 2026-02-06 20:46:15 +00:00
Paul Nothaft 4280444d70 Merge pull request #173 from the-luap/feat/new-features
feat: gallery layouts, hero customization, event types, and UX improvements (#146, #155-163, #170, #171)
2026-02-06 21:45:59 +01:00
Paul Nothaft 6491184402 chore: add dependencies for Gallery Premium/Story layouts
Add missing npm packages required for new gallery layouts:
- yet-another-react-lightbox: lightbox component
- framer-motion: animations
- photoswipe: photo gallery
- swiper: carousel/slider
2026-02-06 21:43:09 +01:00
Paul Nothaft 171abb3161 fix: improve password validation errors and event list UX (#170, #171)
- Show specific failing password requirement instead of generic error
  when password validation fails on AcceptInvitePage (#170)
- Add inline Edit and View Gallery buttons to events table (#171)
- Make event table rows clickable to navigate to details (#171)
- Keep context menu for less common actions (Archive, Delete)
- Add responsive design: inline buttons hidden on mobile
2026-02-06 18:38:12 +01:00
Paul Nothaft e179def3cc feat: add Gallery Premium and Gallery Story layouts (Beta)
- Add Gallery Premium layout: elegant light theme with masonry grid,
  hero section, sticky navigation, and integrated lightbox
- Add Gallery Story layout: cinematic dark theme with scene-based
  sections, carousels, and gold accents
- Implement full-page layout support: bypass standard header/footer/
  sidebar for immersive experience
- Add logout button to both layouts for authenticated galleries
- Mark both layouts as (Beta) in theme editor and layout selectors
- Fix hero title color visibility in Gallery Premium layout
2026-02-06 18:03:47 +01:00
Paul Nothaft bc6c48bb24 fix: render minimal/none header styles, cap hero height, switch category hero images (#158, #162, #163)
- Add distinct rendering branches for minimal and none header styles in
  GalleryLayout (grid and non-grid), skipping the colored banner/wave
  divider for both
- Cap hero section height at 700px via max-h to prevent it dominating
  ultra-wide viewports
- Watch selectedCategoryId in GalleryView and swap the hero photo to
  the category's hero_photo_id when filtering, reverting to the event
  default when cleared
- Add minimal/none preview branches in GalleryPreview so the admin
  theme editor shows visually distinct previews for all four styles
- Remove unused AdminPhoto import that was blocking the build
- Add Playwright e2e tests covering all four header styles, hero max
  height, and category hero switching
2026-02-04 08:30:55 +01:00
Paul Nothaft 57845a5508 Merge pull request #167 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-02-03 19:09:07 +01:00
github-actions[bot] 10ff6b118c chore(beta): release 3.10.1-beta.0 2026-02-03 16:25:51 +00:00
Paul Nothaft 2288309395 Merge pull request #166 from the-luap/feat/new-features
fix: sync header_style DB column with theme editor selections (#158)
2026-02-03 17:16:09 +01:00
Paul Nothaft a19e7c40a2 fix: sync header_style DB column with theme editor selections (#158)
The frontend never sent header_style/hero_divider_style as separate
fields when creating or updating events, so the database columns always
kept their default value of 'standard' — making the hero header
impossible to enable through the admin UI.

- Extract headerStyle/heroDividerStyle from theme config and include in
  create and update payloads (CreateEventPage, EventDetailsPage)
- Add backend fallback to extract values from color_theme JSON when not
  explicitly provided, ensuring older clients stay in sync
2026-02-03 17:12:56 +01:00
Paul Nothaft de56cd0dce Merge pull request #165 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.10.0-beta.0
2026-02-03 15:59:42 +01:00
github-actions[bot] 8ddec6ed8b chore(beta): release 3.10.0-beta.0 2026-02-03 14:56:50 +00:00
Paul Nothaft d9e00dc0db Merge pull request #164 from the-luap/feat/new-features
feat: gallery layouts, hero customization, bulk categories & event types
2026-02-03 15:50:54 +01:00
Paul Nothaft 6c30e2c2ed feat: add category hero/cover photo selection (#163)
Wire up the hero_photo_id column on photo_categories that was added in
the migration but never connected. Backend routes now accept and persist
hero_photo_id on category create/update, a dedicated PUT /:id/hero
endpoint is added, and the gallery API returns hero_photo_id for each
category. Frontend EventCategoryManager shows a clickable thumbnail per
category that opens a photo picker modal. Includes EN/DE i18n keys.
2026-02-03 15:43:23 +01:00
Paul Nothaft 329d224846 fix: resolve code quality issues and add missing i18n keys (#162, #163)
Add missing i18n translations for hero image focal point picker in both
EN and DE locales. Fix lint errors across touched files: remove unused
imports/variables, replace raw buttons with shared Button component,
eliminate inline styles, extract duplicated backend validation, and
remove dead heroImagePosition type.
2026-02-03 10:53:13 +01:00
Paul Nothaft 734868abc2 feat: add hero image focal point picker with anchor positioning (#162)
Add interactive focal point picker for hero images, allowing precise
crop positioning via click or preset buttons (top/center/bottom).
Includes backend validation, migrations, and gallery rendering support.
2026-02-03 10:08:58 +01:00
Paul Nothaft f554f463b3 fix: hero header state and preview in admin theme editor (#158)
- Add hero header rendering to GalleryPreview component with divider styles
- Support event-specific header_style prop in GalleryLayout
- Pass header_style from event data to GalleryLayout in GalleryView
- Divider options now properly show/hide when switching header styles

This ensures the live preview accurately reflects hero header changes
and event-specific header styles are respected in the gallery view.
2026-02-02 23:09:36 +01:00
Paul Nothaft fa4c83812d fix: improve photo serving, category filters, and upload chunking (#155, #156, #161)
- Add try-catch and file existence check for photo path resolution (#161)
- Fix gallery categories to use photo_categories table instead of legacy type field (#156)
- Add byte-size-based chunking (500MB max) for uploads to prevent oversized batches (#155)
2026-02-02 22:55:48 +01:00
Paul Nothaft 8cc5685428 Merge pull request #160 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.9.0-beta.0
2026-02-01 22:59:20 +01:00
github-actions[bot] 7bf1e5c0f9 chore(beta): release 3.9.0-beta.0 2026-02-01 21:58:22 +00:00
Paul Nothaft 7037106bff Merge pull request #159 from the-luap/feat/new-features
feat: gallery layouts, bulk category editing, and hero header improvements
2026-02-01 22:58:08 +01:00
Paul Nothaft eca36c70a2 feat: add bulk category editing for photos (#157)
Add BulkCategoryModal component that allows selecting multiple photos
and moving them to a different category in one operation.
2026-02-01 22:48:08 +01:00
Paul Nothaft 7b8d8bd92b feat: decouple hero header from gallery layouts (#158)
- Add separate header_style setting (hero/standard/minimal/none) that can
  be combined with any layout type (grid/masonry/carousel/timeline/mosaic)
- Create HeroHeader and HeroDivider components for reusable hero section
- Add hero_divider_style setting (wave/straight/angle/curve/none)
- Add database migration for header_style and hero_divider_style columns
- Remove deprecated HeroGalleryLayout component
- Fix various TypeScript errors across the codebase:
  - Add missing type properties (css_template_id, updatedAt, justified settings)
  - Fix null handling for event_date and expires_at fields
  - Fix translation function calls and i18n config
  - Remove unused imports and variables
2026-02-01 22:44:28 +01:00
Paul Nothaft 397d33a95a fix: increase upload limit to 1GB and fix category filters (#155, #156)
- Increase nginx client_max_body_size from 100MB to 1GB for video support
- Fix admin photo category filtering to properly handle numeric category IDs
  from the photo_categories table, not just legacy 'individual'/'collage' types
- Add support for 'uncategorized' filter to show photos with no category
2026-02-01 21:07:44 +01:00
Paul Nothaft 08c2e4530e Merge pull request #154 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.8.0-beta.0
2026-01-30 08:35:15 +01:00
github-actions[bot] 9ec0e2e7c0 chore(beta): release 3.8.0-beta.0 2026-01-30 07:34:38 +00:00
Paul Nothaft aacfcd517e Merge pull request #153 from the-luap/feat/new-features
feat: improve gallery layouts with aspect-ratio-aware masonry and mosaic modes (#146)
2026-01-30 08:34:24 +01:00
Paul Nothaft 27ff51e7a1 fix: use photo dimensions for mosaic aspect ratios (#146)
Thumbnails are generated as 300x300 squares, so CSS Columns alone
couldn't show varied aspect ratios. Now using the photo's width/height
metadata with CSS aspect-ratio property to force correct proportions.
2026-01-30 08:23:58 +01:00
Paul Nothaft 821d3296ea fix: use CSS Columns for gap-free mosaic layout (#146)
Replaced CSS Grid with span rules approach with CSS Columns to eliminate
gaps and white spaces in the mosaic layout. Images now flow vertically
within columns, maintaining their natural aspect ratios without gaps.
2026-01-29 23:16:14 +01:00
Paul Nothaft 46ed1bc276 feat: add quilted layout, fix mosaic, and backfill photo dimensions (#146)
- Add migration to backfill width/height for existing photos without dimensions
- Replace justified masonry mode with quilted layout (mixed sizes based on aspect ratio)
- Rewrite mosaic layout to use proper CSS Grid with span rules
- Fix theme not being applied after gallery login
- Improve columns mode distribution using shortest-column algorithm
- Apply gallery theme regardless of authentication status
2026-01-29 23:09:12 +01:00
Paul Nothaft 8711f967a1 fix: use actual photo aspect ratios in masonry columns mode (#146)
Previously, the Pinterest-style columns mode assigned random heights to
photos, causing landscape images to be cropped into portrait slots.
Now the height is calculated based on the photo's actual aspect ratio
and the column width, preserving natural proportions.
2026-01-29 21:40:41 +01:00
Paul Nothaft 5c8aed5793 Merge pull request #151 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.7.0-beta.0
2026-01-28 22:54:59 +01:00
github-actions[bot] c40f34d3de chore(beta): release 3.7.0-beta.0 2026-01-28 21:54:14 +00:00
Paul Nothaft ef2ae00ff2 Merge pull request #150 from the-luap/feat/new-features
feat: Add justified layout modes and aspect-ratio-aware mosaic (#146)
2026-01-28 22:53:58 +01:00
Paul Nothaft 608bbd50e7 feat: add justified layout modes and aspect-ratio-aware mosaic (#146)
- Add Flickr justified-layout and react-photo-album as masonry mode options
- Implement aspect-ratio-aware mosaic layout that dynamically selects
  patterns based on photo orientations to minimize cropping
- Add 9 mosaic pattern types optimized for different orientation combinations
- Add theme customizer options for masonry mode selection (columns/rows/flickr/justified)
- Add i18n translations for new layout options
2026-01-28 22:31:34 +01:00
Paul Nothaft e3024e6ffd Merge pull request #148 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.6.0-beta.0
2026-01-27 11:47:16 +01:00
github-actions[bot] b4978c0869 chore(beta): release 3.6.0-beta.0 2026-01-27 10:45:56 +00:00
Paul Nothaft cd1d50474f Merge pull request #147 from the-luap/feat/new-features
feat: add justified/rows layout mode to masonry gallery (#146) + security fixes
2026-01-27 11:45:38 +01:00
Paul Nothaft 8097a0cb53 fix: update packages to fix security vulnerabilities
- react-router-dom 6.30.2 → 6.30.3 (XSS via Open Redirects)
- react-router 6.30.2 → 6.30.3
- @remix-run/router 1.23.1 → 1.23.2
- lodash 4.17.21 → 4.17.23 (Prototype Pollution)
2026-01-27 11:40:18 +01:00
Paul Nothaft e081b56a44 feat: add justified/rows layout mode to masonry gallery (#146)
Add Google Photos-style justified row layout as a mode within masonry:

- Add masonryMode setting: 'columns' (Pinterest) or 'rows' (Google Photos)
- Create justifiedLayoutCalculator utility for row-based layouts
- Extract and store image dimensions on upload for layout calculations
- Include width/height in gallery API response
- Add row height and last row behavior controls to theme customizer
- Support responsive container width detection with ResizeObserver

Photos in rows mode maintain their aspect ratios while filling
horizontal rows at a consistent height. The number of photos per
row is automatically calculated based on target row height and
photo dimensions.

Closes #146
2026-01-27 09:58:09 +01:00
Paul Nothaft c2309af3e0 Merge pull request #144 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.5.0-beta.0
2026-01-25 15:25:57 +01:00
github-actions[bot] 32fc939c7a chore(beta): release 3.5.0-beta.0 2026-01-25 14:24:28 +00:00
Paul Nothaft 4c081601e0 Merge pull request #143 from the-luap/feat/new-features
feat: per-event custom logos, customizable event types, and multiple bug fixes
2026-01-25 15:24:14 +01:00
Paul Nothaft 85170b883f feat: add per-event custom logo upload with bug fixes
Add event-level custom logo upload/delete endpoints and UI, allowing
per-event logos to override the global branding logo in gallery views.

Also fixes several bugs discovered during testing:
- fix: category_id 'individual' parsed as NaN causing photo upload failures
- fix: gallery auth race condition where photos query fired before token stored
- fix: gallery-photos query not invalidated after favorite/like mutations
- fix: e2e test race conditions with View Gallery button detachment
2026-01-23 22:01:02 +01:00
Paul Nothaft c018604e5d Merge pull request #142 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.4.0-beta.0
2026-01-22 14:03:45 +01:00
github-actions[bot] 9c8b5e9fd6 chore(beta): release 3.4.0-beta.0 2026-01-22 13:00:33 +00:00
Paul Nothaft 151e1bf50f Merge pull request #141 from the-luap/feat/new-features
feat: new features and bug fixes for beta release
2026-01-22 14:00:14 +01:00
Paul Nothaft c5a8ffc08c fix: handle null dates in dashboard and gallery pages
Add null checks for expires_at and event_date fields to prevent
TypeError when calling parseISO() on null values. This fixes crashes
that occurred after making event dates optional.

- AdminDashboard: skip events with null expires_at in expiring filter
- GalleryPage: handle null expires_at in expiration calculation
- GalleryView: make daysUntilExpiration nullable with explicit checks
- EventDetailsPage: return null from safeParseDate for null inputs
2026-01-22 13:54:23 +01:00
Paul Nothaft d4a15dbe74 fix: remove non-functional watermark toggle from Feature Toggles
The "Enable watermark on photos" checkbox in Settings > General > Feature
Toggles was not connected to any backend logic - it stored a setting that
was never read or used. The actual working watermark functionality exists
in Settings > Branding.

This removes the dead toggle to eliminate user confusion (fixes #140).
2026-01-22 13:54:23 +01:00
Paul Nothaft 0790a1ddad feat: add per-event hero logo customization options
Add configurable hero logo settings for individual events:
- Logo visibility toggle (show/hide in hero section)
- Logo size options (small, medium, large, xlarge)
- Logo position options (top, center, bottom)

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

Also updates .gitignore to exclude test files and artifacts.
2026-01-22 13:54:23 +01:00
Paul Nothaft f8881d5bd6 feat: add customizable event types with admin management
Implements GitHub issue #139 - allows users to create and manage custom
event types beyond the default presets (wedding, birthday, corporate, other).

Backend:
- Add event_types table migration with default system types
- Create eventTypeService for CRUD operations with legacy fallback
- Add adminEventTypes routes with full REST API
- Update event validation to use dynamic event types
- Update slug generation to use custom slug_prefix

Frontend:
- Add EventTypesPage with full CRUD admin interface
- Add eventTypes.service.ts API client
- Update CreateEventPage to fetch types dynamically
- Add Event Types navigation in admin sidebar
- Add i18n translations (EN/DE)

Backward compatible: existing galleries continue to work, legacy types
accepted even if database is empty via fallback mechanisms.
2026-01-22 13:54:23 +01:00
Paul Nothaft 6b3ead747b fix: resend gallery email fails for events without password
Added optional chaining when accessing req.body.password in the
resend-email endpoint to handle cases where req.body is undefined.
This prevented the "Cannot read properties of undefined" error.

Fixes #137
2026-01-22 13:54:00 +01:00
Paul Nothaft dadef81158 fix: event-specific custom CSS settings not being saved
The ThemeCustomizerEnhanced component stored customCss in a separate
local state that was never propagated to the parent component when
hideActions was true (used in both CreateEventPage and EventDetailsPage).

Changes:
- handleChange() now includes customCss when propagating theme changes
- CSS textarea onChange now propagates customCss to parent in preview mode
- handlePresetSelect() clears customCss when selecting a preset

Fixes #136
2026-01-22 13:54:00 +01:00
Paul Nothaft 644ea22b5f Merge pull request #135 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.3.0-beta.0
2026-01-21 17:06:49 +01:00
github-actions[bot] f4da354ae7 chore(beta): release 3.3.0-beta.0 2026-01-21 16:06:24 +00:00
Paul Nothaft a59f41463f Merge pull request #134 from the-luap/feat/optional-event-date-expiration-beta
feat: add original filename preservation and Lightroom export support
2026-01-21 17:06:06 +01:00
Paul Nothaft 9872ad3aef feat: add original filename preservation and Lightroom export support
Addresses GitHub issue #132 - enables filtering client feedback and
exporting filenames for use in Lightroom.

Changes:
- Add original_filename column to photos table via migration
- Store original filename during photo upload
- Fix export service column name mismatches (path, size_bytes, uploaded_at)
- Fix table name (photo_categories instead of categories)
- Fix toFixed() calls to handle string ratings from database

Export formats available:
- TXT with comma separator (for Lightroom Library Filter)
- CSV with full metadata
- JSON for automation
- XMP sidecar files (for Lightroom/Bridge/Capture One)
2026-01-21 16:47:13 +01:00
Paul Nothaft d0880ccb03 Merge pull request #131 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.5-beta.0
2026-01-18 15:38:03 +01:00
github-actions[bot] 237eeea5a6 chore(beta): release 3.2.5-beta.0 2026-01-18 14:35:30 +00:00
Paul Nothaft 41bf6ff884 Merge pull request #130 from the-luap/feat/optional-event-date-expiration-beta
fix: resolve admin invitation flow issues and improve STORAGE_PATH documentation
2026-01-18 15:35:15 +01:00
Paul Nothaft 991aa98f98 fix: correct invitation activation validation and add missing translations
- Fix password minimum length validation: frontend now correctly requires
  12 characters to match backend validation (was incorrectly checking for 8)
- Fix translation key references in AcceptInvitePage to use correct paths
  (e.g., acceptInvitation.errors.* instead of acceptInvitation.*)
- Add missing translations for both EN and DE:
  - contactAdminMessage
  - passwordsMatch
  - alreadyHaveAccount
  - signIn

Fixes #129
2026-01-18 15:00:38 +01:00
Paul Nothaft 86fa1046d5 fix: correct invitation email link URL path
The invitation email was generating links to /admin/accept-invite/{token}
but the frontend route is configured at /invite/{token}. This caused
invited users to see a blank page when clicking the email link.

Fixes #129
2026-01-18 12:55:56 +01:00
Paul Nothaft 3397807670 docs: emphasize importance of STORAGE_PATH in env example 2026-01-17 15:48:24 +01:00
Paul Nothaft cdda709886 fix: add STORAGE_PATH to production docker-compose
Ensures STORAGE_PATH environment variable is explicitly set in
production deployments to prevent path resolution issues when
serving thumbnails and other storage-related operations.
2026-01-17 15:48:15 +01:00
Paul Nothaft 023bb97e66 Merge pull request #128 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.4-beta.0
2026-01-17 15:08:48 +01:00
github-actions[bot] cf38305f28 chore(beta): release 3.2.4-beta.0 2026-01-17 14:08:09 +00:00
Paul Nothaft 0e3674b2b0 Merge pull request #127 from the-luap/feat/optional-event-date-expiration-beta
fix: correct storage path resolution in multiple files (#96)
2026-01-17 15:07:56 +01:00
Paul Nothaft 3ccb8154eb fix: correct storage path resolution in multiple files (#96)
Fixed inconsistent storage path fallbacks that caused 500 errors when
serving thumbnails. The paths were using '../../storage' (2 levels up)
instead of '../../../storage' (3 levels up) when STORAGE_PATH env var
is not set.

Affected files:
- backend/src/routes/gallery.js
- backend/src/services/photoService.js
- backend/src/services/eventService.js
2026-01-17 14:01:29 +01:00
Paul Nothaft b5ac18121d Merge pull request #126 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.3-beta.0
2026-01-16 15:23:29 +01:00
github-actions[bot] b613f8fbc7 chore(beta): release 3.2.3-beta.0 2026-01-16 14:19:26 +00:00
Paul Nothaft cacaffa5c3 Merge pull request #125 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button not visible in gallery (#113)
2026-01-16 15:19:09 +01:00
Paul Nothaft 691e3aba09 fix: add allow_user_uploads to gallery API responses
The gallery /photos and /info endpoints were not returning the
allow_user_uploads field, causing the upload button to never show
in the frontend since the value was always undefined/false.
2026-01-16 15:15:09 +01:00
Paul Nothaft 70a0caa11f Merge pull request #124 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.2-beta.0
2026-01-16 14:53:44 +01:00
github-actions[bot] e808e529cd chore(beta): release 3.2.2-beta.0 2026-01-16 13:53:31 +00:00
Paul Nothaft 05a5307e22 Merge pull request #123 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button visibility in gallery (#113)
2026-01-16 14:53:17 +01:00
Paul Nothaft 2a2c23d116 fix: mobile upload button visibility in gallery
- Remove hidden sm:block wrapper hiding upload button on mobile for Grid layout
- Add upload button to GallerySidebar using existing allowUploads/onUploadClick props
- Ensures upload button is visible in both header and sidebar on all devices

Closes #113
2026-01-16 14:49:37 +01:00
Paul Nothaft a092d98523 Merge pull request #122 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.1-beta.0
2026-01-16 14:38:39 +01:00
github-actions[bot] b5f06af126 chore(beta): release 3.2.1-beta.0 2026-01-16 13:35:53 +00:00
Paul Nothaft 6cb43428d1 Merge pull request #121 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button visibility in gallery (#113)
2026-01-16 14:35:36 +01:00
Paul Nothaft df7dbffbff fix: mobile upload button visibility in gallery
- Remove hidden sm:block wrapper hiding upload button on mobile for Grid layout
- Add upload button to GallerySidebar using existing allowUploads/onUploadClick props
- Ensures upload button is visible in both header and sidebar on all devices

Closes #113
2026-01-16 14:29:49 +01:00
Paul Nothaft 94421a6b12 Merge pull request #120 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.0-beta.0
2026-01-16 09:43:51 +01:00
github-actions[bot] 7805e89bfe chore(beta): release 3.2.0-beta.0 2026-01-16 08:43:36 +00:00
Paul Nothaft 3079eaa2e5 Merge pull request #119 from the-luap/feat/optional-event-date-expiration-beta
feat: add optional event date and expiration settings
2026-01-16 09:43:23 +01:00
Paul Nothaft 2151147f2d feat: add optional event date and expiration settings
Add global settings to make event_date and expiration optional when
creating galleries. This supports non-event use cases like portraits,
corporate shoots, etc.

New features:
- Settings toggles in Settings → Event Creation tab
- "Require event date" checkbox with warning about random URL identifiers
- "Require expiration date" checkbox with warning about manual archiving
- Galleries without date use random hex suffix in slug (e.g. portrait-smith-a1b2c3)
- Galleries without expiration never expire (stay active until archived)

Backend changes:
- New migration for settings and nullable columns
- Conditional validation based on settings
- Updated slug generation with random suffix fallback
- Updated expiration checker to skip null expires_at
- Updated gallery access control for null expiration

Frontend changes:
- New checkboxes in EventsTab with warnings
- Conditional event date field (shows optional label)
- No Expiration message when expiration disabled
- Updated types for nullable event_date and expires_at

Closes #118
2026-01-16 09:39:32 +01:00
Paul Nothaft 3e69579f5a docs: add API_URL environment variable to .env.example files
Document the API_URL environment variable that is used for constructing
URLs for assets (logos, images) in email notifications. Without this
setting, the system defaults to http://localhost:3001 which causes
broken images in production emails.

Added to both root and backend .env.example files with clear
documentation about its purpose and importance.
2026-01-16 09:39:32 +01:00
Paul Nothaft 808ed1d2f1 fix: checkbox and toggle settings not persisting after page refresh
PostgreSQL's json column type returns parsed values directly (boolean
false instead of string "false"). The backend code used a truthy check
which failed for boolean false values, causing null to be returned
instead of the actual false value.

Changed condition from `if (setting.setting_value)` to explicit null
check `if (setting.setting_value !== null && setting.setting_value !== undefined)`
and added handling for already-parsed json column values.

Fixes #117
2026-01-16 09:39:32 +01:00
Paul Nothaft b40e085d28 Merge pull request #116 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.1.0-beta.0
2026-01-15 15:04:17 -05:00
github-actions[bot] d603567e21 chore(beta): release 3.1.0-beta.0 2026-01-15 20:03:57 +00:00
Paul Nothaft c6fdd38e84 Merge pull request #115 from the-luap/fix/codeql-v4-upgrade
feat: pre-generated watermarks and mobile upload button improvements
2026-01-15 15:03:25 -05:00
Paul Nothaft ae181cf92f fix: show upload button in mobile topbar instead of sidebar
The upload button was hidden in the sidebar on mobile devices, requiring
users to open the menu to find it. Now it appears directly in the topbar
for easy access on all screen sizes.

- Remove !isMobile condition from header upload button
- Add responsive text (short on mobile, full on desktop)
- Remove duplicate upload button from sidebar

Fixes #113
2026-01-15 21:00:17 +01:00
Paul Nothaft 1be974afbb feat: pre-generate watermarks for instant lightbox loading
Previously watermarks were applied on-the-fly when viewing photos in the
lightbox, causing 1+ minute load times for high-resolution images.

This change pre-generates watermarked versions during upload and when
watermark settings change, enabling instant image loading (~50-100ms).

- Add database migration for watermark_path tracking (061)
- Add watermarkGeneratorService for batch operations
- Extend watermarkService with save-to-disk capability
- Modify gallery endpoint to serve pre-generated files
- Add background regeneration when branding settings change
- Add npm script for migrating existing photos

Closes #112
2026-01-15 21:00:10 +01:00
Paul Nothaft 4c0baf242b Merge pull request #114 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.4.0
2026-01-15 14:18:51 -05:00
github-actions[bot] b12621b994 chore(main): release 2.4.0 2026-01-15 19:18:31 +00:00
Paul Nothaft 4701edc12e Merge pull request #112 from the-luap/fix/codeql-v4-upgrade
fix: dynamic website title from branding settings
2026-01-15 14:18:11 -05:00
Paul Nothaft d29aab7c70 feat: dynamic website title from branding settings
Update document title based on company name and tagline settings:
- Both filled: "{Company Name} - {Tagline}"
- Name only: "{Company Name}"
- Neither: "PicPeak - Photo Sharing Platform" (default)
2026-01-15 16:43:21 +01:00
Paul Nothaft 41f80fc898 Merge pull request #111 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.3.4
2026-01-15 10:24:59 -05:00
github-actions[bot] 0f7551ab5b chore(main): release 2.3.4 2026-01-15 15:24:23 +00:00
Paul Nothaft 7c58749806 Merge pull request #110 from the-luap/fix/codeql-v4-upgrade
fix: database migration restart bug, lightbox loading spinner, and watermark cache invalidation
2026-01-15 10:24:04 -05:00
Paul Nothaft 050ed37819 fix: add lightbox loading spinner and watermark cache invalidation
- Add spinning loader in lightbox while large images are loading
- Add onLoad callback to AuthenticatedImage for canvas and img modes
- Add ETag headers based on watermark settings for HTTP cache validation
- Add watermark version query param to photo/thumbnail URLs for cache busting
- Ensures images refresh when watermark settings are enabled/changed
2026-01-15 16:19:22 +01:00
Paul Nothaft 83a4344a01 fix: prevent database migration restart failures
- Move migrations table insert inside PostgreSQL transaction for atomicity
- Add PostgreSQL error codes 42701 (duplicate column), 42710 (duplicate
  object), and 23505 (unique violation) to error handling
- Make migrations 006 and 008 idempotent with column existence checks

Fixes #107
2026-01-15 15:43:13 +01:00
Paul Nothaft 9b50f3d6b7 Merge pull request #109 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.3.3
2026-01-15 09:24:12 -05:00
github-actions[bot] e945bc9413 chore(main): release 2.3.3 2026-01-15 14:23:34 +00:00
Paul Nothaft 3b720ed56e fix: lightbox watermark loading, white label translations, and dynamic footer year (#108)
fix: lightbox watermark loading, white label translations, and dynamic footer year
2026-01-15 09:23:10 -05:00
Paul Nothaft ce8587b24d fix: lightbox watermark loading, white label translations, and dynamic footer year
- Fix watermarked images not opening in lightbox (add /api prefix to photo URLs)
- Add i18n translations for 'White Label' and 'Hide Powered by' branding settings
- Add complete logo customization translations (EN and DE)
- Replace hardcoded © 2024 with dynamic current year in footer
- Use company name from settings in default footer text
2026-01-15 14:16:00 +01:00
Paul Nothaft fe772b52d6 Merge pull request #106 from the-luap/release-please--branches--main
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-backend (push) Has started running
chore(main): release 2.3.2
2026-01-15 08:03:07 -05:00
github-actions[bot] f29b77998b chore(main): release 2.3.2 2026-01-15 13:02:43 +00:00
Paul Nothaft f843e4c25c Merge pull request #105 from the-luap/fix/codeql-v4-upgrade
fix: watermark thumbnails, custom logo display, and German translations
2026-01-15 08:02:28 -05:00
Paul Nothaft ea20446a79 fix: watermark thumbnails, custom logo display, and German translations
- Fix thumbnail display when watermarks enabled globally on existing galleries
  - Backend: Apply watermarks to thumbnails at the thumbnail endpoint
  - Frontend: Remove hack that redirected thumbnails to photo endpoint
- Fix custom logo display in gallery hero sections
  - Only apply brightness/invert filter to default PicPeak logo
  - Custom logos now display as-is with drop-shadow only
- Add German translations for Event Creation and Image Protection settings
  - settings.events: Pflichtfelder, Kundenname/E-Mail erforderlich, etc.
  - settings.imageSecurity: Bildschutz, Ratenbegrenzung, Sicherheitsüberwachung
  - Protection level options in both EN and DE locales
2026-01-15 13:57:22 +01:00
Paul Nothaft 41f9b6d45d Merge pull request #104 from the-luap/release-please--branches--main
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (push) Has started running
Build and Push Docker Images / build-backend (push) Failing after 3m44s
chore(main): release 2.3.1
2026-01-15 06:46:06 -05:00
github-actions[bot] 7b5916d3b9 chore(main): release 2.3.1 2026-01-15 11:45:24 +00:00
Paul Nothaft 657c205a4d Merge pull request #103 from the-luap/fix/codeql-v4-upgrade
fix: CI workflow fixes for protected branches
2026-01-15 06:45:06 -05:00
Paul Nothaft 1c8f686c19 Merge pull request #102 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.0.1-beta.0
2026-01-15 06:36:13 -05:00
github-actions[bot] a0f38053d3 chore(beta): release 3.0.1-beta.0 2026-01-15 11:35:58 +00:00
Paul Nothaft cb012186d9 Merge pull request #101 from the-luap/fix/codeql-v4-upgrade
fix: CI workflow fixes for protected branches
2026-01-15 06:35:46 -05:00
Paul Nothaft fe7d45dd12 fix: use Release Please extra-files instead of sync-versions job
Remove sync-versions job that fails on protected branches.
Instead, use Release Please's extra-files feature to update
package.json versions as part of the release PR.
2026-01-15 12:32:07 +01:00
Paul Nothaft c05ae5b0b9 chore: upgrade CodeQL Action from v3 to v4
Address deprecation warning - CodeQL Action v3 will be deprecated in December 2026.
2026-01-15 12:30:25 +01:00
Paul Nothaft dab012c3d1 Merge pull request #100 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.0.0-beta.0
2026-01-15 06:28:27 -05:00
github-actions[bot] 32492c5a91 chore(beta): release 3.0.0-beta.0 2026-01-15 11:24:18 +00:00
github-actions[bot] 2add85eccf chore: sync package.json versions to 2.3.0 2026-01-15 11:19:05 +00:00
Paul Nothaft 5edfb44776 Merge pull request #99 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 4m13s
Build and Push Docker Images / build-frontend (push) Failing after 3m30s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.0
2026-01-15 06:18:39 -05:00
github-actions[bot] eedb0fe49c chore(main): release 2.3.0 2026-01-15 11:18:00 +00:00
Paul Nothaft 3c7dc2013f feat: beta/stable release channels with update notifications and bug fixes (#98)
feat: beta/stable release channels with update notifications and bug fixes
2026-01-15 06:17:41 -05:00
Paul Nothaft 617e778a48 feat: implement beta/stable release channels with update notifications
Add dual-channel release strategy for stable and beta releases:

Release Channels:
- Stable channel: production-ready releases (stable, latest, v2.3.0)
- Beta channel: early access features (beta, v2.3.0-beta.1)
- Configurable via PICPEAK_CHANNEL environment variable

Update Notifications:
- Admin dashboard shows available updates for configured channel
- Checks GitHub Releases API with 1-hour cache
- Can be disabled with UPDATE_CHECK_ENABLED=false

CI/CD Changes:
- New release-please-beta.yml workflow for beta prereleases
- Docker build workflow produces stable/beta tags based on branch
- Beta versions use v2.3.0-beta.1 format

New Files:
- .github/workflows/release-please-beta.yml
- release-please-config-beta.json
- .release-please-manifest-beta.json
- backend/src/services/updateCheckService.js
- frontend/src/components/admin/UpdateNotification.tsx

Modified Files:
- docker-compose.production.yml (channel selection)
- .env.example (PICPEAK_CHANNEL, UPDATE_CHECK_ENABLED)
- backend/src/routes/adminSystem.js (/updates endpoint)
- frontend components (VersionInfo, AdminDashboard)
- i18n locales (en.json, de.json)
- README.md and DEPLOYMENT_GUIDE.md (documentation)
2026-01-15 12:11:06 +01:00
Paul Nothaft e3c3c4c951 fix: gallery thumbnails not loading (404 errors) #96
The gallery thumbnail endpoint was returning 404 when thumbnail_path
was null or the file didn't exist, unlike the admin endpoint which
generates thumbnails on demand using ensureThumbnail().

- Import ensureThumbnail from imageProcessor
- Use ensureThumbnail() in gallery thumbnail route to generate
  thumbnails on demand if they don't exist
- This matches the admin endpoint behavior

Fixes #96
2026-01-15 11:22:13 +01:00
Paul Nothaft 0e3b50d1b6 fix: watermark upload JSON parsing and image quality preservation
- Fix JSON parsing error when uploading watermark logo by handling both
  JSON-stringified and raw string paths
- Ensure publicPath is JSON.stringify'd consistently when saving
- Preserve original image format (PNG/WebP/JPEG) when applying watermarks
- Use maximum quality (100) to prevent unnecessary recompression
2026-01-12 13:24:21 +01:00
Paul Nothaft bd8b885f7f fix: display new password after admin password reset
- show-admin-credentials.js --reset now displays the generated password
  instead of just saying "[NEWLY RESET - stored in database]"
- Also sets must_change_password flag to force password change on login
- Updated DEPLOYMENT_GUIDE.md and SIMPLE_SETUP.md to clarify that the
  new password is displayed in console output after reset
2026-01-12 13:23:24 +01:00
Paul Nothaft 3cdc0ea715 fix: prevent unnecessary image recompression and fix SQLite migration #95
- Skip image processing for basic/standard protection levels when no
  fingerprinting or watermarking is enabled
- Preserve original image format (PNG/WebP/JPEG) instead of always
  converting to JPEG
- Fix SQLite migration failure for fresh installations by adding
  multilingual columns to email_templates table before inserting
  admin email templates

Fixes #95
2026-01-12 13:20:39 +01:00
github-actions[bot] a2ff9eae3f chore: sync package.json versions to 2.2.4 2026-01-08 22:24:23 +00:00
Paul Nothaft 3f7631cd95 Merge pull request #93 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m9s
Build and Push Docker Images / build-frontend (push) Failing after 3m9s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.4
2026-01-08 23:23:57 +01:00
github-actions[bot] 53b8764ed7 chore(main): release 2.2.4 2026-01-08 22:22:51 +00:00
Paul Nothaft 082d8ab205 fix: Docker Swarm DNS resolution and backup status display (v2.2.3)
fix: Docker Swarm DNS resolution and backup status display (v2.2.3)
2026-01-08 23:22:33 +01:00
Paul Nothaft 749100c92a fix(backup): add lastBackup alias and totalBackups for frontend compatibility
The frontend expected `status.lastBackup` but the backend was returning
`status.lastRun`. This caused the backup dashboard to show "No backup available"
even when backups existed in the history.

Added:
- `lastBackup` as alias for `lastRun`
- `totalBackups` count of completed backups
2026-01-08 23:11:48 +01:00
Paul Nothaft 3798662722 Merge pull request #91 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m22s
Build and Push Docker Images / build-frontend (push) Failing after 3m0s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.3
2026-01-08 18:26:58 +01:00
github-actions[bot] fa1397cb8c chore(main): release 2.2.3 2026-01-08 17:26:11 +00:00
Paul Nothaft cc1ddfd42c fix(nginx): Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3)
Fixes 502 Bad Gateway on root path in Docker Swarm by adding DNS resolver
  configuration (127.0.0.11) and dynamic DNS resolution for all proxy_pass 
  directives. This ensures nginx resolves backend service IPs on each request 
  rather than caching them at startup.
2026-01-08 18:25:54 +01:00
Paul Nothaft 049837f9d6 fix(nginx): add Docker DNS resolver for Swarm/dynamic service discovery
- Add resolver 127.0.0.11 directive for Docker's internal DNS
- Use variable-based proxy_pass to force per-request DNS resolution
- Fix 502 Bad Gateway error on root path in Docker Swarm deployments

The issue was that nginx caches DNS lookups at startup, but in Docker
Swarm where service IPs can change dynamically, this caused stale DNS
entries leading to 502 errors for proxied requests.

Bumps version to 2.2.3
2026-01-08 16:25:05 +01:00
Paul Nothaft 29dc2a3cf1 Merge pull request #89 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m26s
Build and Push Docker Images / build-frontend (push) Failing after 3m23s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.2
2026-01-08 15:53:44 +01:00
github-actions[bot] d2cba449b0 chore(main): release 2.2.2 2026-01-08 14:51:24 +00:00
Paul Nothaft e0bd19a74d fix: Align nginx backend port for production Docker deployments (v2.2.2) (#88)
Fix 502 Bad Gateway on root path in production Docker/Traefik deployments.

  - nginx.conf: backend:3001 → backend:3000 (matches production container port)
  - docker-compose.yml: align dev environment to use port 3000
  - Bump version to 2.2.2
2026-01-08 15:51:10 +01:00
Paul Nothaft 0ab8cbde7f chore: bump version to 2.2.2
Includes fix for nginx backend port alignment (3001 → 3000) that caused
502 errors on root path in production Docker deployments.
2026-01-08 15:46:41 +01:00
Paul Nothaft 3a8d53f492 fix: align backend port to 3000 across all configurations
The production docker-compose used port 3000 internally but nginx.conf
was hardcoded to port 3001, causing 502 errors on the root path (/).

Changes:
- Update nginx.conf to use backend:3000
- Update docker-compose.yml to use PORT=3000 for consistency
- Update port mapping and healthcheck to use port 3000
2026-01-08 15:28:35 +01:00
Paul Nothaft 804a964ba0 Merge pull request #87 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 2m58s
Build and Push Docker Images / build-frontend (push) Failing after 3m4s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.1
2026-01-08 14:01:58 +01:00
github-actions[bot] d2cd1aa933 chore(main): release 2.2.1 2026-01-08 13:01:36 +00:00
Paul Nothaft d7ecf83d32 fix: Resolve branding display issues and invitation parsing errors (v2.2.1) (#86)
Fixes #84, Fixes #85
  - Fix uploads proxy routing in nginx and vite dev server
  - Fix logo/favicon state handling in BrandingPage
  - Fix invitation API response field transformation (snake_case → camelCase)  
  - Add hide_powered_by to public settings API
  - Mark Multiple Administrators as implemented in roadmap
  - Bump version to 2.2.1
2026-01-08 14:01:21 +01:00
Paul Nothaft ebb2ce6065 Merge branch 'main' into feature/multiple-administrators 2026-01-08 13:58:41 +01:00
Paul Nothaft 1931d73b60 fix: resolve branding display issues and invitation parsing errors
Fixes #84 - Logo and favicon not displaying on branding page and galleries
Fixes #85 - Invitations showing undefined expiresAt causing parseISO errors

Changes:
- Fix nginx.conf: Add ^~ modifier to /uploads location to prioritize proxy over static file matching
- Fix vite.config.ts: Add /uploads proxy for development environment
- Fix BrandingPage.tsx: Include logo_url from branding settings instead of expecting it from theme
- Fix adminUsers.js: Add transformInvitation() to convert snake_case DB fields to camelCase API response
- Fix publicSettings.js: Add branding_hide_powered_by to public settings API response
- Update README.md: Mark Multiple Administrators feature as implemented
- Bump version to 2.2.1
2026-01-08 13:56:02 +01:00
Paul Nothaft 0d5ce48dcc fix: handle legacy non-JSON logo paths when replacing logo
When uploading a new logo, the code tries to delete the old logo file.
This failed when the old path was stored as a raw path (legacy format)
instead of JSON-serialized. Added check to handle both formats.
2026-01-08 11:44:29 +01:00
Paul Nothaft 4872ef71f8 ci: only build ARM64 images for tagged releases
QEMU emulation of ARM64 on x86 GitHub runners is too slow and
unreliable for npm operations, causing builds to hang or crash
with "Illegal instruction" errors.

Changed platform detection logic to:
- Tagged releases (v*.*.*): Build both amd64 and arm64
- All other builds (branches, PRs): Build amd64 only

This ensures fast CI feedback during development while still
providing multi-arch images for production releases.
2026-01-08 11:33:20 +01:00
Paul Nothaft b83f4272b5 fix: JSON serialize favicon and logo URLs for PostgreSQL storage
Fixes #84

The favicon and logo upload endpoints were storing URL paths directly
without JSON.stringify(), causing PostgreSQL JSON validation errors
("Token '/' is invalid") since paths like "/uploads/favicons/..."
are not valid JSON.

Applied JSON.stringify() to:
- branding_logo_url setting (lines 358, 364)
- branding_favicon_url setting (lines 894, 900)
2026-01-08 11:29:38 +01:00
github-actions[bot] 5df64992c4 chore: sync package.json versions to 2.2.0 2026-01-08 09:28:03 +00:00
Paul Nothaft 7e5e004270 Merge pull request #83 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m5s
Build and Push Docker Images / build-frontend (push) Failing after 3m5s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.0
2026-01-08 10:27:40 +01:00
github-actions[bot] 09ce2b80d0 chore(main): release 2.2.0 2026-01-08 09:26:48 +00:00
Paul Nothaft 476fcce13f fix: Add settings translations and fix manual backup process (#82)
- Add i18n translations for settings tabs (Events, Image Security, Moderation, CSS)
  - Fix manual backup when automated backups are disabled
  - Fix PostgreSQL wait-for-db.sh connection check
2026-01-08 10:26:34 +01:00
Paul Nothaft c030e87213 feat(i18n): add translations for settings tabs
- Add settings.events.* keys for Event Creation settings
- Add settings.imageSecurity.* keys for Image Protection settings
- Add settings.moderation.* keys for Word Filter/Moderation settings
- Add cssTemplates.* keys for Custom CSS Templates
- All settings tabs now have proper i18n support
2026-01-07 22:43:36 +01:00
Paul Nothaft e6dd89e969 fix(backup): allow manual backups when automated backups are disabled
- Manual backup button now works regardless of backup_enabled setting
- backup_enabled only controls scheduled/automated backups
- Manual backups only require destination to be configured
- Fixed backup_type to correctly show 'manual' vs 'scheduled'
2026-01-07 22:39:57 +01:00
Paul Nothaft e85a68a386 fix(db): improve PostgreSQL connection check in wait-for-db.sh
- Try connecting to target database first (most common case)
- Fall back to template1 instead of postgres database for checks
- The picpeak user may not have access to postgres system database
- Add better retry logic with max attempts
- Improve error messages
2026-01-07 22:33:40 +01:00
github-actions[bot] 0acce6ab08 chore: sync package.json versions to 2.1.1 2026-01-07 21:24:07 +00:00
Paul Nothaft 92a1c7a2df Merge pull request #81 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m44s
Build and Push Docker Images / build-frontend (push) Failing after 3m2s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.1.1
2026-01-07 22:23:40 +01:00
github-actions[bot] edc57bfbbe chore(main): release 2.1.1 2026-01-07 21:23:23 +00:00
Paul Nothaft 37d4e1cb61 fix: Multi-administrator RBAC, CSS templates & security hardening (#80)
- Add multi-administrator support with role-based access control (RBAC)
  - Add CSS template system with Apple Liquid Glass designs
  - Add CSS template selector to event editing page
  - Fix photo category selection during upload (#77)
  - Fix category changes not persisting (#77)
  - Improve feedback button visibility in gallery views (#77)
  - Security hardening: upgrade Alpine base image, fix CVEs
  - Add Release Please for automated versioning
  - Fix Docker multi-arch builds with proper QEMU setup
  - Add Photo and Settings service layers
  - Fix date parsing and Vite proxy configuration
  - Fix S3 backup/restore functionality
2026-01-07 22:23:10 +01:00
Paul Nothaft 0d36a273bb fix(ci): add QEMU setup for multi-arch builds and skip for PRs
- Add docker/setup-qemu-action for proper ARM64 emulation
- Skip QEMU setup for PR builds (amd64 only)
- Fix QEMU "Illegal instruction" errors during npm ci
2026-01-07 22:17:33 +01:00
github-actions[bot] a19e218e40 chore: sync package.json versions to 2.1.0 2026-01-07 20:54:46 +00:00
Paul Nothaft 61c53fb24e Merge pull request #79 from the-luap/release-please--branches--main
Build and Push Docker Images / build-frontend (push) Failing after 5m35s
Build and Push Docker Images / build-backend (push) Failing after 43m28s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.1.0
2026-01-07 21:54:20 +01:00
github-actions[bot] dc8cf9a9a2 chore(main): release 2.1.0 2026-01-07 20:53:40 +00:00
Paul Nothaft 16b3ab039a feat: Multi-administrator RBAC, CSS templates & security hardening (#78)
- Add multi-administrator support with role-based access control
  - Add CSS template system with Apple Liquid Glass designs
  - Add CSS template selector to event editing
  - Fix photo category selection and feedback button visibility (#77)
  - Security hardening and Alpine base image upgrade
2026-01-07 21:53:25 +01:00
Paul Nothaft 6a6c2cd34d feat(events): add CSS template selector to event edit page
- Add CSS template selector to ThemeCustomizerEnhanced component
- Rename "Custom CSS" to "Event-specific Custom CSS" for clarity
- Load and save css_template_id when editing events
- Fetch CSS templates when entering edit mode on EventDetailsPage
- Pass CSS template props to ThemeEditorModal
- Add backend validation for css_template_id field
2026-01-07 18:04:23 +01:00
Paul Nothaft 856d53343c fix(photos): resolve upload category selection and improve feedback buttons (#77)
- Fix upload category selection by looking up category from database
  and saving category_id to photos table (was being ignored before)
- Use category slug for filename generation during upload
- Improve Like/Comment button visibility in CarouselGalleryLayout and
  PhotoLightbox with semi-transparent background and border styling
2026-01-07 17:46:46 +01:00
Paul Nothaft d9da98c355 fix(photos): category changes now persist and display correctly (#77)
- Backend PATCH /photos/:photoId now returns updated photo object
- Photo listing now joins with photo_categories table to get actual
  category name and slug instead of hardcoding based on photo.type
- Frontend service now properly returns AdminPhoto from update response

Fixes #77
2026-01-07 17:31:19 +01:00
Paul Nothaft 892e47d017 feat: add multi-administrator support with RBAC and fix backup/restore for S3
## Multi-Administrator System
- Add role-based access control (RBAC) with predefined roles (Super Admin, Admin, Editor, Viewer)
- Add granular permissions system for all admin operations
- Add admin user management page with invite functionality
- Add email invitation system for new administrators
- Add permission middleware protecting all admin routes
- Add PermissionGate component for frontend permission checks
- Track event creator (created_by) for audit purposes

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

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

## Other Improvements
- Update .gitignore to exclude planning docs and local backup directory
- Remove SQLite database file from tracking
- Add i18n translations for user management (EN/DE)
2026-01-07 17:10:46 +01:00
github-actions[bot] 007e46edb9 chore: sync package.json versions to 2.0.0 2026-01-03 22:57:16 +00:00
Paul Nothaft 542887c2e5 Merge pull request #74 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 44m27s
Build and Push Docker Images / build-frontend (push) Failing after 44m16s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.0.0
2026-01-03 23:56:46 +01:00
github-actions[bot] 4651783d4d chore(main): release 2.0.0 2026-01-03 22:52:59 +00:00
Paul Nothaft b706eeb5d3 fix(security): upgrade Alpine base image to fix libpng and c-ares CVEs
Update frontend Dockerfile to use nginx:1.27-alpine3.22 which includes:
- libpng >= 1.6.51 (fixes CVE-2025-64720, CVE-2025-65018, CVE-2025-64505, CVE-2025-64506)
- c-ares >= 1.34.5 (fixes CVE-2025-31498)

Remove redundant edge repository pull since Alpine 3.22 packages are already patched.
2026-01-03 23:52:38 +01:00
Paul Nothaft 40ee67171d Merge pull request #73 from the-luap/feature/event-rename
feat: add event management, gallery customization, and release automationFeature/event rename
2026-01-03 23:39:54 +01:00
Paul Nothaft 6033461be1 feat: add Apple Liquid Glass templates, image security settings, and automated releases
## New Features
- Apple Liquid Glass CSS template with iOS 26-inspired design
- Liquid Glass Dark theme with neon accents
- Image Security settings tab with per-event protection levels
- Release Please automation for versioning and changelog

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

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

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

## Files Added
- .github/workflows/release-please.yml
- .release-please-manifest.json
- release-please-config.json
- CHANGELOG.md
- frontend/src/features/settings/tabs/ImageSecurityTab.tsx
2026-01-03 23:35:23 +01:00
Paul Nothaft f3c2cee362 security: Fix critical vulnerabilities and harden application
## Security Fixes

### CRITICAL: Command Injection (adminBackup.js)
- Replaced exec() with spawn() using argument arrays
- Added input sanitization for host, user, and ssh_key
- Added regex validation for hostname/IP format
- Added username format validation
- Added SSH key file existence check
- Prevents shell metacharacter injection attacks

### HIGH: Hardcoded Password (set-admin-password.js)
- Removed hardcoded 'admin123' password
- Now requires password as CLI argument or env variable
- Added password strength validation (8+ chars, mixed case, numbers, special chars)
- Added --help flag with usage instructions
- Invalidates existing sessions on password change

### MEDIUM: XSS Vulnerability (WelcomeMessageEditor.tsx)
- Added DOMPurify sanitization to getPreviewHtml()
- Strips all HTML tags before rendering preview
- Prevents script injection in admin preview

### LOW: Sample Password Exposure (EmailConfigPage.tsx)
- Replaced plaintext sample password with masked placeholder
- Uses '••••••••' instead of realistic password

## Dependency Updates
- Fixed npm audit vulnerabilities (jws, qs, express)
- Backend: 0 vulnerabilities
- Frontend: 0 vulnerabilities
2026-01-03 10:12:01 +01:00
Paul Nothaft 0da45e699a feat: Add CSS template system with custom gallery styling support
## Changes

### CSS Template System
- Added CSS class hooks to gallery components for custom template targeting
- Gallery sidebar, header, footer, and photo cards can now be styled via CSS templates
- CSS variables on :root allow themes to override colors, effects, and spacing

### Gallery Component CSS Classes Added
- `.gallery-page` - Main gallery container
- `.gallery-header` - Top header bar
- `.gallery-sidebar` - Filter/download sidebar
- `.gallery-sidebar-header`, `.gallery-sidebar-title`, `.gallery-sidebar-close`
- `.gallery-sidebar-content`, `.gallery-sidebar-section`
- `.gallery-sidebar-search-input`, `.gallery-sidebar-search-icon`
- `.gallery-sidebar-backdrop` - Mobile overlay
- `.gallery-btn`, `.gallery-btn-download` - Sidebar buttons
- `.gallery-footer` - Footer section
- `.photo-card`, `.photo-grid` - Photo display elements

### CSS Templates (Database)
- Elegant Dark (id=1): Dark navy theme with light text and red accents
- Liquid Glass Light (id=2): iOS 26 frosted glass effect with gradient background

### Bug Fixes
- Fixed CSS variables not inheriting (moved from .gallery-page to :root)
- Fixed sidebar position breaking layout (removed position: relative override)
- Fixed Elegant Dark sidebar text visibility (white on white issue)

### Other Changes
- Settings page refactoring and cleanup
- i18n locale updates for new gallery features
- Vite proxy port configuration fix
- Admin auth route improvements
- CSS templates service updates
2026-01-03 08:59:01 +01:00
Paul Nothaft 97455ab047 Fix date parsing bug and Vite proxy port configuration
- Add safeParseDate helper to handle dates that may be strings, Date objects, or timestamps
- Replace all parseISO(event.*) calls with safeParseDate() to prevent "dateString.split is not a function" errors
- Fix Vite proxy target from port 3002 to 3001 to match backend server port
2026-01-02 10:49:42 +01:00
Paul Nothaft fbd7b67016 refactor: Add Photo and Settings service layers
Phase 2.2: Photo service layer
- Create backend/src/services/photoService.js
- Functions: getPhotosForEvent, getPhotoById, getPhotoCount
- Functions: updatePhoto, deletePhoto, bulkDeletePhotos
- Functions: updateSortOrder, moveToCategory, setHeroPhoto
- Support for soft delete and hard delete with file cleanup

Phase 2.3: Settings service layer
- Create backend/src/services/settingsService.js
- Functions: getAllSettings, getSetting, getSettingsByPrefix
- Functions: updateSetting, updateSettings, deleteSetting
- Functions: getPublicSettings, getBrandingSettings, getEmailSettings
- Type-aware setting parsing (boolean, number, json, string)

All core service layers are now established for:
- Events (CRUD, slug generation, expiration)
- Photos (CRUD, categories, sorting, hero)
- Settings (typed get/set, prefix queries)

Routes can be incrementally migrated to use these services.
2026-01-02 10:16:55 +01:00
Paul Nothaft 3424bd22ee refactor: Phase 1 code consolidation and service layer setup
Phase 1.1: Shared parsers utility
- Create backend/src/utils/parsers.js with parseBooleanInput, parseStringInput, etc.
- Create frontend/src/utils/parsers.ts with TypeScript equivalents
- Update routes to import from shared parsers

Phase 1.2: Auth routes consolidation
- Merge auth.js, auth-enhanced.js, auth-enhanced-v2.js into single auth.js
- Add password change and password strength endpoints
- Consolidate middleware (auth.js with token revocation support)
- Update all imports across 14+ route files

Phase 1.3: CreateEvent page consolidation
- Remove duplicate CreateEventPage.tsx (basic version)
- Rename CreateEventPageEnhanced.tsx to CreateEventPage.tsx
- Update exports and imports

Phase 1.4: CMS page consolidation
- Remove duplicate CMSPage.tsx (basic version)
- Rename CMSPageEnhanced.tsx to CMSPage.tsx
- Update exports and imports

Phase 1.5: Multer config factory
- Create backend/src/config/multerConfig.js
- Centralized upload configuration with presets for photos, logos, favicons
- Reusable helpers: createDiskStorage, createFileFilter, uploadTimeoutMiddleware

Phase 2.1: Event service layer
- Create backend/src/services/eventService.js
- Move event business logic out of routes
- Functions: createEvent, getAllEvents, updateEvent, deleteEvent, extendExpiration
2026-01-02 10:12:24 +01:00
Paul Nothaft 77a4bfd499 feat: implement 4 new features with bug fixes and refactoring plan
## Features Implemented

### 1. Event Rename Functionality
- Add EventRenameDialog component with live slug preview
- Create eventRenameService for safe event renaming
- Add slug_redirects table for old URL redirects
- Support optional email notification on rename
- Fix date formatting in slug (YYYY-MM-DD format)

### 2. Optional Event Contact Fields
- Add settings to make customer name/email/admin email optional
- Create migration for field requirement settings
- Update CreateEventPage forms to show "(optional)" labels
- Fix boolean parsing in publicSettings.js

### 3. Photo Filtering & Export
- Add PhotoFilterPanel with rating/likes/favorites/comments filters
- Create PhotoExportMenu with ZIP/metadata/XMP export options
- Add photoExportService with Lightroom XMP sidecar generation
- Create photoFilterBuilder utility for query construction
- Wire up photo selection to export button via onSelectionChange

### 4. Custom CSS Gallery Templates
- Add CssTemplateEditor component with 3 template slots
- Create cssSanitizer utility blocking XSS vectors
- Add gallery CSS endpoint for template delivery
- Integrate Custom CSS tab into Settings page
- Include default "Elegant Dark" template

## Bug Fixes
- Fix event rename date formatting (was showing full Date string)
- Fix common.optional translation key missing in locales
- Fix photo export button staying disabled when photos selected
- Fix authService import missing in SettingsPage

## Documentation
- Add comprehensive REFACTORING_PLAN.md for codebase improvement
- Add test specification documents for all features
- Add feature documentation for CSS templates

## Database Migrations
- 049_add_slug_redirects.js
- 050_add_optional_event_fields_settings.js
- 051_add_photo_filter_indexes.js
- 052_add_css_templates.js
2026-01-02 09:56:19 +01:00
Paul Nothaft 64ceb20431 Add planning document for photo filtering and export feature
Comprehensive feature plan for filtering photos by guest feedback
(ratings, likes, favorites) and exporting selections for professional
photo editing workflows.

Export formats supported:
- TXT: Simple filename list for Lightroom filter paste
- CSV: Spreadsheet with metadata columns
- XMP: Sidecar files with ratings/labels for Lightroom/Capture One
- ZIP: Original photos with folder organization
- JSON: Structured metadata for automation

Key features:
- Admin filter UI with rating thresholds and feedback toggles
- AND/OR filter logic
- Quick presets (Guest Picks, Top Rated, Most Popular)
- Photo selection with batch actions
- XMP rating mapping (PicPeak 1-5 → XMP 1-5 + color labels)
- Background job support for large exports
- Export settings dialog with customization options

Research references:
- Adobe XMP/Lightroom metadata standards
- Capture One EIP format
- IPTC Photo Metadata Standard
- ExifTool capabilities
2026-01-02 00:00:00 +01:00
Paul Nothaft e0204aeeee Add planning document for optional event contact fields
Addresses GitHub issue #60 - making customer name, customer email,
and admin email fields optional when creating events.

This feature adds three new admin settings:
- event_require_customer_name (default: true)
- event_require_customer_email (default: true)
- event_require_admin_email (default: true)

Implementation includes:
- Database migration for new app_settings entries
- Backend conditional validation in event creation
- Frontend settings UI with toggle switches
- Dynamic form validation based on settings
- Warning messages for email-related settings
- Graceful handling of empty contact fields

Maintains backward compatibility with default behavior unchanged.
2026-01-01 23:52:07 +01:00
Paul Nothaft 7df481f7ea Add planning document for event rename feature
This document outlines the implementation plan for allowing administrators
to rename gallery events with full Option B implementation:

- Database updates (events, photos, new slug_redirects table)
- File system changes (folders and photo files)
- New API endpoint: POST /api/admin/events/:id/rename
- Frontend UI components (button, dialog, progress indicator)
- Email notification option for resending invitation
- Slug redirect support for backward compatibility
- Transaction handling with rollback mechanism

The feature includes:
- Rename button on event detail page
- Confirmation dialog with new name input
- Real-time slug preview
- Checkbox to resend invitation email
- Progress indicator during operation
- Redirect to renamed event on success
2026-01-01 23:50:28 +01:00
Paul Nothaft 03bd6cef93 Merge pull request #72 from criticalsool/patch-1
FIX BUG Syntax Error
2025-11-30 13:57:40 +01:00
Critical Sool da5ae0ef10 Update server.js 2025-11-29 15:32:42 +01:00
paul 7c7498385f Regenerate frontend package-lock to match package.json
Build and Push Docker Images / build-backend (push) Failing after 2m44s
Build and Push Docker Images / build-frontend (push) Failing after 11s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 18:44:38 +01:00
paul 1ae63890ff Fetch patched libpng from edge for frontend runtime
Build and Push Docker Images / build-backend (push) Failing after 15m39s
Build and Push Docker Images / build-frontend (push) Failing after 4m3s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 17:54:56 +01:00
Claude 5f1affafd8 Update frontend package-lock.json for npm compatibility
Regenerate lock file to include missing esbuild platform dependencies
required by newer npm versions.
2025-11-28 17:54:56 +01:00
Claude 8315c11d34 Update backend package-lock.json for npm compatibility
Regenerate lock file to include missing transitive dependencies
(encoding, iconv-lite) required by newer npm versions.
2025-11-28 17:54:36 +01:00
Claude 0043f2aaf4 Fix npm ci command for newer npm versions
Replace deprecated --only=production with --omit=dev flag
which is required for npm 10+ after the npm upgrade.
2025-11-28 17:54:36 +01:00
Claude d494eda301 Fix glob CVE-2025-64756 security vulnerability in Docker images
Upgrade npm to latest version in both backend and frontend Dockerfiles
to fix the command injection vulnerability in glob's CLI (CVE-2025-64756).
The vulnerability exists in npm's bundled glob package (< 10.5.0 or < 11.1.0).
2025-11-28 17:54:36 +01:00
Claude a59a4232ff Fix worker service and Docker storage permission issues (Issues #66, #67)
Issue #66: Remove redundant picpeak-workers.service creation from setup script.
Workers (fileWatcher, expirationChecker, emailProcessor) are now started
automatically by server.js, so a separate systemd service is not needed.
The legacy service cleanup code is retained for migration purposes.

Issue #67: Ensure storage directories exist at container startup in
wait-for-db.sh. When host directories are bind-mounted in Docker, the
container's built-in directories are overridden. This fix creates the
required directory structure (events/active, events/archived, thumbnails)
before the application starts, preventing EACCES permission errors.
2025-11-28 17:54:36 +01:00
Claude 77326a91ca Apply critical bug fixes from main to prevent merge regressions
This commit applies essential bug fixes from main branch to ensure no
regressions occur when merging the video-support branch:

1. Increase body parser limits from 100mb to 10gb for large video uploads
   - Updated express.json and express.urlencoded limits in server.js

2. Rename video migration from 047 to 048 to avoid conflict
   - Main branch already has 047_add_tls_reject_unauthorized.js
   - Prevents migration system from skipping one of the migrations

3. Fix category update logic with proper validation
   - Add updated_at timestamp to all category updates
   - Add explicit null handling for category_id
   - Add parseInt with radix parameter for numeric IDs
   - Add isNaN validation to prevent invalid values
   - Fix event_id constraint in single photo update query
   - Add parseInt to photoCount comparison for type safety

These fixes ensure all bug fixes from main branch (especially from
commit d91ab43) are preserved when the PR is merged.
2025-11-28 17:54:36 +01:00
Claude 0d95eab86a Add chunked upload support for large video files up to 10GB
- Increased max file size from 500MB to 10GB
- Created chunkedUploadService.js for managing chunked uploads
- Added chunked upload API endpoints (init, chunk, complete, status, abort)
- Added frontend chunked upload methods to photos.service.ts
- Files >100MB automatically use chunked uploads
- 10MB chunk size for reliable transfers
- Auto-cleanup of expired uploads after 24 hours
- Updated README with 10GB limit and nginx configuration example
2025-11-28 17:53:56 +01:00
Claude f3482a9a78 Update README with video support requirements and status
- Added Video Support Requirements section with resource recommendations
- Noted FFmpeg is bundled via npm (no system installation required)
- Listed supported formats and max file size
- Updated roadmap to mark Video Support as implemented
2025-11-28 17:53:56 +01:00
Claude 68a9dc5749 Add comprehensive video support to galleries
This commit implements full video upload, storage, streaming, and playback functionality
for the PicPeak photo sharing platform, allowing users to upload and view videos alongside
photos in galleries.

Backend Changes:
- Added video processing dependencies (fluent-ffmpeg, @ffmpeg-installer/ffmpeg)
- Created videoProcessor.js service for video metadata extraction and thumbnail generation
- Updated photoProcessor.js to handle both images and videos
- Modified adminPhotos.js to accept video files with 500MB size limit
- Enhanced gallery.js with HTTP range request support for video streaming
- Expanded fileSecurityUtils.js with video MIME types and magic number validation
- Added database migration for video support columns (media_type, duration, codecs, dimensions)

Frontend Changes:
- Updated TypeScript types to include video metadata fields
- Created VideoPlayer.tsx component with custom controls
- Modified PhotoUpload.tsx to accept video files (.mp4, .webm, .mov, .avi)
- Updated UserPhotoUpload.tsx for guest video uploads
- Enhanced PhotoGrid.tsx with video badges and duration display
- Modified PhotoLightbox.tsx to conditionally render VideoPlayer for videos

Database Schema:
- Added media_type column ('image' | 'video')
- Added mime_type, duration, video_codec, audio_codec columns
- Added width and height columns for media dimensions
- Migrated existing photos to media_type 'image'

Features:
- Video thumbnail generation from video frames
- Streaming support with range requests for efficient playback
- Video duration display on thumbnails
- Play button indicators on video items
- Full-featured video player with playback controls
- Support for MP4, WebM, MOV, and AVI formats
2025-11-28 17:53:56 +01:00
paul 8c87f1537b Resolve merge conflicts for video uploads and processing 2025-11-28 17:52:42 +01:00
paul 97e54355fb Update frontend runtime image to patched libpng
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2025-11-28 17:47:24 +01:00
paul 9a75f1c929 Add video support, media filters, and translations
Build and Push Docker Images / build-backend (push) Failing after 14m2s
Build and Push Docker Images / build-frontend (push) Failing after 44m43s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 13:29:44 +01:00
paul bce5f749b1 Merge remote-tracking branch 'upstream/main'
Build and Push Docker Images / build-backend (push) Failing after 13m5s
Build and Push Docker Images / build-frontend (push) Failing after 2m55s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-25 22:43:48 +02:00
Paul Nothaft 584cfb11df Merge pull request #65 from the-luap/claude/investigate-issue-62-01GFcj5uDDgojgX5D8ytoF9W
Fix security vulnerabilities detected by Trivy
2025-11-25 21:41:45 +01:00
Claude f327f4cbcd Update package-lock.json files to sync with security overrides 2025-11-25 20:40:29 +00:00
Claude 14c4bc17f3 Fix security vulnerabilities detected by Trivy
- CVE-2025-64756: glob CLI command injection - added override to use glob ^11.1.0
- CVE-2025-13466: body-parser DoS - added override to use body-parser ^2.2.1
- CVE-2025-64718: js-yaml prototype pollution - updated to js-yaml ^4.1.1
- BusyBox vulnerabilities (netstat, tar) - added apk upgrade to all Dockerfiles

Changes:
- backend/package.json: Updated js-yaml, added overrides for glob, body-parser
- frontend/package.json: Added overrides for glob, js-yaml
- All Dockerfiles: Added 'apk upgrade --no-cache' to get latest security patches
- backend/Dockerfile.dev: Updated from node:18-alpine to node:20-alpine
2025-11-25 20:35:59 +00:00
Paul Nothaft 3d0a4564b6 Merge pull request #64 from the-luap/claude/investigate-issue-62-01GFcj5uDDgojgX5D8ytoF9W
Add option to ignore SSL/TLS certificate errors for email (Issue #53)
2025-11-25 21:31:22 +01:00
Claude e85d1bf72a Add option to ignore SSL/TLS certificate errors for email (Issue #53)
This feature allows users with non-standard SMTP setups (shared hosting,
self-signed certificates) to bypass certificate validation when needed.

Changes:
- Add database migration for tls_reject_unauthorized column
- Update emailProcessor.js to pass TLS option to nodemailer
- Update adminEmail.js routes to handle the new field
- Add checkbox UI with security warning in EmailConfigPage
- Add English and German translations
2025-11-25 20:23:39 +00:00
paul bd3aa6206b Add CLAUDE.md guidance and ignore locally
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2025-11-25 22:18:54 +02:00
paul a971eee7b9 Merge remote-tracking branch 'origin/main'
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has started running
2025-11-25 22:03:02 +02:00
paul 8e8dd358bf Merge remote-tracking branch 'upstream/main' 2025-11-25 22:02:23 +02:00
Paul Nothaft ee1aa7e5cb Merge pull request #63 from the-luap/claude/prioritize-bugs-01QQsR6rU9MKPE7jEy2Ey8dM
Fix multiple bugs: thumbnail generation, branding settings, categorie…
2025-11-25 20:59:35 +01:00
Claude f446335e81 Fix CI/CD: Build amd64 only for PRs to avoid QEMU ARM64 emulation issues
Sharp library native binaries cause QEMU 'Illegal instruction' errors during
ARM64 emulation. This change builds only amd64 for PR checks (faster, reliable)
while maintaining multi-arch (amd64+arm64) builds for main/develop/tags.
2025-11-25 19:55:57 +00:00
Claude d91ab436e8 Fix multiple bugs: thumbnail generation, branding settings, categories, theme, feedback icons, upload limit, email errors
Bug fixes included:

#52 - Thumbnail Generation: Added proper parsing of settings values and validation
      of Sharp fit parameter to handle JSON-encoded strings correctly

#61 - Branding Settings Not Persisting: Added _parseBoolean helper for reliable
      boolean parsing, added hide_powered_by option for white-label support

#55 - Categories Not Applied: Fixed category update logic to properly handle
      numeric category IDs, added updated_at timestamp, improved cache invalidation

#59/#56 - Gallery Layout & Apply Theme: Set isPreviewMode=true so theme changes
      immediately propagate to parent state, hidden redundant Apply button

#58 - Feedback Icons Show When Disabled: Added feedbackEnabled check to comment
      and like buttons in MasonryGalleryLayout and GridGalleryLayout

#57 - Upload Limit 100MB: Increased body parser limit from 100MB to 500MB to
      support larger batch uploads

#54 - Wrong Error Message: Enhanced email error handling with specific error
      codes and translation keys for better user feedback
2025-11-25 19:31:07 +00:00
Paul Nothaft 0745b11745 Merge pull request #51 from the-luap/claude/fix-issues-49-50-01Rqwe1uhvLpbZ64tA5eiB2H
Fix issues #49 and #50: Migration errors and missing worker manager
2025-11-19 23:05:48 +01:00
Claude 97589a7c5f Fix issues #49 and #50: Migration errors and missing worker manager
- Fix #49: Add column existence checks to migration 011_add_user_upload_settings.js
  to prevent "column already exists" errors during deployment
- Fix #50: Create missing workerManager.js file that starts background services
  (file watcher and expiration checker) for native installations
2025-11-19 21:59:27 +00:00
Paul Nothaft 9f04da6956 Merge pull request #47 from the-luap/claude/investigate-issues-22-011CUoRMw67THYkdYBdVgG2Z
Fix Issue #46 - Docker OCI Runtime Error
2025-11-06 21:33:53 +01:00
Claude 62e6a67cb7 Remove inline comments from docker-compose files 2025-11-06 19:55:17 +00:00
Claude b2ce011545 Fix issue #46: Docker OCI runtime error with sysctl permissions
Resolves container startup failures on Docker hosts with custom sysctl
configurations at the daemon level.

Problem:
When Docker daemon is configured with sysctl flags (commonly
net.ipv4.ip_unprivileged_port_start or net.ipv4.ping_group_range),
these settings are inherited by containers. Alpine-based containers
running as non-root users (postgres:15-alpine, redis:7-alpine) lack
the privileges to apply these kernel parameters during initialization,
causing OCI runtime errors:

  "unable to start container process: error during container init:
   open sysctl net.ipv4.ip_unprivileged_port_start file: reopen fd 8:
   permission denied"

Root Cause:
- Docker daemon has system-level sysctl configurations
- Containers attempt to inherit these settings during init
- Alpine-based images run as non-root by default
- Non-root users cannot modify kernel parameters
- Container init fails before application starts

Why Only PostgreSQL and Redis Failed:
- Both use Alpine-based official images
- Both run as non-root users for security
- Backend/frontend either run as root initially or use different
  base images with different security contexts

Solution:
Added 'userns_mode: "host"' to postgres and redis services in both
docker-compose.yml and docker-compose.production.yml

This configuration:
- Uses host's user namespace instead of creating isolated namespace
- Bypasses sysctl permission restrictions
- Maintains container isolation at network and filesystem levels
- Does NOT compromise security (services remain internal)
- Is production-safe and widely used for database containers

Security Analysis:
 SAFE: postgres and redis are internal services, not exposed directly
 SAFE: Network isolation remains intact via bridge network
 SAFE: Filesystem isolation remains via volume mounts
 SAFE: No privileged mode or capability additions required
 SAFE: Does not affect frontend/backend security posture

Alternative Solutions Considered:

1. privileged: true
    REJECTED: Too permissive, grants unnecessary capabilities

2. security_opt: ["apparmor:unconfined"]
    REJECTED: Disables important security constraints

3. Host network mode
    REJECTED: Breaks container networking isolation

4. Custom sysctls
    REJECTED: Requires privileged mode, not portable

5. Documentation only
    REJECTED: Forces users to modify Docker daemon config

Benefits:
 Works on hosts with custom Docker daemon sysctl configs
 Works on hosts with default Docker configurations
 No user intervention required
 No Docker daemon reconfiguration needed
 Production-ready and tested
 Maintains all security boundaries that matter
 Fixes both development and production environments

Testing:
Tested on:
- Debian 12 with Docker 28.5.2 (reported environment)
- Standard Docker installations
- Docker with user namespace remapping enabled
- Docker with custom sysctl configurations

Environment Details from Issue:
- OS: Debian GNU/Linux 12 (bookworm)
- Docker: version 28.5.2
- Docker Compose: v2.40.3
- Error: OCI runtime create failed during container init

Documentation:
Added inline comments in both compose files referencing this issue
for future maintainers.

Fixes #46
2025-11-06 14:36:04 +00:00
Paul Nothaft 2f0fd7e360 Merge pull request #45 from the-luap/claude/investigate-issues-22-011CUoRMw67THYkdYBdVgG2Z
Fix Critical Bugs in Issues #22 and #30
2025-11-04 21:25:52 +01:00
Claude ae93755dbb Fix GitHub Actions Docker tag generation
The workflow was generating invalid Docker tags with format ':-3b251d7'
due to empty branch names in PR contexts.

Problem:
- Tag config: type=sha,prefix={{branch}}-,format=short
- For PRs: {{branch}} is empty → results in ':-3b251d7' (invalid)
- Docker doesn't allow tags starting with hyphen

Solution:
- Changed to: type=sha,format=short
- Now generates: '3b251d7' (valid) without branch prefix
- Works correctly for PRs, branches, and tags

Valid tag examples now:
- PRs: pr-44, 3b251d7
- Branches: main, 3b251d7
- Tags: v1.0.0, 1.0, 1, 3b251d7
2025-11-04 20:08:47 +00:00
Claude b2626918d3 Fix issue #30: Critical bugs in Reference (external folder) mode
This commit fixes the core bugs that prevented Reference mode from functioning:

1. Missing external_relpath Error (CRITICAL FIX)
   - Root cause: photoResolver prioritized event.source_mode over photo.source_origin
   - Problem: Events in "reference" mode with uploaded photos would fail
     because uploaded photos have source_origin='managed' but were being
     treated as external photos (requiring external_relpath)
   - Fix: Prioritize photo.source_origin over event.source_mode
   - Result: Events can now have MIXED sources - imported external photos
     AND newly uploaded managed photos coexisting correctly
   - File: backend/src/services/photoResolver.js:19

2. Category Assignment Failure (CRITICAL FIX)
   - Root cause: Update endpoints modified category_id column but display
     used photo.type field ('individual' or 'collage')
   - Problem: Category changes appeared to succeed but had no visible effect
   - Fix: When category_id is 'individual' or 'collage', update the type
     field instead of category_id
   - Result: Category assignments now work correctly for all photos
   - Files: backend/src/routes/adminPhotos.js:489-497, 605-607

3. Scroll Button Non-Functional (UX FIX)
   - Root cause: Scroll indicator was purely visual (no click handler)
   - Problem: Users expected to click the animated chevron to scroll
   - Fix: Convert div to button with smooth scroll to grid section
   - Result: Scroll button now functions as expected with proper a11y
   - File: frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx:165-184

Technical Details:

Mixed Source Support:
The photoResolver now correctly handles events that mix:
- External photos: source_origin='external' + external_relpath set
- Uploaded photos: source_origin='managed' + path in storage/events/active
This allows users to start with external media import and later upload
additional photos without errors.

Category/Type Distinction:
The system uses photo.type ('individual'|'collage') for display but also
has a legacy category_id column. The update logic now handles both:
- String values 'individual'/'collage' → update type field
- Numeric values → update legacy category_id field (backward compat)

Notes on Remaining Issues:

Issue #30 also mentioned:
4. Image display (cropped square) - This is by design. Thumbnails use
   fit='cover' by default for consistent grid layouts. Can be changed
   via app_settings.thumbnail_fit if needed.

5. Theme application - The "Apply Theme" button updates the form state
   correctly. Users need to click "Save Changes" to persist to database.
   This is standard form behavior, not a bug.

Testing:
- Create event in reference mode with external media
- Upload new photos to the same event → verify no external_relpath error
- Change categories on both external and uploaded photos → verify changes apply
- Use Hero gallery layout → verify scroll button works

Fixes #30
2025-11-04 20:05:44 +00:00
Claude 41628b0578 Remove documentation file 2025-11-04 19:56:08 +00:00
Claude 8826fb7a12 Fix issue #22: Gallery filter counts disappearing and upload errors
This commit comprehensively addresses the persistent issues reported in #22:

1. Gallery Filter Bug - Counts Disappearing
   - Root cause: Frontend fetched filtered photos from backend, then
     calculated counts from already-filtered data
   - Fix: Always fetch ALL photos, apply filtering client-side only
   - Benefits: Counts always accurate, filters work correctly in combo
   - Changed: frontend/src/components/gallery/GalleryView.tsx:76

2. Upload ENOENT Errors
   - Root cause: /tmp/uploads/ directory assumed to exist
   - Fix: Verify and create temp directory before multer initialization
   - Changed: backend/src/routes/gallery.js:814-825

3. Upload "Not Iterable" Errors
   - Root cause: normalizeFiles() didn't handle null/edge cases
   - Fix: Enhanced error handling with try-catch and graceful degradation
   - Changed: backend/src/services/photoProcessor.js:10-52

4. Enhanced Upload Debugging
   - Added file existence verification before copy operations
   - Improved temp file cleanup (properly handle ENOENT)
   - Comprehensive error logging with full context
   - Changed: backend/src/services/photoProcessor.js:108-233

Technical Details:
- Gallery filtering now entirely client-side (simpler architecture)
- Upload error messages now include full diagnostic context
- Temp file cleanup handles ENOENT gracefully (expected scenario)
- All fixes preserve backward compatibility

Testing:
- Gallery filters: Verify counts stay visible when filtering
- Uploads: Test single/batch uploads, check temp cleanup
- Logs: Verify detailed error context on failures

See ISSUE_22_FIX_SUMMARY.md for complete analysis and testing guide.

Fixes #22
2025-11-04 19:52:11 +00:00
Gitea Actions Bot f29e9db99d chore: bump version to 1.1.15 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-29 11:30:46 +00:00
paul 81416737e8 chore: remove sensitive files for GitHub mirror 2025-10-29 11:29:50 +00:00
paul d2e97567a9 Merge pull request 'Fix mobile overlay and deps per #43' (#3) from fix/gallery-mobile into main
Test and Lint / backend-test (push) Successful in 1m24s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Reviewed-on: #3
2025-10-29 12:25:38 +01:00
paul 69538b86ea Fix mobile overlay and deps per #43
Test and Lint / backend-test (pull_request) Successful in 1m24s
Test and Lint / frontend-test (pull_request) Successful in 1m59s
continuous-integration/drone/pr Build is passing
2025-10-29 12:19:43 +01:00
paul f6f1c31369 Fix mobile overlay and deps per #43
continuous-integration/drone/pr Build is failing
Test and Lint / backend-test (pull_request) Successful in 2m10s
Test and Lint / frontend-test (pull_request) Successful in 2m0s
2025-10-29 11:11:53 +01:00
Gitea Actions Bot b76e45cb54 chore: bump version to 1.1.14 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-20 12:41:34 +00:00
Paul Nothaft 5b5e431b08 Implement per-IP gallery lockouts and UI controls (#42)
Test and Lint / backend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m50s
2025-10-20 14:35:23 +02:00
Gitea Actions Bot 07759a0e40 chore: bump version to 1.1.13 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-15 05:29:19 +00:00
Paul Nothaft 31fd64c83c Add short gallery URL toggle and token support (#38)
Test and Lint / backend-test (push) Successful in 1m55s
Test and Lint / frontend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
2025-10-15 07:21:09 +02:00
Paul Nothaft 775c5159ea Add customer contact fields and admin API docs (refs #41) 2025-10-14 18:29:21 +02:00
Paul Nothaft 8f297e25c4 Make photo upload limit configurable via admin settings (#40) 2025-10-14 16:27:44 +02:00
Paul Nothaft ccb65b892b Rename setup script and bump installer version (#39) 2025-10-14 15:48:55 +02:00
Paul Nothaft 52f8f1f738 Upgrade nodemailer to 7.0.7 (GHSA-mm7p-fcc7-pg87)
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 21:11:38 +02:00
Paul Nothaft e731e7b47c Address tar-fs CVE-2025-59343
Test and Lint / backend-test (push) Successful in 1m21s
Test and Lint / frontend-test (push) Has been cancelled
2025-10-13 21:09:52 +02:00
Paul Nothaft 2bccb1a439 Handle pre-existing docker app dir (#32)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:59:50 +02:00
Paul Nothaft df10fc677e Send gallery image requests with bearer token fallback (#31)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:29:07 +02:00
Gitea Actions Bot 8c690155bf chore: bump frontend version to 1.1.12 2025-10-13 18:19:57 +00:00
Paul Nothaft 1b1e4f715d Rename event owner fields to customer (#37)
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 20:09:28 +02:00
Paul Nothaft 68eb9ba552 Clarify event owner labeling in UI (#37)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m52s
2025-10-13 20:02:52 +02:00
Paul Nothaft 7040865154 Fix admin password reset guidance in setup.sh (#34)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m56s
2025-10-13 19:58:07 +02:00
Paul Nothaft 013be18d98 fix: clear notifications via API (#35)
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 17:41:06 +02:00
Paul Nothaft 3c2a79a31a feat: allow admin email updates in UI (#36) 2025-10-13 17:21:03 +02:00
Gitea Actions Bot f20472ca26 chore: bump version to 1.1.11 (backend + frontend) 2025-10-12 19:23:19 +00:00
paul a1e9fb6ffc Fix setup clone path conflict for issue #32 2025-10-12 21:20:36 +02:00
Gitea Actions Bot 87f4526220 chore: bump version to 1.1.10 (backend + frontend) 2025-10-12 19:18:37 +00:00
paul 665ce5a6e7 Fix issues #31 #33 #34 #35 #36 2025-10-12 21:03:07 +02:00
Gitea Actions Bot d42a11680f chore: bump version to 1.1.9 (backend + frontend) 2025-10-06 13:18:43 +00:00
paul 8c41dd626d Fix hero layout tile sizing and scroll hook 2025-10-06 15:15:34 +02:00
Gitea Actions Bot 38dd74b893 chore: bump version to 1.1.8 (backend + frontend) 2025-10-03 05:19:52 +00:00
paul 775e417e55 Fix admin reference mode regressions 2025-10-02 23:39:17 +02:00
paul fc1bf53412 fix: harden gallery downloads and per-gallery auth
Test and Lint / backend-test (push) Successful in 1m57s
Test and Lint / frontend-test (push) Successful in 1m57s
2025-10-01 16:00:37 +02:00
paul 5d6c061f1c feat: support per-gallery password toggle 2025-10-01 16:00:37 +02:00
Gitea Actions Bot 45e835a51a chore: bump frontend version to 1.1.7 2025-09-27 06:14:25 +00:00
Gitea Actions Bot afc00090cf chore: bump frontend version to 1.1.6 2025-09-27 06:11:45 +00:00
paul 59750dea15 Enforce mandatory gallery passwords in UI
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 1m51s
2025-09-27 08:05:59 +02:00
Gitea Actions Bot 2fe32e9a69 chore: bump backend version to 1.1.5 2025-09-27 05:59:32 +00:00
paul 5f8c8c5508 Fix branding asset storage path
Test and Lint / backend-test (push) Successful in 1m47s
Test and Lint / frontend-test (push) Successful in 1m56s
2025-09-26 17:26:39 +02:00
Gitea Actions Bot fb739f221d chore: bump version to 1.1.4 (backend + frontend) 2025-09-24 15:39:34 +00:00
paul b5399aaa9b Add installer flag to regenerate admin credentials
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 1m54s
2025-09-24 17:33:54 +02:00
Gitea Actions Bot a4595e2ab2 chore: bump backend version to 1.1.3 2025-09-22 20:50:54 +00:00
paul 0911711a37 Deduplicate external media imports by filename
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m5s
2025-09-22 22:44:52 +02:00
paul f2c7594b23 Refetch gallery data after lightbox feedback (#29)
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m31s
2025-09-22 22:32:56 +02:00
paul 32355fabad Revert "Ignore local Playwright tests directories"
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m9s
This reverts commit c127fd829d.
2025-09-22 21:31:28 +02:00
paul c127fd829d Ignore local Playwright tests directories
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m8s
2025-09-22 21:30:41 +02:00
Gitea Actions Bot cab5b0d795 chore: bump backend version to 1.1.2 2025-09-22 17:08:29 +00:00
paul ba95aad3c6 Switch backend image to Node 20 to address cross-spawn CVE
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m6s
2025-09-22 19:03:33 +02:00
paul c1be7d6785 Harden photo resolver path handling
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Has been cancelled
2025-09-22 18:59:49 +02:00
paul 0024686dc2 Align simple setup storage paths (#27)
Test and Lint / backend-test (push) Successful in 1m46s
Test and Lint / frontend-test (push) Successful in 2m12s
2025-09-22 18:54:13 +02:00
paul 96b8b77792 Fix release workflow when tag already exists
Test and Lint / backend-test (push) Successful in 1m35s
Test and Lint / frontend-test (push) Successful in 2m10s
2025-09-22 14:46:45 +02:00
Gitea Actions Bot 9d2726b3d3 chore: bump frontend version to 1.1.1 2025-09-22 12:41:16 +00:00
paul 8d6ddd257d Fix gallery login persistence and favorites (#29)
Test and Lint / backend-test (push) Successful in 2m6s
Test and Lint / frontend-test (push) Successful in 2m16s
2025-09-22 14:33:11 +02:00
Gitea Actions Bot e0865b81b6 chore: bump backend version to 1.1.1 2025-09-21 20:47:47 +00:00
paul d4404e39bd fix: prefer admin token on admin routes (#23 #28)
Test and Lint / backend-test (push) Successful in 2m9s
Test and Lint / frontend-test (push) Successful in 2m31s
2025-09-21 22:37:30 +02:00
paul 8611206396 Fix PicPeak regressions and close #22 #24 #25 #26 #27 #28 2025-09-21 22:03:07 +02:00
paul 39d2244e1e chore: switch versioning workflows to manual triggers 2025-09-19 22:31:55 +02:00
Gitea Actions Bot eb626be22c chore: bump version to 1.0.130 (backend + frontend) 2025-09-19 14:47:37 +00:00
paul aaaf59817b fix: stabilize uploads and guest feedback filters
Mirror to GitHub / mirror (push) Successful in 1m54s
Test and Lint / backend-test (push) Successful in 1m51s
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 1m49s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-19 16:39:18 +02:00
paul 2a4d38813f feat: overhaul public landing page and backup tooling 2025-09-19 16:39:18 +02:00
Gitea Actions Bot ad9c6d63d3 chore: bump backend version to 1.0.129 2025-09-18 14:54:28 +00:00
paul 8c77b30de6 Default auth cookies to non-secure for HTTP installs
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m36s
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-18 16:48:14 +02:00
Gitea Actions Bot e51347d0a1 chore: bump version to 1.0.128 (backend + frontend) 2025-09-18 14:05:43 +00:00
paul 71e7179145 Harden auth cookies and fix native schema for event creation
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-18 15:59:26 +02:00
Gitea Actions Bot bda76ff513 chore: bump backend version to 1.0.125 2025-09-18 10:49:54 +00:00
paul 097ce2c205 Fix native install schema gaps (closes #20)
Mirror to GitHub / mirror (push) Successful in 1m38s
Test and Lint / backend-test (push) Successful in 1m44s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Failing after 1m20s
Version and Release / trigger-drone (push) Has been skipped
2025-09-18 12:42:05 +02:00
Gitea Actions Bot 1d8be3d840 chore: bump frontend version to 1.0.127 2025-09-17 21:40:18 +00:00
paul aebb8e66cb Make lightbox feedback panel sticky on desktop
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m35s
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 1m17s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-17 23:33:43 +02:00
Gitea Actions Bot ed2a278da2 chore: bump frontend version to 1.0.126 2025-09-17 21:22:44 +00:00
paul db2f5da66a Ensure gallery comment filter hides moderated comments
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m38s
Test and Lint / frontend-test (push) Successful in 2m25s
Version and Release / version-bump (push) Successful in 1m46s
Version and Release / trigger-drone (push) Successful in 4s
2025-09-17 23:15:28 +02:00
paul 19f8facc49 Refine gallery feedback actions
Mirror to GitHub / mirror (push) Successful in 2m10s
Test and Lint / backend-test (push) Successful in 1m48s
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Failing after 50s
Version and Release / trigger-drone (push) Has been skipped
2025-09-17 22:27:13 +02:00
paul b03760ab01 feat(gallery/filters): add Rated and Commented filters (UI + backend).\n\n- UI: add star (Rated) and message (Commented) buttons to feedback filter bars (desktop + mobile)\n- Backend: support filter=rated, commented, and combinations via aggregate counts/queries 2025-09-16 10:11:08 +02:00
paul 526dcd8dfc fix(gallery/filters): always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier 2025-09-16 09:57:35 +02:00
paul 5b2561b6f1 fix(gallery/filters): make feedback filters work globally when no guest_id is provided; remove guest_id from client photos query\n\n- Backend /api/gallery/:slug/photos: if filter present and guest_id missing, filter by like_count/favorite_count\n- Frontend useGalleryPhotos: stop passing random guestId (does not match server guest_identifier)\n\nThis makes Liked/Favorited filters reflect photos with aggregate feedback counts as expected. 2025-09-16 09:28:12 +02:00
paul 3a6d06192a fix(gallery): feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) 2025-09-16 09:15:47 +02:00
paul 4b64b80b20 ui(gallery): feedback filter headline + horizontal compact icons (desktop+mobile); render only when feedback enabled 2025-09-16 09:11:32 +02:00
paul ff89f96e31 fix(gallery/sidebar): compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact 2025-09-16 09:03:24 +02:00
paul 465f997752 feat(gallery): compact vertical icon-only feedback filter in PhotoFilterBar; remove wide buttons to prevent overflow\n\n- Desktop: vertical icon stack (All/Grid, Likes, Favorites) outside scroll area\n- Mobile: vertical icon stack below categories\n- Keeps existing category bar layout and count\n\nRefs: #19
Mirror to GitHub / mirror (push) Successful in 1m57s
Test and Lint / backend-test (push) Successful in 1m49s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Failing after 55s
Version and Release / trigger-drone (push) Has been skipped
2025-09-15 22:59:21 +02:00
paul 6948aaa92a feat(gallery): always-visible feedback indicators on grid tiles; fallback image rendering in lightbox/hero; auto-auth from shared-link token; fix external photo resolver\n\n- GridGallery: bottom-left icons for like/rated/comment on every tile\n- Hero layout grid: added same indicators (non-intrusive icons)\n- Lightbox/Hero: add fallbackSrc to display thumbnail if original fails\n- GalleryAuth: auto-store token from /gallery/:slug/:token and hydrate event\n- Backend gallery photo route: use resolvePhotoFilePath for external-media\n\nfix(admin): move photo feedback badges to bottom-right on admin grid tiles\n\nfix(dashboard): add missing i18n keys for activity types + fallback to formatter\n\nfix(admin/feedback): correct thumbnail URL base + robust date parsing\n\nRefs: #19 2025-09-15 22:59:21 +02:00
paul 4c7b49a5f6 fix(admin/feedback): use correct event id when rendering photo thumbnails
- Replace undefined eventId with route param id to build admin thumbnail URL
- Fixes runtime ReferenceError on /admin/events/:id/feedback when opening Feedback tab

Refs: #19
2025-09-15 22:59:21 +02:00
paul 6368f1027f feat(gallery): add quick Like/Favorite actions on thumbnails across layouts
- Grid, Masonry, Mosaic, Timeline, Hero, and Carousel layouts now expose inline Like/Favorite buttons when feedback is enabled
- Respect requireNameEmail; prompt via identity modal before submitting feedback
- Wire feedback settings from GalleryView -> layouts via feedbackOptions

feat(lightbox): keep feedback usable while navigating

- Add initialShowFeedback prop; preserve panel state across navigation
- Offset Next button when feedback panel is open so it remains accessible
- Hide/avoid overlapping nav on small screens

Refs: #19
2025-09-15 22:59:21 +02:00
paul d64e7d08de feat(admin): refine header layout and logo placement
- Left-align logo across breakpoints; remove duplicate centered/mobile blocks
- Add date separator and spacing; keep header compact and readable

fix(admin): prevent category badge overlap in grid

- Move badge to top-left; make non-interactive; constrain width to avoid checkbox collisions

chore(docker): support ADMIN_PASSWORD in docker-compose

- Allow setting initial admin password via env for easier provisioning

chore(backend): normalize EOF newline in set-admin-password.js

Refs: admin-header-layout, category-badge-overlap, docker-admin-password
2025-09-15 22:59:21 +02:00
Gitea Actions Bot eb3751cb52 chore: bump frontend version to 1.0.125 2025-09-14 15:08:14 +00:00
paul 9fda54bd06 feat(select): add per-tile checkbox selection in Admin grid and all gallery layouts; tile click opens viewer; checkbox toggles selection; auto-enable selection mode; add testids
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 17:02:45 +02:00
Gitea Actions Bot 0d77a3a0a8 chore: bump version to 1.0.124 (backend + frontend) 2025-09-14 14:25:37 +00:00
paul 0618b78725 feat(setup/docker): auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 1m3s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 16:20:08 +02:00
paul 0178e71c67 docs: add PUID/PGID note for Docker bind mounts to avoid permission issues
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m16s
2025-09-14 16:10:46 +02:00
Gitea Actions Bot aa9b3a0227 chore: bump version to 1.0.123 (backend + frontend) 2025-09-14 13:53:37 +00:00
paul 410a33fecf feat(docker): add PUID/PGID and user mapping to avoid bind mount permission issues; feat(setup): prompt for admin email interactively; docs: PUID/PGID in .env.example
Mirror to GitHub / mirror (push) Successful in 1m41s
Test and Lint / backend-test (push) Successful in 1m47s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 15:46:35 +02:00
Gitea Actions Bot 05ebaaeedb chore: bump version to 1.0.122 (backend + frontend) 2025-09-09 18:54:09 +00:00
paul 84d0f63d36 feat(setup): remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands
Mirror to GitHub / mirror (push) Successful in 37s
Test and Lint / backend-test (push) Successful in 1m29s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:48:49 +02:00
Gitea Actions Bot 6a4b549d9f chore: bump version to 1.0.121 (backend + frontend) 2025-09-09 18:45:28 +00:00
paul f3604b438b fix(native): remove obsolete workers service; restart only backend; add API request logging and preflight handler; keep static assets outside CORS
Mirror to GitHub / mirror (push) Successful in 33s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 56s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:40:17 +02:00
Gitea Actions Bot 531831e84b chore: bump backend version to 1.0.120 2025-09-09 18:28:39 +00:00
paul 90bb21e38b fix(cors): scope CORS to /api only and avoid throwing on disallowed origins; prevents static asset 500s on native
Mirror to GitHub / mirror (push) Successful in 37s
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 52s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:23:25 +02:00
paul 2f1a137342 ci: make ghcr login non-fatal and gate pushes/scans on login success; build images regardless (supports transient GHCR outages)
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 44s
Version and Release / trigger-drone (push) Has been skipped
2025-09-09 20:12:15 +02:00
paul adf576fbe1 fix(setup/update): detect native installs first (/opt/picpeak/app/backend or systemd unit); avoid false docker updates on root
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m29s
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
2025-09-09 20:08:51 +02:00
Gitea Actions Bot 4264026bbe chore: bump backend version to 1.0.119 2025-09-09 18:06:15 +00:00
paul 24b4a314a9 fix(native/http): disable CSP upgrade-insecure-requests and HSTS unless ENABLE_HSTS=true; prevents HTTPS upgrades on HTTP installs
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 1m3s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:00:48 +02:00
Gitea Actions Bot ba825823a0 chore: bump backend version to 1.0.118 2025-09-09 17:58:50 +00:00
paul fb16b7bbb8 feat(native): auto-serve SPA when dist exists (unless SERVE_FRONTEND=false); add clear logging; serve index.html for /admin
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 59s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 19:52:46 +02:00
Gitea Actions Bot 8404125ff0 chore: bump version to 1.0.117 (backend + frontend) 2025-09-09 17:10:38 +00:00
paul 61ad2d61c1 feat(native): serve built frontend from backend; build frontend during install/update; ensure env flags (SERVE_FRONTEND, FRONTEND_DIR)
Mirror to GitHub / mirror (push) Successful in 43s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 19:04:39 +02:00
Gitea Actions Bot 9fd6b44487 chore: bump version to 1.0.116 (backend + frontend) 2025-09-09 15:47:41 +00:00
paul 9fe10bcce2 feat(native): build frontend and serve SPA from backend (SERVE_FRONTEND); fix Cannot GET /admin on native installs
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m7s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 17:41:41 +02:00
Gitea Actions Bot f2abb40987 chore: bump version to 1.0.115 (backend + frontend) 2025-09-09 15:32:47 +00:00
paul 3697344cd0 fix(setup/native): handle forced updates safely by fetch+checkout/reset instead of pull; stable on rewritten histories
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m29s
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 17:27:27 +02:00
Gitea Actions Bot 4aa0ff705f chore: bump version to 1.0.114 (backend + frontend) 2025-09-09 15:25:05 +00:00
paul dc482e614a fix(setup/native): Debian 12 compatibility (reliable RAM detection, sudo-less run_as_user, git safe.directory); ensure SQLite data dir; use user for migrate
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 17:19:03 +02:00
Gitea Actions Bot 448882cfef chore: bump version to 1.0.113 (backend + frontend) 2025-09-09 13:31:10 +00:00
paul 7f9cb33a40 chore(native): ensure SQLite data dir exists in setup and at runtime; keep native paths consistent under /opt/picpeak/app
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m38s
Test and Lint / frontend-test (push) Successful in 2m3s
Version and Release / version-bump (push) Successful in 59s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 15:25:04 +02:00
Gitea Actions Bot 798f6211e0 chore: bump version to 1.0.112 (backend + frontend) 2025-09-09 09:46:52 +00:00
paul b992b151d3 fix(native): correct setup paths to /opt/picpeak/app, update repo URL, add sqlite prod support; docs path fixes
Mirror to GitHub / mirror (push) Successful in 1m27s
Test and Lint / backend-test (push) Successful in 1m49s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m8s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 11:25:41 +02:00
paul 87b8414e44 fix(setup/native): correct repo URL, paths, and systemd for native install; support sqlite in production knex config 2025-09-09 11:13:01 +02:00
paul ee13556c5c docs(readme): reflect new External Media reference mode and update roadmap (gallery feedback status)
Mirror to GitHub / mirror (push) Successful in 36s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m14s
2025-09-06 10:16:08 +02:00
Gitea Actions Bot afeb35a446 chore: bump version to 1.0.111 (backend + frontend) 2025-09-06 07:22:38 +00:00
paul ab324f1928 fix(frontend): add missing externalMedia service and mount admin external-media routes; verify Vite build
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 1m37s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-06 09:15:49 +02:00
Gitea Actions Bot 78ab0ad2e9 chore: bump version to 1.0.110 (backend + frontend) 2025-09-05 22:06:43 +00:00
paul 49c77785e7 feat(admin): external media import modal + thumbnail fixes for reference events\n\n- Photos tab: replace inline external folder picker with a modal opened via "Import from External Folder" button next to "Upload Photos"; add info that all pictures in the selected folder will be imported.\n- Admin thumbnails: align list endpoint to /api/admin/photos/:eventId/photos and always return thumbnail_url to trigger on-demand generation; normalize external paths to avoid duplicated folder segments (e.g., individual/individual) that broke resolver; improve thumbnail logging.\n- Use authenticated image fetching on admin feedback pages to prevent 401s in automation.\n- i18n: add backup.external.warning strings; complete German backup/restore coverage; add common keys (notSet, of, up, select, selected).\n- Docs: add Local (npm) setup for EXTERNAL_MEDIA_ROOT in deployment guide.\n\nRefs #17 – gallery feature request: https://github.com/the-luap/picpeak/issues/17
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Failing after 1m50s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-05 23:44:30 +02:00
Gitea Actions Bot 1d826accdc chore: bump version to 1.0.109 (backend + frontend) 2025-09-05 13:07:58 +00:00
paul ceefe4f5a7 chore: normalize .gitignore after cleanup
Mirror to GitHub / mirror (push) Successful in 49s
Test and Lint / backend-test (push) Successful in 1m52s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m4s
Version and Release / trigger-drone (push) Successful in 4s
2025-09-05 15:01:16 +02:00
paul e9171c7115 docs: follow-up on PR #15 — clarify VITE_API_URL usage, compose mounts, and admin routing (refs #15) 2025-09-05 15:01:16 +02:00
paul 758c085467 docs: clarify VITE_API_URL usage; remove FRONTEND_API_URL; add storage vars; simplify compose mounts and external DB example (refs #18) 2025-09-05 15:01:16 +02:00
paul ecbc48815d docs(compose): fix backend healthcheck path; remove frontend VITE_API_URL env and document /api proxy (refs #18) 2025-09-05 15:01:16 +02:00
paul e91b138154 chore: remove unintended local artifacts and SQLite DB; update .gitignore (refs #18) 2025-09-05 15:01:16 +02:00
paul dad1787aad docs: fix deployment/admin routing and CORS guidance; add AGENTS.md; ignore AGENTS.md (refs #18) 2025-09-05 15:01:16 +02:00
paul 909e760447 feat: implement gallery logo customization (Issue #17)
Added comprehensive logo customization features for gallery views:
- Logo size options (small, medium, large, xlarge, custom)
- Logo position control (left, center, right)
- Display mode settings (logo only, text only, logo and text)
- Visibility controls for header and hero sections
- Custom height configuration for fine-tuning

Changes:
- Added database migration for 6 new logo customization settings
- Extended backend APIs to handle logo customization fields
- Updated GalleryLayout.tsx with dynamic logo rendering logic
- Added logo upload functionality to BrandingPage.tsx
- Extended settings service with logo customization types

This addresses the issue where the gallery logo was "very large and centered"
by providing full control over logo appearance and positioning.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 14:56:09 +02:00
paul 41857ec499 feat: implement feedback filter for liked/favorited photos (Issue #17)
Implemented Feature Request 1 from github.com/the-luap/picpeak/issues/17:
- Added filter functionality to display only liked or favorited photos
- Integrated feedback filter directly into PhotoFilterBar component
- Implemented responsive design with proper mobile/tablet/desktop layouts
- Filter only shows when feedback is enabled for the gallery
- Added proper count display for liked and favorited photos

Improvements:
- Fixed responsive breakpoints (mobile <768px, tablet 768-1023px, desktop ≥1024px)
- Feedback filter shows inline with categories on desktop with vertical divider
- On mobile/tablet, filter appears below categories to prevent layout issues
- Added horizontal scrolling for category buttons to prevent cut-off

Code cleanup:
- Removed all debug console.log statements from production code
- Removed test route from backend gallery.js
- Cleaned up unnecessary logging in frontend components

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 14:56:09 +02:00
Gitea Actions Bot f7a8765f58 chore: bump version to 1.0.108 (backend + frontend) 2025-09-02 15:46:57 +00:00
paul 214f120f7a chore: update system metrics
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-02 17:40:23 +02:00
paul f26becad1d fix: resolve feedback validation issues from GitHub issue #16
- Fixed 400 Bad Request error when submitting feedback with name/email required
- Updated backend validation to properly handle empty/undefined name/email fields
- Modified frontend components to send undefined instead of empty strings when fields are not provided
- Fixed thumbnail display issue in moderation view by using correct admin API endpoints
- Updated FeedbackModerationPanel and EventFeedbackPage to display thumbnails correctly

The issue was caused by the validation logic treating empty strings differently than undefined values.
Frontend components now properly send undefined when name/email are not provided, and the backend
validation correctly handles both cases.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 17:40:23 +02:00
paul 67ff415840 fix: resolve feedback validation issues from GitHub issue #16
- Fixed backend validation to properly handle empty strings in validateGuestRequirements
- Added Boolean conversion for SQLite boolean values in feedback settings API response
- Created FeedbackIdentityModal component for collecting name/email when required
- Updated PhotoLikes, PhotoRating, and PhotoFavorites components to show modal when requireNameEmail is true
- Fixed issue where require_name_email field was not reaching frontend due to missing boolean conversion

This ensures that when 'Require Name & Email' is enabled, guests are prompted with a modal to provide their information before submitting feedback, preventing 400 Bad Request errors.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 17:40:23 +02:00
Gitea Actions Bot 88659f1fa6 chore: bump frontend version to 1.0.107 2025-09-02 14:14:52 +00:00
paul c1e10f14a3 fix: resolve translation interpolation issue for download button
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Successful in 1m13s
Version and Release / trigger-drone (push) Successful in 3s
Fixed the download selected button not displaying count properly.
The translation key 'gallery.downloadSelected' was not receiving
the count parameter for interpolation, causing "{{count}}" to
display literally instead of the actual number.

Fixes the issue where the button showed:
- "Download {{count}} Selected" instead of "Download 2 Selected"
- "{{count}} ausgewählte herunterladen" instead of "3 ausgewählte herunterladen"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 16:08:14 +02:00
Gitea Actions Bot 0881a0fa71 chore: bump version to 1.0.106 (backend + frontend) 2025-09-01 21:03:36 +00:00
paul e91209f7cb fix: resolve multiple issues from GitHub issue #14
Mirror to GitHub / mirror (push) Successful in 46s
Test and Lint / backend-test (push) Successful in 1m54s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
- Fixed duplicate German translation for 'downloadSelected' button
- Added client_max_body_size configuration in nginx for file uploads
- Fixed date parsing in FeedbackModerationPanel to handle timestamps
- Fixed admin authentication context (req.admin vs req.user) in feedback routes
- Enhanced clipboard functionality with fallback for non-HTTPS contexts
- Fixed authentication token handling for numeric event IDs in uploads

These changes ensure comment moderation works properly, file uploads are configured correctly, and the UI handles all edge cases properly.

Fixes #14

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 22:56:44 +02:00
paul 828d6bc456 fix: correct script name in Gitea mirror workflow
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Has been skipped
- Fix script name from gitea-runner.sh to install-gitea-runner.sh
- Update system metrics

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:37:12 +02:00
paul f945573f09 chore: update system metrics
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Has started running
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
2025-08-29 22:33:38 +02:00
paul 296430e4d7 fix: update Gitea mirror workflow to selectively remove scripts
- Only remove gitea-runner.sh instead of entire scripts directory
- Preserve useful deployment and utility scripts in GitHub mirror
- Update system metrics

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:33:38 +02:00
Gitea Actions Bot 7b517fa290 chore: bump version to 1.0.105 (backend + frontend) 2025-08-29 20:28:39 +00:00
paul 2c9a56f217 docs: update deployment guide with GitHub Container Registry images
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 2m20s
Version and Release / version-bump (push) Successful in 1m9s
Version and Release / trigger-drone (push) Successful in 3s
- Added instructions for using pre-built images from ghcr.io
- Created docker-compose.production.yml for quick deployment with official images
- Updated deployment guide with two methods:
  1. Using pre-built images (fastest, recommended)
  2. Building from source (for customization)
- Updated SIMPLE_SETUP references to use new unified script
- Added specific version deployment instructions
- Maintained backward compatibility with local build process

The pre-built images eliminate build time and ensure consistent deployments
across environments. Users can now deploy PicPeak in minutes using:
- ghcr.io/the-luap/picpeak/backend:latest
- ghcr.io/the-luap/picpeak/frontend:latest

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:22:40 +02:00
paul 986b101040 fix: remove unnecessary publish-manifest job from Docker workflow
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m40s
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Has been skipped
The publish-manifest job was failing because it tried to create manifests
from non-existent architecture-specific tags (latest-amd64, latest-arm64).

docker/build-push-action@v5 already creates multi-arch manifests automatically
when building for multiple platforms, making this job redundant.

The workflow now correctly builds and pushes multi-arch images in a single
step with proper manifest lists included.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:16:35 +02:00
Gitea Actions Bot 0c283717cb chore: bump version to 1.0.104 (backend + frontend) 2025-08-29 20:13:18 +00:00
paul 4029559954 feat: add GitHub Actions workflow for Docker image builds
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 1m11s
Version and Release / trigger-drone (push) Successful in 3s
- Created docker-build.yml workflow for automated Docker builds
- Configured GitHub Container Registry (ghcr.io) with GITHUB_TOKEN auth
- Added multi-architecture support (linux/amd64, linux/arm64)
- Integrated Trivy security scanning for vulnerability detection
- Implemented smart tagging based on branches, PRs, and releases
- Added build caching for improved performance
- Updated Dockerfiles with OCI labels for proper ghcr.io linking
- Created comprehensive README-DOCKER.md documentation

The workflow automatically builds and pushes images on:
- Push to main/develop branches
- Pull requests (build only, no push)
- Release publications
- Manual workflow dispatch

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:07:19 +02:00
Gitea Actions Bot 9c943bc69a chore: bump version to 1.0.103 (backend + frontend) 2025-08-29 20:06:11 +00:00
paul 29a8ff914c feat: consolidate setup scripts and guides into unified solution
Mirror to GitHub / mirror (push) Successful in 46s
Test and Lint / backend-test (push) Successful in 1m51s
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 1m9s
Version and Release / trigger-drone (push) Successful in 3s
- Created unified SIMPLE_SETUP.md combining Docker and native installation guides
- Created universal scripts/setup.sh supporting both Docker and native installations
- Removed redundant setup files (simple-setup.md, simple-setup.sh, scripts/simple-setup.sh)
- Added intelligent installation method selection based on system resources
- Implemented update and uninstall functionality in unified script
- Enhanced with command-line options for unattended installations
- Improved cross-platform support (Ubuntu, Debian, RHEL/CentOS, Fedora, Raspberry Pi OS)

Fixes #7

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 21:59:39 +02:00
paul a73d217273 refactor: simplify setup file names
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m41s
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Has been skipped
- Rename SETUP_GUIDE.md to simple-setup.md
- Rename setup-picpeak.sh to simple-setup.sh
- Update all internal references to use new filenames
- Simplify naming convention for easier understanding

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 23:23:02 +02:00
paul 1b4b497fdf chore: clean up codebase for production readiness
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
- Remove all console.log/debug statements from production code
- Add NODE_ENV checks for development-only logging
- Remove test scripts (test-feedback, test-image-security, test-backup-*, test-restore)
- Remove one-time fix scripts (fix-temp-photos, fix-migration-state, mark-migration-applied)
- Remove sensitive files (.env.backup, ADMIN_CREDENTIALS.txt)
- Update package.json to remove references to deleted scripts
- Replace console statements with logger utility in backend
- Secure error boundaries to not expose stack traces in production

This makes the codebase production-ready with no debug output or test scripts.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 23:19:30 +02:00
paul 827eb4819b fix: update GitHub mirror action to support fine-grained personal access tokens
Mirror to GitHub / mirror (push) Failing after 39s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Successful in 2m14s
- Changed authentication from x-access-token to actual username (required for fine-grained tokens)
- Implemented git config url.insteadOf method for better token compatibility
- Added comprehensive token type detection and validation
- Improved error handling with detailed troubleshooting instructions
- Added clear documentation for both classic and fine-grained token setup
- Enhanced security by removing credentials from remote URLs
- Added automatic git config cleanup after push

Required permissions for fine-grained tokens:
- Repository access: the-luap/picpeak
- Contents: Read and Write
- Metadata: Read

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 11:24:49 +02:00
Gitea Actions Bot 086a4ca342 chore: bump version to 1.0.102 (backend + frontend) 2025-08-24 09:18:00 +00:00
paul 6de64a1df1 fix: resolve port configuration issues and database column mismatch
Mirror to GitHub / mirror (push) Failing after 40s
Test and Lint / backend-test (push) Successful in 1m40s
Test and Lint / frontend-test (push) Successful in 2m1s
Version and Release / version-bump (push) Successful in 1m6s
Version and Release / trigger-drone (push) Successful in 3s
- Fixed database query in adminDashboard.js using non-existent 'created_at' column
  Changed to use 'scheduled_at' for email_queue table queries
- Updated frontend/.env.example to default to Docker configuration (port 3001/api)
- Clarified DEPLOYMENT_GUIDE.md with separate frontend/backend configuration sections
- Added explicit port configuration warnings to prevent future mismatches
- Added beta features section to README for download protection and deployment script

The 500 errors were caused by:
1. Frontend .env pointing to wrong port (3002 instead of 3001)
2. Database query using 'created_at' instead of 'scheduled_at' for email_queue

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 11:11:53 +02:00
paul 3074748bbc fix: correct malformed gallery URLs in admin panel View Gallery links
Fixed issue where full URLs in share_link field were incorrectly being prepended
with `/gallery/` prefix, resulting in malformed URLs like:
`/gallery/http://localhost:3000/gallery/event-slug/token`

The fix now properly handles both formats stored in the database:
- Full URLs (from adminEvents.js): Used directly
- Relative paths (from events.js): Prepended with `/gallery/`

This ensures View Gallery links work correctly regardless of which backend
endpoint created the event.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 11:11:53 +02:00
paul 934d6ddc58 fix: resolve GitHub issues #4, #8, #9, and #10
- Fix missing database columns for password reset (#8)
  - Add must_change_password column to admin_users table
  - Add password_changed_at column for tracking password changes

- Fix feedback functionality (#9)
  - Add require_moderation column to event_feedback_settings table
  - Add missing host_name column to events table

- Add download control features (#10)
  - Add allow_downloads, disable_right_click, watermark_downloads columns to events
  - Implement download restrictions in gallery endpoints
  - Update event creation and update endpoints to support new fields
  - Prevent downloads when disabled for an event

- Login functionality (#4) verified working with proper credentials

All database migrations included and tested with Docker environment.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 11:11:53 +02:00
Gitea Actions Bot a699a0477b chore: bump backend version to 1.0.101
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-08-03 18:14:51 +00:00
paul ed0243ec39 fix: remove updated_at field from password reset query
Mirror to GitHub / mirror (push) Successful in 37s
Test and Lint / backend-test (push) Successful in 1m31s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
- The events table doesn't have an updated_at column
- Fixes PostgreSQL error 42703 when resetting passwords
- Password hash update now works correctly

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-03 20:08:55 +02:00
Gitea Actions Bot ac31798bf5 chore: bump backend version to 1.0.100
continuous-integration/drone/push Build is passing
2025-08-03 17:58:27 +00:00
paul 65d796b9f0 fix: correct password generator function name in reset password route
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m46s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Failing after 1m38s
Version and Release / trigger-drone (push) Has been skipped
- Change generatePassword to generateReadablePassword
- Fixes TypeError when resetting gallery passwords
- The function generatePassword doesn't exist in passwordGenerator.js

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-03 19:51:17 +02:00
paul 6389b9df3f fix: update all deployment guide links in README.md
Mirror to GitHub / mirror (push) Successful in 28s
Test and Lint / backend-test (push) Successful in 1m18s
Test and Lint / frontend-test (push) Successful in 1m56s
continuous-integration/drone/push Build is passing
- Change all links from DEPLOYMENT.md to DEPLOYMENT_GUIDE.md
- Fixed 3 occurrences: documentation section, getting started section, and footer
- Matches the actual filename in the repository

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-26 00:25:04 +02:00
paul 87d1761091 docs: add warnings about $ character in Docker Compose passwords
Mirror to GitHub / mirror (push) Successful in 28s
Test and Lint / frontend-test (push) Has been cancelled
Test and Lint / backend-test (push) Has started running
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Has been skipped
continuous-integration/drone/push Build is passing
- Add clear warnings in .env.example about $ variable substitution
- Update DEPLOYMENT_GUIDE.md with password generation commands that exclude $
- Add troubleshooting section for Docker Compose variable substitution errors
- Provide solutions: avoid $, escape as $$, or use quotes

Fixes issue where passwords containing $ cause Docker Compose warnings
and potential authentication failures.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-26 00:23:48 +02:00
Gitea Actions Bot fda132eed4 chore: bump frontend version to 1.0.100
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 22:22:28 +00:00
paul 1cadce196b fix: update deployment guide with critical URL configuration and nginx port fixes
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m31s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m56s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Successful in 3s
- Add prominent warning about FRONTEND_URL configuration requiring exact port match
- Add comprehensive troubleshooting section for 502/CORS login failures
- Fix nginx.conf to use correct backend port (3001 instead of 3000)
- Document common deployment issues and their solutions
- Explain Docker DNS caching issues after container restarts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-26 00:17:41 +02:00
Gitea Actions Bot 840b8870ec chore: bump version to 1.0.99 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 14:33:40 +00:00
paul ad495a92c4 fix: improve admin credentials display and configuration
Mirror to GitHub / mirror (push) Successful in 29s
Test and Lint / backend-test (push) Successful in 1m32s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 42s
Version and Release / trigger-drone (push) Successful in 3s
- Display email address instead of username in migration output
- Use environment variables for admin email configuration
- Update deployment guide with clear admin setup instructions
- Add note that login requires email address, not username
- Fix GitHub URL to correct repository
- Remove obsolete version field from docker-compose.yml

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 16:28:32 +02:00
Gitea Actions Bot b428543452 chore: bump version to 1.0.98 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-25 13:09:41 +00:00
paul 6492cb9ec8 refactor: simplify deployment structure with direct port exposure
Mirror to GitHub / mirror (push) Successful in 24s
Test and Lint / backend-test (push) Successful in 1m30s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m2s
Version and Release / version-bump (push) Successful in 42s
Version and Release / trigger-drone (push) Successful in 2s
- Removed nginx/certbot/umami from docker-compose.yml
- Services now expose ports directly (frontend:3000, backend:3001)
- Updated deployment guide with reverse proxy setup instructions
- Changed all docker-compose commands to use docker compose (no hyphen)
- Removed separate dev deployment files (.env.dev, docker-compose.dev.yml)
- Simplified .env.example for production use
- Added comprehensive reverse proxy examples (nginx, Traefik, Caddy)

BREAKING CHANGE: Deployment now requires external reverse proxy for SSL/HTTPS

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 15:04:51 +02:00
Gitea Actions Bot 0e0a0b91d1 chore: bump version to 1.0.97 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 12:59:57 +00:00
paul f8fb1c3f4b fix: resolve backend startup errors in development
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 45s
Version and Release / trigger-drone (push) Successful in 3s
- Added STORAGE_PATH environment variable and volume mount for storage directory
- Fixed authSecurity functions to check if login_attempts table exists before using it
- Prevents errors when running with only core migrations (new deployments)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 14:54:32 +02:00
Gitea Actions Bot b108f6fe1c chore: bump version to 1.0.96 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 12:46:48 +00:00
paul 61299a33c4 fix: resolve development environment issues
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 39s
Version and Release / trigger-drone (push) Successful in 3s
- Updated frontend to Node 20 to fix Vite crypto.hash error
- Removed mailhog service as not needed for development
- Updated email configuration to be disabled by default in dev
- Fixed frontend port mapping to use 3005 consistently
- Added script to show/reset admin credentials
- Removed unnecessary storage volume mount

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 14:41:59 +02:00
Gitea Actions Bot 96542d7e35 chore: bump version to 1.0.95 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 12:36:35 +00:00
paul ee855a3502 fix: resolve PostgreSQL migration issues for development environment
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m29s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m0s
Version and Release / version-bump (push) Successful in 40s
Version and Release / trigger-drone (push) Successful in 3s
- Added DATABASE_CLIENT=pg to docker-compose.dev.yml for PostgreSQL connection
- Fixed migration 032 to check if tables exist before creating
- Removed language-specific email template columns (use standard columns)
- Added conditional checks for app_settings and email_templates inserts
- Created helper scripts for migration state management
- Added .env.dev with PostgreSQL configuration for development

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 14:31:46 +02:00
Gitea Actions Bot 02c407d431 chore: bump version to 1.0.94 (backend + frontend)
continuous-integration/drone/push Build is passing
2025-07-25 12:14:42 +00:00
paul 62617f627f fix: resolve language-specific column issues in core migrations
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m20s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m0s
Version and Release / version-bump (push) Failing after 34s
Version and Release / trigger-drone (push) Has been skipped
- Fixed migration 030 to use standard email_templates columns (subject, body_html, body_text)
- Removed language-specific columns that don't exist in base schema
- Updated docker-compose.dev.yml for development environment

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 14:09:26 +02:00
paul 1cbeb75094 Fix migration column and JSON errors
continuous-integration/drone/push Build is running
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Failing after 1m13s
Version and Release / version-bump (push) Has started running
Version and Release / trigger-drone (push) Has been cancelled
- Fixed migration 029: Use base email_templates columns (subject, body_html, body_text)
  instead of language-specific columns that don't exist yet
- Fixed migration 004: JSON.stringify the setting_value for app_settings table
- Removed German translations from backup email templates in core migration

The errors occurred because:
1. Migration 029 assumed language columns existed, but they're added by later migrations
2. Migration 004 passed a plain string to a JSON column in PostgreSQL
2025-07-25 13:50:01 +02:00
paul baa08e9ec9 Fix duplicate key error in migration marking
Mirror to GitHub / mirror (push) Successful in 30s
Test and Lint / backend-test (push) Successful in 1m36s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Successful in 31s
Version and Release / trigger-drone (push) Has been skipped
- Added check in markMigrationAsApplied to prevent duplicate inserts
- Now checks if migration is already marked before inserting
- Prevents 'duplicate key value violates unique constraint' error

The error occurred when detectExistingSchema() marked a migration
as applied, then the migration runner caught a 'schema exists' error
and tried to mark it as applied again.
2025-07-25 13:34:02 +02:00
paul 8d85454ef6 Make credential file writing optional in migration
Mirror to GitHub / mirror (push) Successful in 27s
Test and Lint / backend-test (push) Successful in 1m22s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m0s
Version and Release / version-bump (push) Failing after 38s
Version and Release / trigger-drone (push) Has been skipped
- Wrapped file writing in try-catch to prevent migration failure
- Credentials are always shown in console output
- File writing is now optional - if it fails, migration continues
- Added informative message when file cannot be written

This prevents the migration from failing in environments where
the data directory has permission issues, while still ensuring
administrators can see and copy the credentials from console output.
2025-07-25 13:29:02 +02:00
paul 596bba2c1b Fix permission error when writing admin credentials
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m21s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m0s
Version and Release / version-bump (push) Successful in 28s
Version and Release / trigger-drone (push) Has been skipped
- Changed credential file location from /app/ to /app/data/
- Added directory creation with recursive flag
- Updated console messages to show correct file location
- The data/ directory is already owned by nodejs user in Dockerfile

The error occurred because the nodejs user doesn't have write
permission to /app/ directory, but does have permission to /app/data/
which is explicitly created and chowned in the Dockerfile.
2025-07-25 13:20:44 +02:00
paul 055de06315 Fix 001_init.js database column mismatch
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m13s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m58s
Version and Release / version-bump (push) Failing after 36s
Version and Release / trigger-drone (push) Has been skipped
- Removed must_change_password field that doesn't exist in admin_users table
- Changed from using db to knex parameter for database operations
- Fixed require statement that was accidentally changed
- Updated security message to reflect no forced password change
- Removed debug logging after identifying the issue

The error occurred because 001_init.js was trying to insert a column
that doesn't exist in the admin_users table schema created by
initializeDatabase().
2025-07-25 13:15:06 +02:00
paul ccf59d1d4d Fix 001_init.js to follow proper migration pattern
Mirror to GitHub / mirror (push) Successful in 24s
Test and Lint / backend-test (push) Successful in 1m39s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m0s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Has been skipped
- Changed from standalone script to proper migration with exports.up/down
- Removed process.exit() calls that were terminating the migration runner
- Removed immediate execution of runMigrations()
- Now properly exports migration functions like other migrations

This was the root cause - 001_init.js was executing immediately when
required and calling process.exit(), preventing it from being run as
a migration and causing 029 to run first on an empty database.
2025-07-25 13:00:03 +02:00
paul 4e052966d3 Fix migration sorting to use numeric comparison
Mirror to GitHub / mirror (push) Successful in 24s
Test and Lint / backend-test (push) Successful in 1m27s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Failing after 39s
Version and Release / trigger-drone (push) Has been skipped
- Changed from string sort to numeric sort for migration files
- String sort was causing '029' to run before '001'
- Now properly extracts and compares numeric prefixes
- Applied fix to both run-migrations.js and run-migrations-safe.js

This ensures 001_init.js runs first and creates all necessary tables
before other migrations try to use them.
2025-07-25 12:51:54 +02:00
paul ba2c021c45 Fix new deployment detection in migration runner
Mirror to GitHub / mirror (push) Successful in 23s
Test and Lint / backend-test (push) Successful in 1m26s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 37s
Version and Release / trigger-drone (push) Has been skipped
- Check for essential tables (events, photos, admin_users, activity_logs)
  to determine if it's truly a new deployment
- Only run detectExistingSchema() for actual existing deployments
- Remove obsolete init.js references (now 001_init.js)
- Fix migration filters to handle renamed init file

The issue was that detectExistingSchema() was marking migrations as
applied from previous failed runs, causing the system to incorrectly
treat new deployments as existing ones and run legacy migrations that
expect tables to already exist.
2025-07-25 12:45:25 +02:00
paul 519518ed6c Fix migration order by renaming init.js to 001_init.js
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Failing after 39s
Version and Release / trigger-drone (push) Has been skipped
- Renamed core/init.js to core/001_init.js to ensure it runs first
- Updated detectExistingSchema() to reference 001_init.js
- This fixes the issue where backup migrations tried to access
  app_settings table before it was created
- Migrations now run in correct order: init first, then numbered

The error occurred because alphabetical sorting put 029 before init,
causing migrations to fail on new deployments.
2025-07-25 11:37:52 +02:00
paul 8a0a4436b0 Fix migration require paths after reorganization
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m41s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m21s
Version and Release / version-bump (push) Successful in 48s
Version and Release / trigger-drone (push) Has been skipped
- Updated all core migrations to use ../../src/ instead of ../src/
- Updated legacy migrations with the same path fix
- This fixes MODULE_NOT_FOUND errors during deployment

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

This change optimizes deployment for new users who will get a clean
schema without running unnecessary upgrade migrations.
2025-07-24 22:40:25 +02:00
paul 0c989ce086 docs: replace email addresses with GitHub issue links
Mirror to GitHub / mirror (push) Successful in 32s
Test and Lint / backend-test (push) Successful in 1m35s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m59s
- Remove all @example.com email addresses from documentation
- Replace security@example.com with GitHub security issue links
- Replace conduct@example.com with GitHub issue link
- Update CONTRIBUTING.md to use GitHub issues instead of email
- Ensure all communication happens through GitHub's issue tracking system
- Avoid direct email communication for better transparency and tracking
2025-07-24 21:28:44 +02:00
paul 35e360dcf7 docs: add transparency note about AI-assisted development
Mirror to GitHub / mirror (push) Successful in 33s
Test and Lint / backend-test (push) Successful in 1m30s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Has been skipped
- Add acknowledgment section about AI generation
- Clarify human testing and security auditing
- Emphasize production testing and code review
- Remove unnecessary .gitkeep files
2025-07-24 21:19:57 +02:00
paul a209796b16 refactor: complete configuration cleanup and consistency fixes
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m15s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m2s
Version and Release / version-bump (push) Failing after 1m17s
Version and Release / trigger-drone (push) Has been skipped
- Create docker-compose.dev.yml with Mailhog for development email testing
- Standardize all configurations to use PORT=3001 for backend
- Fix database service naming (postgres → db) across all files
- Add missing BACKEND_URL environment variable to all configs
- Update .env examples to match actual Docker setup requirements
- Remove orphaned postgres-init directory (Umami handles its own DB)
- Update README roadmap: mark gallery feedback as implemented, add multi-admin support
- Update deployment guide with development setup instructions
- Fix frontend Dockerfile.dev for proper hot-reload development
- Remove unused files (wedding-photos.db, frontend/README.md)

This ensures all configuration files are consistent and aligned with the deployment guide.
2025-07-24 21:13:52 +02:00
paul 7c79052681 refactor: consolidate deployment documentation and cleanup repository
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Has been skipped
- Merge all deployment docs into single comprehensive DEPLOYMENT_GUIDE.md
- Add instructions for non-nginx deployment options
- Reference utility scripts in deployment guide
- Remove orphaned migrations folder at root level
- Remove redundant deployment documentation files
- Keep all utility scripts in scripts/ folder
- Update CLAUDE.md to reference new deployment guide

This provides a single source of truth for all deployment scenarios.
2025-07-24 20:54:50 +02:00
paul d560453982 Merge main-old branch into main - includes backup service, feedback system, and numerous enhancements
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 2m0s
continuous-integration/drone/push Build is passing
Version and Release / version-bump (push) Failing after 37s
Version and Release / trigger-drone (push) Has been skipped
2025-07-24 20:21:01 +02:00
paul ecb3263267 Cleanup repository
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m21s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Has been skipped
2025-07-24 17:09:30 +02:00
paul 4d929a71ce Cleanup repository 2025-07-24 17:05:50 +02:00
paul bf705674d5 fix: multiple improvements and CI/CD updates
Frontend fixes:
- Add missing translations for chunk upload (upload.uploadingChunks, common.chunk)
- Fix photo deletion visual bug by tracking deletion state per photo
- Prevent UI confusion when deleting photos in admin grid

Backend fixes:
- Add file existence checks before deleting thumbnails
- Prevent ENOENT errors for missing thumbnail files
- Improve error handling in photo deletion

CI/CD updates:
- Remove Gitea release creation from Drone pipeline
- Simplify GitHub mirror workflow (remove history rewriting, keep file removal)
- Add clean-git-history.sh script for manual history cleanup

These changes improve the admin photo management experience and streamline
the CI/CD process for better maintainability.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot fee369a503 chore: bump version to 1.0.93 (backend + frontend) 2025-07-24 16:57:08 +02:00
paul ad75818566 fix: resolve multiple feedback management issues
- Hide "Manage Feedback" button when feedback is disabled for an event
- Fix 500 error on feedback API endpoint by adding null-safe operators
- Fix TypeError on analytics page by calculating average_rating in backend
- Fix password validation for event creation by properly awaiting async validation
- Add proper null checks and fallbacks for feedback statistics

These fixes ensure:
- Date passwords like "19.07.2025" work with simple password complexity settings
- Feedback management page loads without errors
- Analytics display correctly even with no feedback data

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 65f2c8610d chore: bump frontend version to 1.0.92 2025-07-24 16:57:08 +02:00
paul 517128fd99 fix: add missing route for feedback management page
- Added /admin/events/:id/feedback route to App.tsx
- This fixes the empty page issue when navigating to feedback management
- EventFeedbackPage component was already implemented but route was missing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 55c8384a25 chore: bump frontend version to 1.0.91 2025-07-24 16:57:08 +02:00
paul 0064122eff feat: add feedback management enhancements
- Add German translations for event dropdown menu actions
- Add feedback settings to event edit form
- Hide comment button in gallery when feedback is disabled
- Add feedback moderation panel to event details page

Implements:
1. German translation for three dots menu actions (viewDetails, archiveEventAction, etc.)
2. Feedback enable option now visible when editing existing events
3. Comment button in photo lightbox only shows when feedback is enabled
4. Inline comment moderation in admin event detail view

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 0c783c66d0 fix: use plugins/gitea-release for Drone CI/CD
- Replace plugins/github-release with plugins/gitea-release
- Fix API endpoint compatibility issue (was using GitHub API v3)
- Update base_url to gitea.local.nothaft.cloud
- Change secret from GITHUB_TOKEN to GITEA_TOKEN
- Update release notes to reference local Gitea URLs

This fixes the 401 authentication error when creating releases
on Gitea instances.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 47e2351dab chore: bump frontend version to 1.0.90 2025-07-24 16:57:08 +02:00
paul e1aca6b00c fix: auto-convert old date formats to new date-fns syntax
- Add convertDateFormat function to automatically fix DD->dd, YYYY->yyyy
- Handles existing database values with old format strings
- Prevents RangeError when using old formats stored in settings
- Ensures backward compatibility without requiring database updates

This fix converts formats on-the-fly so existing production data
with old formats like 'DD.MM.YYYY' will work correctly.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 2cb6577f26 chore: bump frontend version to 1.0.89 2025-07-24 16:57:08 +02:00
paul c51d756503 fix: resolve date formatting error in event creation
- Fix TypeError "e.match is not a function" when creating events
- Update useLocalizedDate hook to handle both string and object date formats
- Add type safety for date format configuration
- Fix date format strings to use correct date-fns format (lowercase)
- Ensure backward compatibility with existing date settings

The issue was caused by SettingsPage saving date formats as objects
while useLocalizedDate expected strings. This fix handles both formats
gracefully and prevents the error page redirect.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot c48b9780df chore: bump frontend version to 1.0.88 2025-07-24 16:57:08 +02:00
paul 618e2695fd fix: complete restore page translations and fix structure
- Fix restoreTypes translation structure (was under options.types)
- Add missing restore.messages.restoreStarted translation
- Ensure all restore wizard strings use translations
- Add corresponding German translations for restore section
- Fix translation key structure to match component expectations

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot a289f97a31 chore: bump frontend version to 1.0.87 2025-07-24 16:57:08 +02:00
paul 7387a5e9f9 fix: complete backup page translations and improve UI
- Fix '0 files' hardcoded string to use translation
- Fix 'local' destination type to show translated name
- Add missing field placeholders for rsync and S3 configurations
- Add missing backup.history.columns.* translations
- Add missing backup.history.filter.* translations
- Add missing backup.history.details.* translations
- Fix backup destination display to use proper translation key
- Replace TestTube icon with Wifi icon for connection testing
- Add all corresponding German translations
- Ensure Backup Health and Coverage titles use translations

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 4615a5d795 docs: add minimum system requirements section to README
- Add CPU, RAM, and storage requirements
- Include OS and software dependencies
- Add Docker requirements for containerized deployment
- Keep it concise and focused on minimum requirements only

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul f926cd3adf fix: use plugins/github-release for Drone CI/CD
- Replace manual curl approach with plugins/github-release
- Fixes shell parsing issues with multiline strings
- Properly passes GITHUB_TOKEN via api_key setting
- Uses YAML multiline string (|) for release notes
- Cleaner and more reliable approach

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 96b05b5e0c chore: bump frontend version to 1.0.86 2025-07-24 16:57:08 +02:00
paul 99e47785e4 fix: add missing translations and fix BackupHistory useTranslation error
- Add missing useTranslation hook in BackupHistory.jsx
- Add missing translation keys:
  - backup.dashboard.health.title
  - backup.dashboard.coverage.title
  - backup.dashboard.stats.noBackupsYet
  - backup.configuration.enableBackupHelp
  - backup.configuration.schedule.options.*
  - backup.configuration.messages.*
  - backup.dashboard.noDestinationSet
  - Fix health message keys to match component usage
- Update German translations with same missing keys
- Fix runtime error preventing access to backup history page

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 94f10e1645 fix: simplify Drone github-release step to avoid shell parsing issues
- Use echo with single JSON string instead of heredoc
- Use > for folded scalar to avoid newline issues
- Properly escape quotes in JSON body
- Ensure GITHUB_TOKEN is properly passed

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot fe4a476e41 chore: bump frontend version to 1.0.85 2025-07-24 16:57:08 +02:00
paul e9f92e66d0 feat: add complete translation support for backup admin page
- Add comprehensive backup translation keys to en.json and de.json
- Update all backup components to use i18next translations:
  - BackupManagement.jsx: main page with tab navigation
  - BackupDashboard.jsx: health status and statistics
  - BackupConfiguration.jsx: settings and destination configuration
  - BackupHistory.jsx: backup history table and details
  - RestoreWizard.jsx: multi-step restore process
- Replace all hardcoded strings with translation keys
- Support dynamic values with interpolation
- Fix Drone CI/CD github-release step:
  - Write release.json to /tmp to avoid permission issues
  - Use quoted heredoc to prevent shell interpretation errors
  - Replace placeholders with actual tag values using sed

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 58f4217756 chore: bump version to 1.0.84 (backend + frontend) 2025-07-24 16:57:08 +02:00
paul 76a466c077 fix: replace github-release plugin with direct curl API call
The github-release plugin was incorrectly detecting and using the
Gitea API instead of GitHub's API. Replaced with direct curl command
that explicitly calls GitHub API to create releases.

This approach:
- Uses curlimages/curl image for lightweight execution
- Directly calls GitHub API v3 with proper authentication
- Avoids any auto-detection issues from the plugin
- Creates releases with full markdown formatting

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 9dc3777985 CRITICAL FIX: prevent gallery pages redirecting to admin login
Users were being redirected from gallery pages to admin login due to
useLocalizedDate hook trying to fetch admin settings. Fixed by:

1. Added general_date_format to public settings endpoint
2. Created publicSettingsService for unauthenticated access
3. Updated useLocalizedDate to use public settings instead of admin
4. Fixed API interceptor to not redirect on public endpoint 401s
5. Added backups/ and test-archiver/ to .gitignore

This restores gallery access for all users.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot e006d73831 chore: bump backend version to 1.0.83 2025-07-24 16:57:08 +02:00
paul 63e88c8324 CRITICAL FIX: correct email_templates column names in migration 032
Production failing because email_templates table has different columns.
Fixed column names:
- name → template_key
- subject → subject_en, subject_de
- body → body_html_en, body_html_de, body_text_en, body_text_de
- Added missing 'variables' field
- Removed language and is_active fields (not in schema)

Also fixed the down() function to use template_key instead of name.

URGENT: Production is still down.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 105167fb57 chore: bump backend version to 1.0.82 2025-07-24 16:57:08 +02:00
paul 22cc40617f fix: remove description field from migration 035 app_settings inserts
The app_settings table doesn't have a description column.
Removed all description fields to prevent migration failures.

This completes the fix for all app_settings inserts across migrations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 7f28917795 CRITICAL FIX: remove description field from app_settings inserts
Production failing with "column description does not exist" error.
The app_settings table only has: id, setting_key, setting_value, setting_type, updated_at
Removed all description fields from migration 032.

URGENT: Production is down - this is blocking the backend from starting.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 77af2a8415 chore: bump backend version to 1.0.81 2025-07-24 16:57:08 +02:00
paul 4c42b4c601 fix: remove updated_at from app_settings inserts in multiple migrations
The app_settings table in production doesn't have created_at/updated_at columns.
Fixed inconsistent usage across migrations:
- Migration 014: removed updated_at: new Date()
- Migration 027: removed updated_at: knex.fn.now()
- Migration 033: removed updated_at: new Date()

This ensures all migrations are consistent and won't fail in production.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 856cdc214c CRITICAL FIX: remove created_at/updated_at from migration 032 inserts
Production was failing because app_settings and email_templates
tables don't have created_at/updated_at columns. Removed these
fields from all insert statements to restore service.

This is a critical production fix - system was down.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 15b244292f chore: bump backend version to 1.0.80 2025-07-24 16:57:08 +02:00
paul 558a966f85 fix: force github-release plugin to use GitHub API instead of Gitea
The plugin was auto-detecting the Gitea instance and using its API
instead of GitHub's. Fixed by:
- Adding explicit environment variables to override detection
- Removing deprecated github_url/github_upload_url parameters
- Setting DRONE_REMOTE_URL to point to GitHub

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 1238db58c2 fix: remove unused formatBoolean import from migration 033
Removed unnecessary import that could cause issues if helpers.js
doesn't define formatBoolean. Migration already uses correct
boolean syntax without the helper.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 0502ed34c9 fix: remove formatBoolean calls from migration 032 - critical production fix
Migration was failing with "formatBoolean is not a function" error,
preventing backend startup. Fixed by:
- Removing formatBoolean import
- Using direct boolean values for column defaults
- Using JSON.stringify for setting values

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 1f417c7e30 chore: bump backend version to 1.0.79 2025-07-24 16:57:08 +02:00
paul a401fbdc54 fix: resolve migration conflicts and duplicate numbering
- Rename conflicting migrations to sequential numbers
- Update 035_enhance_backup_system.js to check for existing columns
- Prevent 'column already exists' errors during migration
- Add proper column existence checks before alterations
2025-07-24 16:57:08 +02:00
paul 247e154afe fix: correct GitHub repository path in Drone CI release config
- Remove deprecated base_url and upload_url parameters
- Use correct GitHub repository: the-luap/picpeak
- This should resolve the 404 error when creating releases
2025-07-24 16:57:08 +02:00
Gitea Actions Bot cbfd84ddea chore: bump version to 1.0.78 (backend + frontend) 2025-07-24 16:57:08 +02:00
paul dc1419c051 feat: implement gallery feedback system with version tracking for backups
Gallery Feedback Features:
- Add feedback system allowing ratings, likes, comments, and favorites on photos
- Implement admin controls for enabling/disabling feedback per event
- Add content moderation with word filters and spam detection
- Implement rate limiting to prevent abuse (10 requests/15min per type)
- Create comprehensive admin interface for feedback management
- Add analytics dashboard for feedback insights
- Export feedback data when archiving events

Frontend Components:
- PhotoRating: 5-star rating system with optimistic updates
- PhotoLikes: Like/unlike with animation
- PhotoComments: Threaded comments with moderation
- PhotoFavorites: Bookmark functionality
- FeedbackSettings: Admin configuration panel
- EventFeedbackPage: Complete management interface

Backend Implementation:
- Database migration 033: 4 new tables for feedback system
- RESTful API with proper authorization
- Guest identification via SHA256(IP+UserAgent)
- Automatic backup integration
- Email notification support

Backup Version Tracking:
- Migration 034: Add version columns to backup tables
- Track app version, Node.js version, and DB schema version
- Create restore_history table for tracking restore attempts
- Add version compatibility checking for safe restores
- Configurable version matching requirements

Security & Performance:
- Input validation and sanitization
- Rate limiting per feedback type
- Content moderation system
- Optimistic UI updates
- Efficient database queries with proper indexes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 2624ea6130 fix: configure github-release plugin to use GitHub API instead of Gitea
- Add base_url and upload_url pointing to GitHub API
- Explicitly set repo and owner for GitHub repository
- Fixes 401 authentication error in release pipeline
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 08eeac66eb chore: bump version to 1.0.77 (backend + frontend) 2025-07-24 16:57:08 +02:00
paul 11769219e4 chore: upgrade npm packages for security and stability
Backend upgrades:
- i18next: 25.3.1 → 25.3.2 (patch)
- bcrypt: 5.1.1 → 6.0.0 (maintains compatibility)
- nodemailer: 6.10.1 → 7.0.5 (no AWS SES impact)
- sharp: 0.32.6 → 0.34.3 (image processing)
- chokidar: 3.6.0 → 4.0.3 (file watching)

Frontend upgrades:
- date-fns: 2.30.0 → 4.1.0 (date utilities)
- lucide-react: 0.292.0 → 0.525.0 (icons)
- react-toastify: 9.1.3 → 11.0.5 (notifications)

All upgrades tested, 0 npm audit vulnerabilities maintained.
Deferred high-risk upgrades (archiver, React 19, Express 5).

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 7750170832 fix: update form-data and multer to address security vulnerabilities
- Update form-data from 4.0.3 to 4.0.4 (fixes CVE GHSA-fjxv-7rqg-78g4)
- Update multer from 2.0.1 to 2.0.2 (fixes CVE GHSA-fjgf-rc76-4x9p)
- Both backend and frontend now have 0 vulnerabilities
- Tested upload functionality - all working correctly

These are patch updates with no breaking changes. The updates address:
- form-data: Critical vulnerability - unsafe random function for boundary
- multer: High vulnerability - DoS via unhandled exception

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 833591681a fix: remove file requirement from GitHub release in Drone CI
- Remove files parameter that was looking for non-existent CHANGELOG.md
- Update release notes to include Docker image pull commands
- Add proper formatting and quick start instructions
- Fix 'validation failed: failed to find any file to release' error

The GitHub release will now create without requiring file attachments.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot b77e60c37c chore: bump frontend version to 1.0.76 2025-07-24 16:57:08 +02:00
paul 30f6780484 fix: correct import statements for api in backup JSX files
- Change default import to named import for api from config/api.ts
- Fixes build error: 'default' is not exported by src/config/api.ts
- Affected files: BackupHistory.jsx, RestoreWizard.jsx, BackupManagement.jsx

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot aa39e132aa chore: bump version to 1.0.75 (backend + frontend) 2025-07-24 16:57:08 +02:00
paul f6a79c815e feat: implement comprehensive backup and restore system with S3 support
- Add S3/MinIO storage adapter with multipart upload support
- Implement database backup service for SQLite and PostgreSQL
- Create backup manifest generator for tracking backup contents
- Enhance backup service with S3 integration and incremental backups
- Add restore service with safety measures and rollback capability
- Create comprehensive test suite for all backup functionality
- Add admin API endpoints for backup/restore management
- Implement frontend UI with dashboard, configuration, and restore wizard
- Add roadmap section to README with implemented backup feature

This implementation provides:
- Multiple backup destinations (local, rsync, S3/MinIO)
- Intelligent change detection to minimize backup frequency
- Full database backups with compression
- Manifest-based restore with integrity validation
- Pre-restore safety backups with rollback
- Comprehensive error handling and monitoring
- User-friendly admin interface

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 3c6837bd90 chore: bump version to 1.0.74 (backend + frontend) 2025-07-24 16:57:08 +02:00
paul 1773ed5f95 Initial commit - Project start (July 17, 2025)
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Has been skipped
Original: feat: enhance security logging and ensure rate limit blocks are properly tracked

- Add comprehensive logging for rate limit blocks with full request details
  - IP address (with proper proxy detection), user agent, headers, timestamps
  - Rate limit info (current count, limit, remaining, reset time)
  - Separate tracking for auth vs general endpoints

- Enhance authentication failure logging
  - JWT validation failures with detailed error info
  - Admin auth attempts without token
  - Failed token validation with user context
  - All events include IP, path, method, user agent

- Improve Winston logger configuration for production
  - Add automatic log rotation (10MB errors, 50MB combined)
  - Create separate security.log for auth/rate limit events
  - Ensure logs directory exists automatically
  - Add structured JSON format for log aggregation
  - Support container logging with LOG_TO_CONSOLE env var

- Create comprehensive documentation
  - Security logging guide with examples
  - Monitoring recommendations
  - Configuration reference

- Add test script to verify logging functionality

All rate limit settings remain configurable via admin panel:
- Window duration, max requests, auth limits
- Skip authenticated requests option
- Public endpoints only option

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
paul 62dcaf8555 ci: publish images to GHCR and create GitHub release via Drone 2025-07-24 16:57:07 +02:00
paul 4ed35f1b16 chore: consolidate and update deployment documentation
- Remove completed PRODUCTION_TODO_LIST.md
- Consolidate deployment guides: keep comprehensive PRODUCTION_DEPLOYMENT_GUIDE.md, remove redundant PRODUCTION_DEPLOYMENT.md
- Update all .env.example files to reflect current system:
  - Remove deprecated ADMIN_EMAIL/ADMIN_PASSWORD (now auto-generated)
  - Add proper documentation for all environment variables
  - Clarify that Umami config is optional (primary via Admin UI)
  - Add realistic examples for SMTP providers
  - Update ports to match actual defaults (3001)
- Update PRODUCTION_DEPLOYMENT_GUIDE.md:
  - Document auto-generated admin credentials process
  - Add Traefik configuration section
  - Update security checklist with current features
  - Fix outdated environment variables
  - Add nginx proxy configuration details

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot 5eff7dd4a6 chore: bump frontend version to 1.0.73 2025-07-24 16:57:07 +02:00
paul abbcdb1113 feat: exclude Claude contributor from GitHub mirror workflow 2025-07-24 16:57:07 +02:00
Gitea Actions Bot 17fc40e65d chore: bump frontend version to 1.0.72 2025-07-24 16:57:07 +02:00
paul a54a2c0fda fix: use admin API for Umami config in analytics page
- Changed from public settings endpoint to admin settings endpoint
- Fixed "Unexpected token '<'" JSON parse error
- Properly transforms settings array to key-value map
- Uses correct setting keys (analytics_umami_*)
- Maintains fallback to environment variables

The analytics page now correctly fetches Umami configuration using
the authenticated admin API instead of the public endpoint, which
was returning errors and causing JSON parse failures.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
paul febacb79ad feat: completely rewrite GitHub mirror to create new history from target commit
BREAKING: This completely replaces the previous approach and will DELETE
all existing history on GitHub, creating entirely new commit SHAs.

Key changes:
- Use orphan branch instead of cherry-pick to break history connection
- Create initial commit from target commit tree using git read-tree
- Apply subsequent changes as completely new commits with new SHAs
- Force push will COMPLETELY REPLACE GitHub history
- No trace of commits before 7aca927937 will remain on GitHub

This ensures GitHub shows only history from the target commit onwards
with no connection to previous commits or their metadata.
2025-07-24 16:57:07 +02:00
paul c7875102c5 fix: improve version bump workflow with better conflict resolution
- Added pre-fetch and check before committing to ensure we're up-to-date
- Improved retry logic with clearer output and better error handling
- Added explicit fetch before each retry attempt
- Use for-loop instead of while for clearer retry counting
- Better fallback from rebase to merge on conflicts
- Added set -e to fail fast on errors
- More verbose logging for debugging

This should resolve the persistent "non-fast-forward" errors by:
1. Checking if we're behind before even committing
2. Pulling changes if needed
3. Retrying with proper synchronization
4. Providing clear debug output

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
paul 3dc013d7b1 CRITICAL FIX: Remove 403 from auth redirect logic to restore login
BREAKING ISSUE FIXED:
- 403 errors were triggering redirects, preventing login page from loading
- Public endpoints returning 403 were causing redirect loops

Changes:
- Removed 403 status from automatic redirect logic
- Only 401 (Unauthorized) now triggers login redirect
- 403 (Forbidden) errors are passed through without redirect

This fixes the critical issue where users couldn't access the login page
because public API calls were returning 403 and triggering redirects.

403 errors should be handled differently than 401:
- 401 = Missing/invalid auth (redirect to login)
- 403 = Forbidden (could be rate limit, IP block, etc - don't redirect)

🚨 Emergency fix for production

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
paul b7c8953cb4 fix: resolve SIGPIPE error in GitHub mirror workflow file cleanup
- Replace problematic 'find | head -20' commands that caused exit code 141
- Use 'ls -la | head -10 || true' for safer file listing
- Add better progress logging during sensitive file removal
- Add error handling with '|| true' to prevent pipe failures

The find command was outputting more than head could handle, causing
SIGPIPE when head closed the pipe early. This fix uses ls which is
more predictable and adds proper error handling.
2025-07-24 16:57:07 +02:00
paul d6adde4e09 fix: resolve GitHub mirror workflow cherry-pick failure with merge commits
- Add --no-merges flag to exclude merge commits during cherry-pick
- Improve error handling for cherry-pick conflicts with auto-resolution
- Add reporting of skipped merge commits for transparency
- Enhance logging to show detailed progress during commit application

Fixes the workflow failure caused by trying to cherry-pick merge commits
which require special handling that was causing exit code 128.
2025-07-24 16:57:07 +02:00
paul 0bf4764a07 fix: resolve CI/CD version bump race condition
- Added pull before push to handle concurrent workflow executions
- Implemented retry logic with 3 attempts for push operations
- Added fallback from rebase to merge if conflicts occur
- Added proper error handling and logging for debugging

This fixes the "non-fast-forward" error that occurs when multiple
workflows run simultaneously and try to push version bumps.

The workflow now:
1. Pulls latest changes before pushing
2. Retries up to 3 times with 5-second delays
3. Falls back to merge if rebase fails
4. Provides clear error messages for debugging

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
paul 08da01f021 feat: update GitHub mirror workflow to start history from specific commit
- Start history from commit 7aca927937 instead of orphan branch
- Use cherry-pick to preserve meaningful commit history
- Automatically exclude files that only existed before target commit
- Add comprehensive error handling and logging
- Maintain clean linear history for GitHub repository
2025-07-24 16:57:07 +02:00
paul b4b09c1650 feat: enhance mirror-to-github workflow with commit-based history filtering
- Replace orphan branch approach with commit-based filtering from cfa29ad5cb
- Add automatic removal of sensitive files (env, logs, gitea configs)
- Implement robust git operations with fallback mechanisms
- Add comprehensive debugging and error handling
- Ensure same security exclusions as manual process
2025-07-24 16:57:07 +02:00
paul b2ae5f18ad fix: handle auth errors and JSON parsing in admin panel
- Added proper HTTP status check before JSON parsing in AnalyticsPage
  * Prevents "Unexpected token '<'" error when API returns HTML error pages
  * Throws proper error for non-OK responses

- Enhanced API error handling to treat 403 as auth failure
  * Both 401 and 403 now trigger redirect to login page
  * Clears expired admin tokens automatically
  * Prevents users from staying on admin pages with expired sessions

These fixes resolve:
1. JSON parse errors when fetching Umami config
2. 403 Forbidden errors not redirecting to login
3. Backend version display issues due to auth failures

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot 4a7a3bba07 chore: bump version to 1.0.71 (backend + frontend) 2025-07-24 16:57:07 +02:00
paul b3f240b2a5 chore: add production todo list and update CI/CD configs
- Added PRODUCTION_TODO_LIST.md with 9 completed production fixes
- Updated .gitea/workflows/mirror-to-github.yml
- Updated .gitignore

This commit includes all the production fixes implemented:
1. Password complexity settings
2. Gallery login security improvements
3. Analytics configuration fixes
4. Translation additions
5. UI/UX improvements
6. Date format consistency
7. Chrome compatibility fixes

All tasks have been completed and tested for production deployment.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot ba0bf11a1d chore: bump backend version to 1.0.70 2025-07-24 16:57:07 +02:00
paul d5790ad635 fix: resolve production UI and API issues
- Fixed backend version endpoint by adding retry logic import
- Gallery login page improvements:
  * Increased title size from text-xl to text-2xl (responsive scaling)
  * Title now uses event's custom primary color (var(--color-primary))
  * Removed event category badge from login page
- Fixed Umami analytics configuration check:
  * Added proper enabled state tracking
  * Warning now only shows when Umami is explicitly not configured
  * Checks both admin settings and environment variables properly

These changes improve user experience and fix false warnings in production.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot e6757bd51b chore: bump version to 1.0.69 (backend + frontend) 2025-07-24 16:57:07 +02:00
paul 8588133a4e fix: critical database connection pool exhaustion issues
- Disabled duplicate email service (emailService.js) that was creating redundant connections
- Increased connection pool size from 10 to 25 for production environment
- Extended session timeout cache from 5 to 30 minutes to reduce DB queries
- Added connection retry logic with exponential backoff for transient failures
- Fixed password validation to use retry wrapper and correct setting key
- Updated public settings and gallery middleware to handle connection failures gracefully

These changes address the "Connection terminated unexpectedly" errors in production by:
1. Reducing unnecessary database connections
2. Increasing available connection pool capacity
3. Implementing automatic retry for transient connection failures
4. Caching frequently accessed data for longer periods

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot 4b18077573 chore: bump version to 1.0.68 (backend + frontend) 2025-07-24 16:57:07 +02:00
paul c584369d5d fix: implement 9 production enhancements and security fixes
- Password Complexity: Added 4-level complexity selector (Simple/Moderate/Strong/Very Strong) in admin security settings with dynamic backend validation
- Gallery Security: Removed event date from login page (security risk), replaced with event type badge
- Analytics Config: Fixed "Not Configured" detection logic to check both admin settings and env variables
- Analytics Accuracy: Aligned calculation logic between dashboard and analytics endpoints, added totals verification
- Translations: Added missing activity keys (analytics_settings_updated, cms_page_updated, security_settings_updated, password_reset, admin_logout, system_activity)
- UI Fixes: Fixed German text overflow in CMS page selector with proper CSS truncation
- Date Format: Event creation now respects admin-configured date format instead of browser locale
- Chrome Compatibility: Replaced emoji flags with SVG components for Windows Chrome support

All changes maintain backward compatibility and production stability.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
paul 95939d57e6 fix: resolve database connection error for analytics settings
- Update publicSettings.js to handle missing analytics setting_type gracefully
- Add dedicated PUT /analytics endpoint for saving analytics settings
- Update frontend settings service to route to correct endpoints based on setting type
- Fix query to use WHERE clause that won't fail if analytics type doesn't exist

This fixes the "Connection terminated unexpectedly" error when fetching
public settings with analytics configuration.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot be58146dc7 chore: bump version to 1.0.67 (backend + frontend) 2025-07-24 16:57:07 +02:00
paul 45ce98806d feat: fix analytics dashboard and implement complete Umami integration
- Fix backend analytics to include both 'download' and 'download_all' actions
- Add Analytics tab to Settings page for Umami configuration
- Update public settings endpoint to expose Umami config when enabled
- Implement dynamic Umami initialization from backend settings
- Fix frontend analytics calculations (remove hardcoded estimations)
- Add proper download counts and unique visitor tracking
- Update CLAUDE.md with production safety guidelines

The analytics dashboard now shows accurate data for all metrics, and Umami
can be configured through the admin panel instead of environment variables.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot 23b7a848ab chore: bump backend version to 1.0.66 2025-07-24 16:57:07 +02:00
paul 0fe6d738b2 fix: resolve duplicate logger declaration and syntax error in rate limit service
- Remove duplicate logger import in server.js (line 26)
- Fix missing closing bracket in rateLimitService.js headers object
- Ensure backend starts without syntax errors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot 3f73d44c5a chore: bump version to 1.0.65 (backend + frontend) 2025-07-24 16:57:07 +02:00
708 changed files with 84475 additions and 5400 deletions
+32 -1
View File
@@ -5,7 +5,10 @@
.env.*
docker-compose*.yml
.DS_Store
node_modules
# **/ so backend/node_modules and frontend/node_modules are excluded too —
# the root-context Dockerfile.aio COPYs those directories and must get its
# deps from its builder stages, never from the host checkout.
**/node_modules
npm-debug.log
coverage
.nyc_output
@@ -18,3 +21,31 @@ storage/events/archived/*
storage/thumbnails/*
data/*.db
logs/*
# Dockerfile.aio builds from the REPOSITORY ROOT and Docker reads only this
# file — backend/.dockerignore is never consulted — so the unprefixed rules
# above miss backend/data, backend/logs and backend/storage. A checkout that has
# been used to run PicPeak would otherwise bake its database, photos, logs and
# SETUP_TOKEN into a published image layer.
# backend/data wholesale, not a suffix list. It holds only runtime state and is
# gitignored in full (.gitignore: `data/`), while suffix rules kept letting real
# secrets through: a used checkout here carries ADMIN_CREDENTIALS.txt alongside
# the database, plus -journal files and any DATABASE_PATH that does not end in
# .db. Any of those in a published layer is a credential leak.
backend/data
backend/logs
backend/storage
# Same root-context trap, one level deeper: the `.env`, `.env.*` and `data/*.db`
# rules above are unanchored only in appearance — Docker matches them against the
# path from the build context, so they catch `./.env` and never `backend/.env`.
# A checkout that has been used to run PicPeak locally keeps its JWT_SECRET,
# DB_PASSWORD and SMTP credentials there, and `COPY backend/ .` puts the file at
# /app/.env in the published layer. Match at any depth instead, the way
# **/node_modules above already does.
**/.env
**/.env.*
**/*.db
**/*.db-journal
**/*.sqlite*
frontend/dist
+137 -15
View File
@@ -10,6 +10,21 @@ NODE_ENV=production
# Generate one with: openssl rand -base64 64
#JWT_SECRET=your_very_long_random_jwt_secret_here
# How long a gallery guest stays recognised (#1210). Default 30d. It was 24h,
# which meant a client reviewing a gallery across two weekends registered again
# in between — and each re-registration is a separate guest whose likes and
# favourites no longer join up with the first visit's. Takes any jsonwebtoken
# duration ('7d', '12h'); shorten it if your galleries hold sensitive work.
#GUEST_TOKEN_TTL=30d
# OIDC SSO for admins (#798) — configured in the admin UI; only these two
# values live in the environment:
# Key encrypting the OIDC client secret at rest (defaults to JWT_SECRET).
#OIDC_ENCRYPTION_KEY=
# Break-glass: 'true' re-enables local password login even while the SSO
# settings disable it (recovery when the IdP is down or misconfigured).
#OIDC_BREAK_GLASS=false
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
@@ -56,29 +71,80 @@ DB_NAME=picpeak_prod
# Admin Account (initial setup) — OPTIONAL
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
# open /admin and PicPeak shows a setup screen. The one-time setup token is
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
# and saved to data/SETUP_TOKEN.
# written to data/SETUP_TOKEN with mode 0600 — read it with
# `docker compose exec backend cat /app/data/SETUP_TOKEN`. It is NOT logged
# unless that write fails, so it never sits in `docker logs`.
# The all-in-one image keeps it at /data/db/SETUP_TOKEN — inside the volume,
# in the db/ subdirectory (#1218). On a NAS with no shell, set ADMIN_PASSWORD
# below instead: it needs no file at all.
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
# credentials written to data/ADMIN_CREDENTIALS.txt).
#ADMIN_USERNAME=admin
#ADMIN_EMAIL=admin@yourdomain.com
#ADMIN_PASSWORD=your_secure_admin_password_here
# Email Configuration
# Email Configuration — OPTIONAL, and normally left alone.
# SMTP is configured in the setup wizard / Settings -> Email and stored in the
# database (email_configs); that is what the mail queue actually sends with.
# These variables are a legacy path kept for config-as-code deployments: when
# SMTP_HOST is set, the initial migration seeds the database row from it.
# Developers running the `dev` compose profile want SMTP_HOST=mailhog here so
# that seed points at the mailhog container.
# For Gmail: use app-specific password
# For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-specific-password
EMAIL_FROM=noreply@yourdomain.com
#SMTP_HOST=smtp.gmail.com
#SMTP_PORT=587
#SMTP_SECURE=false
#SMTP_USER=your-email@gmail.com
#SMTP_PASS=your-app-specific-password
#EMAIL_FROM=noreply@yourdomain.com
# Application URLs
# Webhook email transport (#1225) — OPTIONAL, an alternative to SMTP entirely.
# When EMAIL_WEBHOOK_URL is set, PicPeak stops sending mail itself and POSTs
# each composed message as JSON to that URL instead; something downstream
# (n8n, Make, a self-hosted relay) delivers it. Useful when SMTP is the part
# you cannot get working — app passwords, blocked ports, a NAS with no
# outbound 25.
#
# Deliberately environment-only, not an admin setting: it redirects every
# outbound message including password resets, so it should not be changeable
# from a compromised admin session.
#
# EMAIL_WEBHOOK_SECRET is REQUIRED. The body is signed with it and sent as
# X-PicPeak-Signature (HMAC-SHA256, hex) — the same scheme as gallery
# webhooks, so a receiver verifies both the same way. Set the URL without a
# secret and the transport stays OFF and says so in the log, rather than
# posting unauthenticated mail to the internet.
#
# Payload: { from, to[], cc[], subject, html, text, attachments[] }, where each
# attachment is { filename, content_type, content_base64 }. Attachments are
# included rather than dropped; a message whose attachments exceed 10 MB fails
# and stays in the queue instead of arriving without its invoice.
#
# The receiver must be a public https:// address unless you opt in — a container or LAN
# address is refused by the SSRF check otherwise. Running n8n beside PicPeak is
# normal, so set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true for that.
#
# A mail account with its own SMTP host (Settings -> Mail accounts) keeps
# sending through it; this replaces the global transport only.
#
# Set EMAIL_FROM above as well. A webhook-only install never gets an
# email_configs row (that is seeded only when SMTP_HOST is set), so EMAIL_FROM
# is where the sender address comes from.
#EMAIL_WEBHOOK_URL=https://n8n.example.com/webhook/picpeak-mail
#EMAIL_WEBHOOK_SECRET=generate-a-long-random-string
#EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=false
# Application URLs — OPTIONAL. Leave unset for the normal install.
# The public origin is captured by the setup wizard (it proposes the address
# you opened the browser at) and stored as the `general_site_url` setting, so
# you can change it later in Settings -> General without touching this file.
# Setting FRONTEND_URL here OVERRIDES that setting and makes the field
# read-only in the admin UI - use it only for config-as-code deployments.
# Use full origin with scheme, no trailing slash.
# Admin UI is served by the frontend at /admin.
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
#FRONTEND_URL=https://yourdomain.com
#ADMIN_URL=https://yourdomain.com
# Static HTML title + description used for social link previews when the
# fetcher doesn't trigger the per-event OG endpoint — most notably the
@@ -91,9 +157,10 @@ BRAND_TITLE=PicPeak
BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
# API URL for email assets (logos, images in notification emails)
# This must be the publicly accessible URL where email recipients can load images.
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
API_URL=https://yourdomain.com/api
# OPTIONAL: when unset this is derived from the resolved public origin + /api,
# so the wizard's answer covers it. Set it only for split-origin deployments
# where the API lives on a different host than the gallery.
#API_URL=https://yourdomain.com/api
# Frontend API base
# For pre-built images and production behind a reverse proxy, keep '/api'.
@@ -106,6 +173,11 @@ VITE_API_URL=/api
# DB_PORT=5432
# REDIS_PORT=6379
# File watcher (watch-folder auto-import, local storage only)
# Max photos processed in parallel — raise on hosts with memory headroom,
# lower to 1 on very small hosts. Default: 2
# FILE_WATCHER_CONCURRENCY=2
# Release Channel
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
# 'stable' uses the :stable tag (same as :latest on main)
@@ -209,6 +281,56 @@ LOGS=./logs
# attempts is exponential: 1m, 5m, 30m, 2h, 12h.
# WEBHOOK_MAX_ATTEMPTS=5
# -----------------------------------------------------------------------------
# Face recognition — "People in this gallery" (#1074, optional)
# -----------------------------------------------------------------------------
# Requires the optional picpeak-ml sidecar container:
# docker compose --profile faces up -d
#
# NONE of these variables do anything until the `faces` feature flag is
# enabled in Admin → Settings, AND the per-event "Detect people in this
# gallery" toggle is switched on. Both default to OFF. With the flag off the
# backend never contacts the sidecar, so leaving these at their defaults on an
# install without the container is completely inert.
#
# Face embeddings are biometric data (GDPR Art. 9 special category in the EU).
# The photographer is the controller and needs a lawful basis for the people
# in their photos — read https://docs.picpeak.app/features/face-recognition
# before enabling.
#
# NOT AVAILABLE ON THE ALL-IN-ONE IMAGE. The single-container build sets
# PICPEAK_SINGLE_CONTAINER=true and the backend refuses to enable face
# recognition there regardless of these variables or the feature flag: that
# image runs the backend, frontend, database and every worker in one
# container, with no ML sidecar to talk to, and face detection would compete
# with image processing for the same CPU and memory. Use the standard
# multi-container deployment if you want this feature.
#
# FACE_ML_TOKEN (no default — REQUIRED to run the sidecar)
# Shared secret between the backend and the sidecar. The sidecar refuses to
# start without it rather than serving anonymously, so an accidentally
# published port is never a free face-detection API. Generate with:
# openssl rand -hex 32
# FACE_ML_TOKEN=
#
# FACE_ML_URL (default: http://picpeak-ml:8000)
# Defaults to the sidecar's compose service name, so the standard
# deployment needs no configuration here. Only change it if you run the
# sidecar outside the default compose network.
# FACE_ML_URL=http://picpeak-ml:8000
#
# FACE_PROCESSOR_CONCURRENCY (default: 1)
# Face-detection workers in the backend. Defaults to 1 deliberately: face
# scanning shares a host with Sharp image processing, which is the real
# memory pressure (see UPLOAD_PROCESSOR_CONCURRENCY). Raise only on hosts
# with headroom to spare.
# FACE_PROCESSOR_CONCURRENCY=1
#
# FACE_ORT_THREADS (default: 1)
# ONNX Runtime threads inside the sidecar. More threads mean faster
# per-photo inference and higher RSS.
# FACE_ORT_THREADS=1
# Note on FRONTEND_API_URL (documentation only):
# When using pre-built frontend images, runtime env vars cannot override the built JS.
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
buy_me_a_coffee: theluap
+47
View File
@@ -0,0 +1,47 @@
# picpeak — All-in-one
**picpeak** is an open-source, self-hosted **photo-sharing platform for photographers**, with an optional CRM / accounting suite. This image is the **all-in-one** build: the backend, the built web UI and SQLite in **one container, one process** — no compose file, no separate database, no reverse proxy to wire up.
- 📦 **Source, docs & issues:** https://github.com/PicPeak/picpeak
- 🧩 **Multi-container images:** [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) + [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend)
## Supported tags
- `latest` / `stable` — latest stable release
- `x.y.z` — a pinned release (**recommended for production**)
- `beta` / `main` — latest build from `main` (may be unstable)
- **Architectures:** `linux/amd64`, `linux/arm64` (x86 and ARM NAS)
## Quick start
docker run -d --name picpeak -p 3000:3000 \
-v picpeak:/data \
-e JWT_SECRET="$(openssl rand -base64 48)" \
picpeak/aio:stable
Then open **http://localhost:3000/admin** and complete the setup wizard. Read the one-time setup token with:
docker exec picpeak cat /data/db/SETUP_TOKEN
> 🔗 Share links need to know your address. The image defaults `FRONTEND_URL` to `http://localhost:3000`; pass `-e FRONTEND_URL=https://photos.example.com` (or set the site URL in Settings) before you send a gallery to a client.
## Ports & volumes
- Container port **3000** (HTTP; put your own TLS terminator in front for public use).
- **One volume: `/data`** — back it up and you have backed up the install.
- `/data/db``picpeak.db` and `SETUP_TOKEN`
- `/data/storage` — originals, thumbnails, archives
- `/data/logs`, `/data/backup`
## External Postgres
SQLite is this image's default, not its only option. Point it at an existing database exactly like the backend image:
-e DATABASE_CLIENT=pg -e DB_HOST=… -e DB_USER=… -e DB_PASSWORD=…
## How it differs from the compose stack
- **SQLite takes one writer at a time** — right for a home server, a NAS or a single studio; the compose stack with PostgreSQL is what scales.
- **No Redis** — background jobs run in-process.
- **Face recognition is unavailable** here. It needs the separate [`picpeak/ml`](https://hub.docker.com/r/picpeak/ml) sidecar, and a second image-processing pipeline competing with thumbnailing for one container's CPU would just make the install slow. Run the multi-container deployment for that feature.
You can move to the full stack later without reinstalling: take a `.picpeak` backup and restore it there.
## Docs
Volume layout, the external-Postgres variant, TLS, updates and the limits: **https://docs.picpeak.app/deployment/single-container**
+56
View File
@@ -0,0 +1,56 @@
# picpeak — ML sidecar (face detection)
**picpeak** is an open-source, self-hosted **photo-sharing platform for photographers**. This image is the **optional face-detection sidecar**: it detects faces in one image and returns a bounding box, five landmarks, quality signals and a 512-d embedding per face.
**Nothing else.** No database, no volumes, no state, no egress, no model download at runtime. Clustering, person identity, thresholds and every privacy decision live in the picpeak backend, where the data already is — this service forgets each image the moment it answers.
If you don't run this container, the feature does not exist.
- 📦 **Source, docs & issues:** https://github.com/PicPeak/picpeak
- 🧩 **Runs with:** [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) + [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend)
## Supported tags
- `latest` / `stable` — latest stable release
- `x.y.z` — a pinned release (**recommended for production** — keep it on the **same** tag as the backend)
- `beta` / `main` — latest build from `main` (may be unstable)
- **Architectures:** `linux/amd64`, `linux/arm64`
> The sidecar's API contract is versioned with the backend that calls it, so `PICPEAK_CHANNEL` resolves the same string across all picpeak images.
## Turning it on
The maintained compose file already contains this service behind a profile — you do not write it by hand:
docker compose --profile faces up -d
Then two deliberate actions in the app, neither of which is installing this container:
1. Enable the **`faces`** feature flag in admin settings.
2. Enable **"Detect people in this gallery"** per event.
**Nothing in the backend touches this service while the flag is off**, so an install without this container never attempts a connection.
## Configuration
| | |
|---|---|
| `FACE_ML_TOKEN` | **Required.** The container **refuses to start** without it, so an accidentally published port is never a free face-detection API. Must match the backend's `FACE_ML_TOKEN`. |
| `FACE_ORT_THREADS` | ONNX Runtime threads (default `1`). |
Port **8000**, no volumes, no published ports needed — the backend reaches it on the compose network. `FACE_ML_URL` defaults to `http://picpeak-ml:8000` (the compose service name), so the standard deployment needs no URL configuration.
## API
All endpoints except `/health` require the `X-Face-ML-Token` header.
| | |
|---|---|
| `GET /health` | `{"status": "ok"}` — unauthenticated, used by the healthcheck |
| `GET /info` | `{detector, embedder, model_version, dim}` |
| `POST /faces` | multipart `image``{model_version, faces: [...]}` |
## Models
YuNet (detection) + FaceNet-512 (embedding), **both MIT**, baked into the image and verified by SHA-256 at build time — never downloaded at runtime, so airgapped installs work and a model cannot change under a running deployment. See [`ml/LICENSES.md`](https://github.com/PicPeak/picpeak/blob/main/ml/LICENSES.md) for why these and not InsightFace's non-commercial weights.
## Not available on the all-in-one image
[`picpeak/aio`](https://hub.docker.com/r/picpeak/aio) sets `PICPEAK_SINGLE_CONTAINER=true` and the backend refuses to enable face recognition there — a second image-processing pipeline competing with thumbnailing for one small container's CPU would not fail loudly, it would just make the install slow. Run the multi-container deployment for this feature.
## Docs
**https://docs.picpeak.app** · sidecar internals, model conversion and the alignment/threshold contract: [`ml/README.md`](https://github.com/PicPeak/picpeak/blob/main/ml/README.md)
+9 -1
View File
@@ -1,6 +1,8 @@
# Docker Build and Push Workflow
This GitHub Actions workflow automatically builds and pushes Docker images for both the backend and frontend to GitHub Container Registry (ghcr.io).
This GitHub Actions workflow automatically builds and pushes Docker images for the backend, the frontend, the all-in-one image and the optional ML sidecar to GitHub Container Registry (ghcr.io). On the canonical org repo every one of them is mirrored to Docker Hub as `docker.io/picpeak/{backend,frontend,aio,ml}`; forks build the same images GHCR-only.
The **all-in-one image** (`<repo>/aio`, built from `Dockerfile.aio` at the repo root, #1042) bundles the backend and the built frontend into a single container with SQLite as the default engine — one `docker run`, no compose. It follows the same per-arch build → digest-merge → per-version tag scheme as the other two images, is mirrored to Docker Hub (`docker.io/picpeak/aio`) alongside GHCR on the canonical org repo, and every PR additionally runs a `smoke-aio` job that boots the image and asserts the SPA shell, brand-title rendering, immutable asset caching, and the SQLite engine resolution.
## Features
@@ -10,6 +12,7 @@ This GitHub Actions workflow automatically builds and pushes Docker images for b
- 🔒 **Security scanning** with Trivy vulnerability scanner
- 💾 **Build caching** for faster subsequent builds
- 📊 **Build summaries** in GitHub Actions UI
- 📝 **Docker Hub pages** for `aio` and `ml` synced from `.github/dockerhub/*.md` on every `main` merge (`dockerhub-descriptions` job). `backend` and `frontend` pages are still hand-maintained in the Hub UI — add `.github/dockerhub/{backend,frontend}.md` with their current text before putting them under the same job.
## Authentication
@@ -52,6 +55,11 @@ docker pull ghcr.io/picpeak/picpeak/backend:v1.0.0
# Pull for specific architecture
docker pull --platform linux/arm64 ghcr.io/picpeak/picpeak/backend:latest
# The same images on Docker Hub (identical tags, identical digests)
docker pull picpeak/backend:latest
docker pull picpeak/aio:stable
docker pull picpeak/ml:stable
```
### Using in Docker Compose
File diff suppressed because it is too large Load Diff
+7 -26
View File
@@ -25,33 +25,14 @@ jobs:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
target-branch: stable
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
# whenever no PAT is configured.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout in this job — set the repo explicitly so gh works
# without a git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
# is a valid review; enable auto-merge as the PAT so the merge commit is
# attributed to a real identity and triggers the tag-cutting run (#719).
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
# NOTE: stable release PRs are intentionally NOT auto-merged here
# anymore. Fixes accumulate in the rolling release PR and are cut as
# ONE patch version per day by release-stable-daily.yml (18:00 UTC,
# or on demand via workflow_dispatch / a manual merge of the release
# PR). Beta keeps instant releases — see release-please-beta.yml —
# because same-day reporter verification depends on it.
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -0,0 +1,86 @@
name: Cut Stable Release (daily batch)
# Stable fixes accumulate in release-please's rolling release PR instead of
# each cutting its own patch version (the old per-merge auto-merge produced
# e.g. 3.45.8 AND 3.45.9 on the same day). This workflow merges the open
# stable release PR once a day, so a day of N bugfixes ships as ONE version
# with all N changelog entries — and one Docker build instead of N.
#
# - schedule only fires from the default branch (main); the stable copy of
# this file is inert and exists to keep the branches in sync.
# - Need a release NOW? Run this via workflow_dispatch, or merge the
# release PR by hand — the schedule is a default, not a gate.
# - Approval/merge mechanics mirror the old inline step (#719): approve as
# github-actions[bot] (GITHUB_TOKEN, a valid distinct reviewer), enable
# auto-merge as the PAT so the merge attributes to a real identity and
# triggers the tag-cutting run. --auto waits for green checks.
on:
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
merge-stable-release-pr:
runs-on: ubuntu-latest
steps:
- name: Approve and enable auto-merge on the open stable release PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout — set the repo explicitly so gh works without a
# git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping (manual review required)."
exit 0
fi
# Strict selection (review P1): this job runs daily even without a
# stable push, and `gh pr list --head` matches the branch NAME only
# — a fork PR can spoof `release-please--branches--stable`. Pin the
# base to stable AND require a same-repo head (isCrossRepository
# == false); a fork PR is cross-repository, so it can never be
# picked and auto-merged with the privileged PAT.
pr=$(gh pr list \
--base stable \
--head release-please--branches--stable \
--state open \
--json number,isCrossRepository \
--jq '[.[] | select(.isCrossRepository == false)] | .[0].number // empty')
if [ -z "$pr" ]; then
echo "No open same-repo stable release PR — nothing to cut today."
exit 0
fi
# Approve is tolerant — a pre-existing approval already satisfies
# branch protection and re-approving can return non-zero.
gh pr review "$pr" --approve --body "Automated approval — daily stable release batch (release-please version bump + changelog)." || echo "::warning::approve returned non-zero (PR may already be approved)"
# But the auto-merge enable is the load-bearing step: this scheduled
# job is the ONLY automatic stable cut, so DON'T swallow its failure
# (review P2) — an expired/under-scoped PAT would otherwise stop
# releases while the workflow stays green.
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto
# `gh pr merge --auto` merges IMMEDIATELY when the required checks
# are already green — the normal case at 18:00, since the fixes
# merged hours earlier and CI passed. So success is EITHER the PR is
# already merged OR an auto-merge request is now pending; only a PR
# that is still open with no auto-merge request is a real failure
# (expired/under-scoped PAT) worth failing the job on (review round 2).
# One snapshot of both fields (review round 3): querying state and
# autoMergeRequest separately races — auto-merge can complete
# between the two calls, so the first sees OPEN and the second sees
# the request already cleared on the now-merged PR → false failure.
read -r state automerge < <(gh pr view "$pr" --json state,autoMergeRequest \
--jq '[.state, (.autoMergeRequest != null)] | @tsv')
if [ "$state" = "MERGED" ]; then
echo "Stable release PR #$pr merged immediately (checks were already green)."
elif [ "$automerge" = "true" ]; then
echo "Auto-merge enabled on stable release PR #$pr — merges when checks are green."
else
echo "::error::stable release PR #$pr is still open with no auto-merge — check RELEASE_PLEASE_TOKEN scope/expiry."
exit 1
fi
+70 -3
View File
@@ -17,9 +17,9 @@ name: Tests
on:
push:
branches: [main, beta]
branches: [main, beta, stable]
pull_request:
branches: [main, beta]
branches: [main, beta, stable]
workflow_dispatch:
permissions:
@@ -28,7 +28,41 @@ permissions:
jobs:
backend:
runs-on: ubuntu-latest
timeout-minutes: 10
# 20, not 10. This job normally finishes in ~3 minutes, but it is the only
# one that boots Postgres and runs the full integration suite, so it is the
# only one exposed to runner contention — observed spread has reached 9.2
# minutes, and a release PR (#1088) was cancelled at 10.3 with every test
# passing and jest still running. A cancelled job reads as a red X on a
# green branch, which costs a re-run and a diagnosis every time it happens.
#
# The cap is a runaway guard, not a performance budget; 20 leaves real
# headroom over the worst observed run while still killing a hung suite
# well inside the hour GitHub would otherwise allow. frontend and ml keep
# 10 — they take seconds and have never come close.
timeout-minutes: 20
# The .picpeak restore suites gate their real-Postgres cases behind
# PICPEAK_PG_TEST_URL and `describe.skip` themselves out when it is
# unset — so until now they never ran here. That hid the half that
# matters: sequence resync, operator/role preservation across a
# cross-instance restore, and (with #1041) whether a SQLite-shaped
# row actually lands in Postgres with the right STORED VALUES rather
# than merely not throwing. Everything else in the suite still runs
# on SQLite; this service only un-gates those cases.
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: picpeak
POSTGRES_PASSWORD: testpass
POSTGRES_DB: picpeak_test
options: >-
--health-cmd "pg_isready -U picpeak -d picpeak_test"
--health-interval 2s
--health-timeout 2s
--health-retries 30
ports:
- 5432:5432
steps:
- name: Checkout code
@@ -52,6 +86,9 @@ jobs:
# The S3 path itself is covered separately by the integration
# suite when MinIO is provisioned.
SKIP_S3_TESTS: 'true'
# Un-gates the real-Postgres cases in the .picpeak restore suites
# (see the `services:` note above). Absent it they silently skip.
PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test'
run: |
# Excluded suites — fail on upstream/beta too, tracked
# separately as test-infra debt:
@@ -87,3 +124,33 @@ jobs:
- name: Run Vitest suite
working-directory: ./frontend
run: npm test -- --run
# Optional face-detection sidecar (#1074). Runs on every PR regardless of
# whether the feature is enabled anywhere — these tests need no model
# weights (they stub the pipeline out) and cover the auth boundary, the
# request guards and the alignment geometry, which is where a mistake is a
# security problem or a silent accuracy problem rather than a visible bug.
ml:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
# Matches ml/Dockerfile's base image, so a wheel that resolves here
# resolves in the image too.
python-version: '3.12'
cache: 'pip'
cache-dependency-path: ml/requirements.txt
- name: Install ml deps
working-directory: ./ml
run: pip install -r requirements.txt pytest httpx
- name: Run pytest suite
working-directory: ./ml
run: python -m pytest tests/ -q
+15 -2
View File
@@ -130,5 +130,18 @@ docker-compose.dev.yml
# New layout development files
new-layouts/
# Generated CRM/accounting documents (runtime) — never commit
backend/storage/business-docs/
# Backend runtime storage (generated media, previews, thumbnails,
# CRM/accounting documents) — never commit
backend/storage/
# Python artifacts — the picpeak-ml sidecar (#1074) is the only Python in
# this tree, but bytecode and virtualenvs must never be committed.
__pycache__/
*.py[cod]
.pytest_cache/
ml/.venv/
ml/venv/
# Locally produced model weights. The image fetches these by pinned URL and
# SHA-256 at build time; a 90MB blob must not end up in git history.
ml/*.onnx
ml/*.h5
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.79.1-beta.0"
".": "3.121.3-beta.0"
}
+1 -3
View File
@@ -1,3 +1 @@
{
".": "2.6.1"
}
{".":"3.44.0"}
+924
View File
@@ -5,6 +5,930 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.121.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.121.2-beta.0...v3.121.3-beta.0) (2026-08-30)
### Bug Fixes
* **archives:** take the restored category from the manifest ([#1240](https://github.com/PicPeak/picpeak/issues/1240)) ([0d340f4](https://github.com/PicPeak/picpeak/commit/0d340f4e813dd8c1c0cdcacd3abb96607b0de5b5))
## [3.121.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.121.1-beta.0...v3.121.2-beta.0) (2026-08-29)
### Bug Fixes
* **watcher:** stop re-importing a photo whose file was replaced ([#1226](https://github.com/PicPeak/picpeak/issues/1226)) ([#1237](https://github.com/PicPeak/picpeak/issues/1237)) ([6ca8baa](https://github.com/PicPeak/picpeak/commit/6ca8baab23a659b1329dded05002722fd9eaddbc))
## [3.121.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.121.0-beta.0...v3.121.1-beta.0) (2026-08-29)
### Bug Fixes
* **email:** keep the webhook payload out of the logs, and bound the response read ([#1225](https://github.com/PicPeak/picpeak/issues/1225)) ([#1233](https://github.com/PicPeak/picpeak/issues/1233)) ([0d41fe5](https://github.com/PicPeak/picpeak/commit/0d41fe5bf145bff5b5db48090868ec74e9980842))
## [3.121.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.120.0-beta.0...v3.121.0-beta.0) (2026-08-29)
### Features
* **email:** webhook transport as an alternative to SMTP ([#1225](https://github.com/PicPeak/picpeak/issues/1225)) ([#1231](https://github.com/PicPeak/picpeak/issues/1231)) ([d62407f](https://github.com/PicPeak/picpeak/commit/d62407f431b8e34c228ecefb9338df325fbd97a3))
### Bug Fixes
* **export:** name the camera master in photo exports, not the delivered render ([#1229](https://github.com/PicPeak/picpeak/issues/1229)) ([#1230](https://github.com/PicPeak/picpeak/issues/1230)) ([f4c054a](https://github.com/PicPeak/picpeak/commit/f4c054a661e8cb52e1f75a2445b07a427ad613f2))
* **feedback:** name the camera original in the exports, not just the stored file ([#1224](https://github.com/PicPeak/picpeak/issues/1224)) ([#1228](https://github.com/PicPeak/picpeak/issues/1228)) ([4f684eb](https://github.com/PicPeak/picpeak/commit/4f684eb482ee3bef9a6d4d3e63fe35be0cb99700))
## [3.120.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.119.0-beta.0...v3.120.0-beta.0) (2026-08-28)
### Features
* **api:** Lightroom round-trip — read proofing marks, put finished edits back ([#745](https://github.com/PicPeak/picpeak/issues/745)) ([#1165](https://github.com/PicPeak/picpeak/issues/1165)) ([8db8527](https://github.com/PicPeak/picpeak/commit/8db8527f9ea3edda7e3267512d4cb1eadaaa95d7))
## [3.119.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.118.0-beta.0...v3.119.0-beta.0) (2026-08-28)
### Features
* **admin:** shift-click range selection in the photo grid ([#1212](https://github.com/PicPeak/picpeak/issues/1212)) ([#1213](https://github.com/PicPeak/picpeak/issues/1213)) ([f18bc56](https://github.com/PicPeak/picpeak/commit/f18bc568c88dabcd305595f365838ab6470b975f))
### Bug Fixes
* **gallery:** make the returning-guest recovery findable ([#1210](https://github.com/PicPeak/picpeak/issues/1210)) ([#1217](https://github.com/PicPeak/picpeak/issues/1217)) ([1f3f7e9](https://github.com/PicPeak/picpeak/commit/1f3f7e9c0299806eb6b981669c1ba3ec811ab266))
* **guests:** surface duplicate guest registrations, and stop making so many ([#1210](https://github.com/PicPeak/picpeak/issues/1210)) ([#1216](https://github.com/PicPeak/picpeak/issues/1216)) ([5c85e0c](https://github.com/PicPeak/picpeak/commit/5c85e0c0e42a826eb7f63f2be8ed1a3908ca0d91))
## [3.118.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.117.0-beta.0...v3.118.0-beta.0) (2026-08-28)
### Features
* **feedback:** a third identity mode with one shared colour tag per photo ([#1197](https://github.com/PicPeak/picpeak/issues/1197)) ([#1208](https://github.com/PicPeak/picpeak/issues/1208)) ([22e00f8](https://github.com/PicPeak/picpeak/commit/22e00f80b6f2afe3f0721a29f5eeab0835e4c2c0))
## [3.117.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.116.1-beta.0...v3.117.0-beta.0) (2026-08-28)
### Features
* **gallery:** folders that contain photos instead of filtering them ([#1160](https://github.com/PicPeak/picpeak/issues/1160)) ([#1161](https://github.com/PicPeak/picpeak/issues/1161)) ([0a36ca6](https://github.com/PicPeak/picpeak/commit/0a36ca605662db5ff6d215ea7164e13b166b3121))
### Bug Fixes
* **admin:** the "Uncategorized" photo filter returns every photo ([#1211](https://github.com/PicPeak/picpeak/issues/1211)) ([#1214](https://github.com/PicPeak/picpeak/issues/1214)) ([a490b64](https://github.com/PicPeak/picpeak/commit/a490b649542371c70493feb79b0af844deeae006))
* **setup:** put the setup token where a NAS user can find it ([#1218](https://github.com/PicPeak/picpeak/issues/1218)) ([#1219](https://github.com/PicPeak/picpeak/issues/1219)) ([696c69a](https://github.com/PicPeak/picpeak/commit/696c69a6d02e6f1cf01d83470825215feacacd00))
## [3.116.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.116.0-beta.0...v3.116.1-beta.0) (2026-08-27)
### Bug Fixes
* **images:** fence the capture-date backfill on the file it read ([#1201](https://github.com/PicPeak/picpeak/issues/1201)) ([#1204](https://github.com/PicPeak/picpeak/issues/1204)) ([cec8eff](https://github.com/PicPeak/picpeak/commit/cec8eff70ce55d93bdb0b582ec39de400711892b))
## [3.116.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.115.4-beta.0...v3.116.0-beta.0) (2026-08-26)
### Features
* **auth:** make the admin "Remember me" checkbox actually do something ([#1186](https://github.com/PicPeak/picpeak/issues/1186)) ([#1195](https://github.com/PicPeak/picpeak/issues/1195)) ([d3e9a7c](https://github.com/PicPeak/picpeak/commit/d3e9a7cf0d55aaf0c83bd32031f00259da2af11f))
### Bug Fixes
* **gallery:** show colour labels in the Carousel layout ([#1189](https://github.com/PicPeak/picpeak/issues/1189)) ([#1196](https://github.com/PicPeak/picpeak/issues/1196)) ([da80216](https://github.com/PicPeak/picpeak/commit/da802169a87602d34d89f58eb311d1e99b40e8bd))
## [3.115.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.115.3-beta.0...v3.115.4-beta.0) (2026-08-26)
### Bug Fixes
* **images:** backfill orientation for libraries that predate the fix ([#1199](https://github.com/PicPeak/picpeak/issues/1199)) ([edef4d7](https://github.com/PicPeak/picpeak/commit/edef4d73653e92a3e884f703c7ac578e4288ba41))
* **images:** respect EXIF orientation in thumbnails, heroes and previews ([#1194](https://github.com/PicPeak/picpeak/issues/1194)) ([c18f54e](https://github.com/PicPeak/picpeak/commit/c18f54ede065c47fec47aca0e0c35c139ae4410a))
## [3.115.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.115.2-beta.0...v3.115.3-beta.0) (2026-08-26)
### Bug Fixes
* **admin:** gate the dimension repair as system maintenance ([#1182](https://github.com/PicPeak/picpeak/issues/1182)) ([3991dc3](https://github.com/PicPeak/picpeak/commit/3991dc3ccb67e4e33a91b171dd9eb77c0c262502))
* **admin:** make "Storage used" report storage used ([#1164](https://github.com/PicPeak/picpeak/issues/1164)) ([#1170](https://github.com/PicPeak/picpeak/issues/1170)) ([849a580](https://github.com/PicPeak/picpeak/commit/849a5807b7174d05e7f4769c8984843e0d0805e2))
* **admin:** move the maintenance sweeps' run state into the database ([#1181](https://github.com/PicPeak/picpeak/issues/1181)) ([#1184](https://github.com/PicPeak/picpeak/issues/1184)) ([05e23ef](https://github.com/PicPeak/picpeak/commit/05e23ef1a1c78b426a21d3106ca5d732958c31a6))
* **external-media:** record capture dates on import, and backfill existing libraries ([#1172](https://github.com/PicPeak/picpeak/issues/1172)) ([#1179](https://github.com/PicPeak/picpeak/issues/1179)) ([410b8f8](https://github.com/PicPeak/picpeak/commit/410b8f8f6f7258c285446b3454164c9a20f2280f))
* **gallery:** show other guests' colour labels in the grid ([#1178](https://github.com/PicPeak/picpeak/issues/1178)) ([#1180](https://github.com/PicPeak/picpeak/issues/1180)) ([51d20c5](https://github.com/PicPeak/picpeak/commit/51d20c5920ec6cd5aa9bbe8504fd2aa59c180545))
* **gallery:** stop the lightbox loading originals to display a photo ([#1166](https://github.com/PicPeak/picpeak/issues/1166)) ([#1169](https://github.com/PicPeak/picpeak/issues/1169)) ([77953c1](https://github.com/PicPeak/picpeak/commit/77953c15c12affd1987bd2e78bac815ffa64d0fc))
* **previews:** preserve alpha and animation in the preview tier ([#1171](https://github.com/PicPeak/picpeak/issues/1171)) ([1366d6d](https://github.com/PicPeak/picpeak/commit/1366d6d14cd07e05ede043ff8cfb368cddc76362))
## [3.115.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.115.1-beta.0...v3.115.2-beta.0) (2026-08-26)
### Bug Fixes
* **external-media:** store external paths from the media root ([#1163](https://github.com/PicPeak/picpeak/issues/1163)) ([#1168](https://github.com/PicPeak/picpeak/issues/1168)) ([a7b74bc](https://github.com/PicPeak/picpeak/commit/a7b74bcd87fa9700351331e65a631e31c89d1354))
## [3.115.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.115.0-beta.0...v3.115.1-beta.0) (2026-08-26)
### Bug Fixes
* **external-media:** one row per external file per event ([#1162](https://github.com/PicPeak/picpeak/issues/1162)) ([#1167](https://github.com/PicPeak/picpeak/issues/1167)) ([06da1b9](https://github.com/PicPeak/picpeak/commit/06da1b9f7eefa5ff216a068f64fa648f49e7084a))
## [3.115.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.114.0-beta.0...v3.115.0-beta.0) (2026-08-23)
### Features
* **faces:** make "not the same person" survive a re-scan ([#1132](https://github.com/PicPeak/picpeak/issues/1132)) ([#1145](https://github.com/PicPeak/picpeak/issues/1145)) ([c305ad4](https://github.com/PicPeak/picpeak/commit/c305ad41469bd04647e71ba768bfce26f20c4c96))
### Bug Fixes
* **gallery:** a guest's own hidden feedback is hidden from them too ([#1150](https://github.com/PicPeak/picpeak/issues/1150)) ([#1153](https://github.com/PicPeak/picpeak/issues/1153)) ([2c81888](https://github.com/PicPeak/picpeak/commit/2c81888eafadc083a4654464ea6e955abc7e8704))
* **gallery:** guest filters respect show_feedback_to_guests, and marks survive a mid-write clear ([#1147](https://github.com/PicPeak/picpeak/issues/1147)) ([00b20b2](https://github.com/PicPeak/picpeak/commit/00b20b2d72ffb9a8418cda4479d1e34df1b65ccf))
* **gallery:** no Logout button on galleries that don't require a password ([#1149](https://github.com/PicPeak/picpeak/issues/1149)) ([#1152](https://github.com/PicPeak/picpeak/issues/1152)) ([e4a8be8](https://github.com/PicPeak/picpeak/commit/e4a8be8e7e8ede850f07a43c229c3e4e28e0e59f))
* **scripts:** regenerate-thumbnails resolves external sources through ensureThumbnail ([#1148](https://github.com/PicPeak/picpeak/issues/1148)) ([#1151](https://github.com/PicPeak/picpeak/issues/1151)) ([b581267](https://github.com/PicPeak/picpeak/commit/b5812670318a852b4731d326f15f2623e0302395))
## [3.114.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.113.0-beta.0...v3.114.0-beta.0) (2026-08-23)
### Features
* **gallery:** colour labels for client proofing, and one global default per feedback type ([#1044](https://github.com/PicPeak/picpeak/issues/1044)) ([#1137](https://github.com/PicPeak/picpeak/issues/1137)) ([e2844d1](https://github.com/PicPeak/picpeak/commit/e2844d190969269e53dfac9a74ebd8fe94e042dc))
## [3.113.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.112.0-beta.0...v3.113.0-beta.0) (2026-08-22)
### Features
* **faces:** consolidate look-alike clusters after a scan, and suggest the rest ([#1107](https://github.com/PicPeak/picpeak/issues/1107)) ([3583c92](https://github.com/PicPeak/picpeak/commit/3583c924dae999e5edf6bf86c4611a035c9bd986))
### Bug Fixes
* **gallery:** a missing thumbnail tier must not take the backend down ([#1128](https://github.com/PicPeak/picpeak/issues/1128)) ([f735d26](https://github.com/PicPeak/picpeak/commit/f735d26422ddd7e4f83cdbaf6fa10c2120dc82a4))
* **gallery:** give masonry tiles their real shape back ([#1130](https://github.com/PicPeak/picpeak/issues/1130), [#1131](https://github.com/PicPeak/picpeak/issues/1131)) ([87115b2](https://github.com/PicPeak/picpeak/commit/87115b28e8aa4d955adc6534103d4cf1fb15485b))
* **thumbnails:** regenerate external photos instead of dropping their tiers ([#1129](https://github.com/PicPeak/picpeak/issues/1129)) ([97d92f8](https://github.com/PicPeak/picpeak/commit/97d92f8428e28852011456ab5785e1f70dce5e8b))
### Documentation
* **faces:** link the face-recognition guidance from where people look ([#1125](https://github.com/PicPeak/picpeak/issues/1125)) ([25fbefc](https://github.com/PicPeak/picpeak/commit/25fbefc703c0531060203bcdb3910a590a6bfdb2))
## [3.112.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.111.1-beta.0...v3.112.0-beta.0) (2026-08-22)
### Features
* **deploy:** make the all-in-one image installable without a shell ([#1124](https://github.com/PicPeak/picpeak/issues/1124)) ([7223118](https://github.com/PicPeak/picpeak/commit/7223118b894ffa47bf8dedca5f77077983d29d52))
## [3.111.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.111.0-beta.0...v3.111.1-beta.0) (2026-08-22)
### Bug Fixes
* **faces:** dark-mode styling for the People surfaces ([#1106](https://github.com/PicPeak/picpeak/issues/1106)) ([#1126](https://github.com/PicPeak/picpeak/issues/1126)) ([24e11df](https://github.com/PicPeak/picpeak/commit/24e11df2991856753541aaaed380974a5eb267e4))
## [3.111.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.110.0-beta.0...v3.111.0-beta.0) (2026-08-21)
### Features
* **faces:** show a detected face in its source photo, outlined ([#1120](https://github.com/PicPeak/picpeak/issues/1120)) ([38c27d0](https://github.com/PicPeak/picpeak/commit/38c27d097c593283c253208bb3bb547cb2512c32))
## [3.110.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.109.0-beta.0...v3.110.0-beta.0) (2026-08-21)
### Features
* **faces:** let the photographer choose which photo represents a person ([#1119](https://github.com/PicPeak/picpeak/issues/1119)) ([bbce3cd](https://github.com/PicPeak/picpeak/commit/bbce3cd2a2822a3c53bbb9acdd60c0c3c41a5d7c))
* **gallery:** responsive grid thumbnails ([#1095](https://github.com/PicPeak/picpeak/issues/1095)) ([#1109](https://github.com/PicPeak/picpeak/issues/1109)) ([887bdbe](https://github.com/PicPeak/picpeak/commit/887bdbe6e5cdc7db2f57674dd94b5308dfed0dff))
### Bug Fixes
* **security:** let cors() own Access-Control-Allow-Origin on protected images ([#1118](https://github.com/PicPeak/picpeak/issues/1118)) ([0077623](https://github.com/PicPeak/picpeak/commit/00776234fd6683186c08ffcb510d1145586ad7e9))
* **ui:** stop iOS Safari zooming in on 14px form fields ([#1113](https://github.com/PicPeak/picpeak/issues/1113)) ([d241919](https://github.com/PicPeak/picpeak/commit/d24191960476d042e9c99d852db782c25e8340f9))
## [3.109.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.108.1-beta.0...v3.109.0-beta.0) (2026-08-21)
### Features
* **setup:** configure the public address and SMTP in the wizard, not .env ([#1104](https://github.com/PicPeak/picpeak/issues/1104)) ([9431b9f](https://github.com/PicPeak/picpeak/commit/9431b9f0949e8e51c486019224ca92f46443c50e))
## [3.108.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.108.0-beta.0...v3.108.1-beta.0) (2026-08-20)
### Bug Fixes
* **faces:** face avatars were cropped against a cropped rendition ([#1100](https://github.com/PicPeak/picpeak/issues/1100)) ([b3a7ab2](https://github.com/PicPeak/picpeak/commit/b3a7ab27ea6ecbae30f5b3eb5716c861b3660a73))
## [3.108.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.107.5-beta.0...v3.108.0-beta.0) (2026-08-20)
### Features
* **gallery:** sized preview tiers so phones stop pulling 1920px ([#1095](https://github.com/PicPeak/picpeak/issues/1095)) ([#1099](https://github.com/PicPeak/picpeak/issues/1099)) ([011f6ae](https://github.com/PicPeak/picpeak/commit/011f6ae7eca48b41b98f4d4beab0aceaa9d34e6c))
### Bug Fixes
* **faces:** defer on unreachable storage, and commit the import path first ([#1097](https://github.com/PicPeak/picpeak/issues/1097)) ([0b886ed](https://github.com/PicPeak/picpeak/commit/0b886ed9428b31831c86ad4ddafd0c0a98e4ac3a))
## [3.107.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.107.4-beta.0...v3.107.5-beta.0) (2026-08-20)
### Documentation
* **readme:** point the single-container install at a tag that exists ([2a84efe](https://github.com/PicPeak/picpeak/commit/2a84efef719e15998a693947f80ed2aa9931ff85))
* **readme:** point the single-container install at a tag that exists ([e47c103](https://github.com/PicPeak/picpeak/commit/e47c103c2a9011e45cd43c8476a0683e8896db2b))
## [3.107.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.107.3-beta.0...v3.107.4-beta.0) (2026-08-20)
### Documentation
* **docker:** Hub pages for aio + ml, and the image table in the README ([899c9b3](https://github.com/PicPeak/picpeak/commit/899c9b34072ca73ddc4391b74ead43ef4157b235))
## [3.107.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.107.2-beta.0...v3.107.3-beta.0) (2026-08-20)
### Bug Fixes
* **faces:** scan external/reference photos instead of skipping them ([#1090](https://github.com/PicPeak/picpeak/issues/1090)) ([#1091](https://github.com/PicPeak/picpeak/issues/1091)) ([576924f](https://github.com/PicPeak/picpeak/commit/576924fa574c0fdbaf167a885216ad3ee6ebf66b))
## [3.107.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.107.1-beta.0...v3.107.2-beta.0) (2026-08-19)
### Bug Fixes
* **faces:** restore the :beta image tag and surface sidecar health ([#1087](https://github.com/PicPeak/picpeak/issues/1087)) ([37a15e3](https://github.com/PicPeak/picpeak/commit/37a15e3d49de5cad83b7e7b466153a732e64c45e))
## [3.107.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.107.0-beta.0...v3.107.1-beta.0) (2026-08-19)
### Bug Fixes
* **preview:** generate lightbox previews for external/reference photos ([#1078](https://github.com/PicPeak/picpeak/issues/1078)) ([#1079](https://github.com/PicPeak/picpeak/issues/1079)) ([af7970b](https://github.com/PicPeak/picpeak/commit/af7970b069d219e32d22c005dd5adf4204d0d514))
## [3.107.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.106.0-beta.0...v3.107.0-beta.0) (2026-08-18)
### Features
* **faces:** People in this gallery — face recognition via an optional ML sidecar ([#1074](https://github.com/PicPeak/picpeak/issues/1074)) ([#1075](https://github.com/PicPeak/picpeak/issues/1075)) ([b69dd13](https://github.com/PicPeak/picpeak/commit/b69dd134d0f5b1570ace261af4541d36771e62cd))
## [3.106.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.105.1-beta.0...v3.106.0-beta.0) (2026-08-18)
### Features
* **docker:** all-in-one image ([#1042](https://github.com/PicPeak/picpeak/issues/1042)) — my version of [#1067](https://github.com/PicPeak/picpeak/issues/1067) ([#1068](https://github.com/PicPeak/picpeak/issues/1068)) ([0874a30](https://github.com/PicPeak/picpeak/commit/0874a30ac94483e5a725bf2bb047dca11880129c))
## [3.105.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.105.0-beta.0...v3.105.1-beta.0) (2026-08-16)
### Bug Fixes
* **gallery:** make per-event banner overrides actually work, both banners ([#440](https://github.com/PicPeak/picpeak/issues/440), [#932](https://github.com/PicPeak/picpeak/issues/932)) ([#1064](https://github.com/PicPeak/picpeak/issues/1064)) ([52db982](https://github.com/PicPeak/picpeak/commit/52db9826610a41f156b4878c142abfc71dea8b07))
## [3.105.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.104.1-beta.0...v3.105.0-beta.0) (2026-08-16)
### Features
* **gallery:** info banner above the photo grid ([#932](https://github.com/PicPeak/picpeak/issues/932)) ([#1063](https://github.com/PicPeak/picpeak/issues/1063)) ([b48fa62](https://github.com/PicPeak/picpeak/commit/b48fa62eea5117c3e09bb6c8a7d3dc02931ee8f9))
## [3.104.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.104.0-beta.0...v3.104.1-beta.0) (2026-08-16)
### Bug Fixes
* **pdf:** RFC 6266-encode Content-Disposition on quote/invoice PDFs ([#1024](https://github.com/PicPeak/picpeak/issues/1024)) ([#1055](https://github.com/PicPeak/picpeak/issues/1055)) ([3a11e6e](https://github.com/PicPeak/picpeak/commit/3a11e6ebb5542a526a92c66f12e13d5a30aba7b2))
## [3.104.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.103.1-beta.0...v3.104.0-beta.0) (2026-08-16)
### Features
* **backup:** open sqlite → pg .picpeak restore as the supported upgrade direction ([#1041](https://github.com/PicPeak/picpeak/issues/1041)) ([#1043](https://github.com/PicPeak/picpeak/issues/1043)) ([8809564](https://github.com/PicPeak/picpeak/commit/8809564aadc1782484e0369ddbf03850530680a6))
## [3.103.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.103.0-beta.0...v3.103.1-beta.0) (2026-08-16)
### Bug Fixes
* **storage:** add S3 client timeouts so a dropped connection can't wedge uploads ([#1049](https://github.com/PicPeak/picpeak/issues/1049)) ([3600231](https://github.com/PicPeak/picpeak/commit/3600231d5f059cb7fd7250430a26bf0b0396a87f))
## [3.103.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.102.2-beta.0...v3.103.0-beta.0) (2026-08-16)
### Features
* **permissions:** granular permission gating + role editor & presets ([#747](https://github.com/PicPeak/picpeak/issues/747), phase 1 of [#743](https://github.com/PicPeak/picpeak/issues/743)) ([#1045](https://github.com/PicPeak/picpeak/issues/1045)) ([b118695](https://github.com/PicPeak/picpeak/commit/b118695474b30f848e79d0ce8f52e051b5b8217b))
## [3.102.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.102.1-beta.0...v3.102.2-beta.0) (2026-08-13)
### Bug Fixes
* **docker:** default NODE_ENV=production so non-compose deploys don't fall back to SQLite ([#1038](https://github.com/PicPeak/picpeak/issues/1038)) ([#1039](https://github.com/PicPeak/picpeak/issues/1039)) ([6de30e5](https://github.com/PicPeak/picpeak/commit/6de30e5bf17b54601e64d1b0b6a31d8877aa642d))
* **events:** make event_date/expires_at nullable on SQLite ([#1029](https://github.com/PicPeak/picpeak/issues/1029)) ([#1035](https://github.com/PicPeak/picpeak/issues/1035)) ([671c4db](https://github.com/PicPeak/picpeak/commit/671c4dbd56fde69d512c09af2e928f1899c5ad8a))
* **feedback:** persist guest feedback settings, unshadow the guest route ([#1030](https://github.com/PicPeak/picpeak/issues/1030)) ([#1031](https://github.com/PicPeak/picpeak/issues/1031)) ([89dc962](https://github.com/PicPeak/picpeak/commit/89dc9623c154493eb50f94a25da3e774e8c215b1))
* **gallery:** coerce SQLite 0/1 booleans in the guest surface ([#1028](https://github.com/PicPeak/picpeak/issues/1028)) ([#1034](https://github.com/PicPeak/picpeak/issues/1034)) ([34ee311](https://github.com/PicPeak/picpeak/commit/34ee31141b0e6b93c75ea91fd6d82ca35f72eacb))
## [3.102.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.102.0-beta.0...v3.102.1-beta.0) (2026-08-11)
### Documentation
* flip README links to docs.picpeak.app + delete docs/_to-migrate ([#1000](https://github.com/PicPeak/picpeak/issues/1000) phase 3) ([#1023](https://github.com/PicPeak/picpeak/issues/1023)) ([27dedb1](https://github.com/PicPeak/picpeak/commit/27dedb13f390f956690e667d41ed532bce697eb4))
## [3.102.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.101.5-beta.0...v3.102.0-beta.0) (2026-08-11)
### Features
* **downloads:** per-gallery download resolutions ([#858](https://github.com/PicPeak/picpeak/issues/858)) ([#1022](https://github.com/PicPeak/picpeak/issues/1022)) ([8e35737](https://github.com/PicPeak/picpeak/commit/8e3573788b1bde8768d023e779ea3361c91f6223))
## [3.101.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.101.4-beta.0...v3.101.5-beta.0) (2026-08-10)
### Bug Fixes
* **slideshow:** stop "no crop" fit letterboxing a pre-cropped frame ([#1015](https://github.com/PicPeak/picpeak/issues/1015)) ([#1018](https://github.com/PicPeak/picpeak/issues/1018)) ([75bfad2](https://github.com/PicPeak/picpeak/commit/75bfad2b6ae2e7a9a622d0c648df30db83c69b14))
## [3.101.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.101.3-beta.0...v3.101.4-beta.0) (2026-08-10)
### Bug Fixes
* **deps:** bump nanoid and js-yaml out of two HIGH advisories ([#1013](https://github.com/PicPeak/picpeak/issues/1013)) ([e3830cd](https://github.com/PicPeak/picpeak/commit/e3830cd9219ad4e81b556c7682e0f48a806f1620))
## [3.101.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.101.2-beta.0...v3.101.3-beta.0) (2026-08-10)
### Bug Fixes
* **auth:** issuer-tag the oversize SSO logout marker ([#798](https://github.com/PicPeak/picpeak/issues/798)) ([#1010](https://github.com/PicPeak/picpeak/issues/1010)) ([a607cea](https://github.com/PicPeak/picpeak/commit/a607cea11018e68aea8797160dbde7f34b8eca44))
## [3.101.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.101.1-beta.0...v3.101.2-beta.0) (2026-08-10)
### Bug Fixes
* **branding:** route the gallery footer through &lt;PoweredBy /&gt; ([#1008](https://github.com/PicPeak/picpeak/issues/1008)) ([1bf19a7](https://github.com/PicPeak/picpeak/commit/1bf19a7caf45b7f800b3650b2cd2365ea89cb169))
## [3.101.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.101.0-beta.0...v3.101.1-beta.0) (2026-08-10)
### Documentation
* slim README to a lean router, stage deep content for docs-site migration ([#1001](https://github.com/PicPeak/picpeak/issues/1001)) ([ddebd50](https://github.com/PicPeak/picpeak/commit/ddebd50d3fd3750f97f13a07afb38447601e3889))
## [3.101.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.100.2-beta.0...v3.101.0-beta.0) (2026-08-09)
### Features
* **transfers:** add PicTransfer — cross-event file transfers ([#998](https://github.com/PicPeak/picpeak/issues/998)) ([2e495d7](https://github.com/PicPeak/picpeak/commit/2e495d7c489c3195c6e1c042ec4ac35fe90cf4ba))
## [3.100.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.100.1-beta.0...v3.100.2-beta.0) (2026-08-09)
### Bug Fixes
* **branding:** hide "Powered by PicPeak" on every page, not only the gallery ([#999](https://github.com/PicPeak/picpeak/issues/999)) ([3bb4f1a](https://github.com/PicPeak/picpeak/commit/3bb4f1a1a894a6bbc4b1610c3585b73e38f1753d))
## [3.100.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.100.0-beta.0...v3.100.1-beta.0) (2026-08-04)
### Documentation
* the retired registry path freezes, it does not stop serving ([#995](https://github.com/PicPeak/picpeak/issues/995)) ([b9e4259](https://github.com/PicPeak/picpeak/commit/b9e42591f53d3e5dbee136f4ee53020461c3e2ba))
## [3.100.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.99.2-beta.0...v3.100.0-beta.0) (2026-08-04)
### Features
* **admin:** surface the registry move through the update check ([#993](https://github.com/PicPeak/picpeak/issues/993)) ([137a42f](https://github.com/PicPeak/picpeak/commit/137a42f259692999fe88b75bbe6652d34893ef11))
* **gallery:** admin preview skips the password on protected galleries ([#981](https://github.com/PicPeak/picpeak/issues/981)) ([f006615](https://github.com/PicPeak/picpeak/commit/f00661511c3f3b4fc338be860965244b0ee3b611))
### Bug Fixes
* **security:** vet the destination project when linking a deal ([#991](https://github.com/PicPeak/picpeak/issues/991)) ([0c8ad6b](https://github.com/PicPeak/picpeak/commit/0c8ad6bbedb00ba443c20c7ab00b58925d6b9b5c))
## [3.99.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.99.1-beta.0...v3.99.2-beta.0) (2026-08-04)
### Bug Fixes
* **deps:** bump ip-address, brace-expansion and postcss for open CVEs ([#987](https://github.com/PicPeak/picpeak/issues/987)) ([6c03fea](https://github.com/PicPeak/picpeak/commit/6c03feaef5ef9be694445728f5d5a4ddabafd5c1))
## [3.99.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.99.0-beta.0...v3.99.1-beta.0) (2026-08-04)
### Bug Fixes
* **accounting:** gate cross-add counters on the permission their endpoint checks ([#984](https://github.com/PicPeak/picpeak/issues/984)) ([4b53b64](https://github.com/PicPeak/picpeak/commit/4b53b64277a6e3b1d3e94b8cc3bb705aa33629ec))
## [3.99.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.6-beta.0...v3.99.0-beta.0) (2026-08-03)
### Features
* **accounting:** re-bill proof attachment, CRM panel & hours↔re-bills cross-add ([#979](https://github.com/PicPeak/picpeak/issues/979)) ([165cebd](https://github.com/PicPeak/picpeak/commit/165cebdb5c744cb3c3c26cc9cd4182cb5fa85143))
## [3.98.6-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.5-beta.0...v3.98.6-beta.0) (2026-08-03)
### Bug Fixes
* **auth:** fail closed when the adminAuth roles join errors ([#974](https://github.com/PicPeak/picpeak/issues/974)) ([6699855](https://github.com/PicPeak/picpeak/commit/6699855c931657c7af7860e9bdd097da303a3a26))
* **projects:** stop the cockpit offering email controls the API rejects ([#976](https://github.com/PicPeak/picpeak/issues/976)) ([67592fc](https://github.com/PicPeak/picpeak/commit/67592fc56956b1ec4db696483efd2a285bffb6f4))
## [3.98.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.4-beta.0...v3.98.5-beta.0) (2026-08-02)
### Bug Fixes
* **security:** enforce project ownership on project + project-email routes (GHSA-wrg5, GHSA-93x4) ([#960](https://github.com/PicPeak/picpeak/issues/960)) ([7c0c0a5](https://github.com/PicPeak/picpeak/commit/7c0c0a5b7ff5758ec07ac64ab5c0c81818cca96d))
## [3.98.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.3-beta.0...v3.98.4-beta.0) (2026-08-02)
### Bug Fixes
* **security:** backup/restore hardening — public-dir DB dump, restore path allowlist, gunzip bound, manifest keying ([#956](https://github.com/PicPeak/picpeak/issues/956)) ([0d4c308](https://github.com/PicPeak/picpeak/commit/0d4c30884e21a43401f7e8acc0f31e5ab1f77bba))
* **security:** bound inbound-mail resources, redact secrets from logs (GHSA-2qf9, pgmp, r794) ([#959](https://github.com/PicPeak/picpeak/issues/959)) ([1b4e5fe](https://github.com/PicPeak/picpeak/commit/1b4e5fee3efd1a7fb980476d45551971225df50c))
* **security:** enforce event ownership on the v1 API surface (GHSA-9697) ([#957](https://github.com/PicPeak/picpeak/issues/957)) ([e2ce95e](https://github.com/PicPeak/picpeak/commit/e2ce95ee48105f6e04150334df77a866a1c60a83))
* **security:** escape brand tokens, block tracker redirects, trim logo diagnostic (GHSA-j347, mw76, 29vm) ([#961](https://github.com/PicPeak/picpeak/issues/961)) ([164129b](https://github.com/PicPeak/picpeak/commit/164129b8f5bbf8a68d743930a72bdb95b88fdee3))
* **security:** scope dashboard stats/analytics/activity to the caller's events (GHSA-c2jj, gqx7, jhcf) ([#958](https://github.com/PicPeak/picpeak/issues/958)) ([da855cf](https://github.com/PicPeak/picpeak/commit/da855cfef9e74b0b1e77d54c39008c998ab3e20b))
## [3.98.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.2-beta.0...v3.98.3-beta.0) (2026-08-02)
### Bug Fixes
* **security:** authz/ownership gaps (token binding, auth revocation, feedback/customer ownership, token logging) ([#950](https://github.com/PicPeak/picpeak/issues/950)) ([c2ce12c](https://github.com/PicPeak/picpeak/commit/c2ce12c039d5564e4457fdbbd50a06bbcfec4d6a))
* **security:** neutralize spreadsheet formulas in all CSV/export cell-writers (CSV injection cluster) ([#948](https://github.com/PicPeak/picpeak/issues/948)) ([8f91c2c](https://github.com/PicPeak/picpeak/commit/8f91c2ca99de09d64b32a292c5f7fe86e63f9787))
* **security:** redact gallery share tokens from analytics tracking (GHSA-7m6c) ([#952](https://github.com/PicPeak/picpeak/issues/952)) ([1c8f7d5](https://github.com/PicPeak/picpeak/commit/1c8f7d58a88b867c07d8d9699c20866c334dfa73))
* **security:** unauth share_token leak (HIGH) + restore path-traversal, logo file-read, branding path keys ([#946](https://github.com/PicPeak/picpeak/issues/946)) ([9050aff](https://github.com/PicPeak/picpeak/commit/9050affd8dd0d5dff0514a8a7cb677fc2d410fca))
## [3.98.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.1-beta.0...v3.98.2-beta.0) (2026-08-01)
### Bug Fixes
* **security:** block guest access to hidden/client-only photos across bulk + secure routes ([#939](https://github.com/PicPeak/picpeak/issues/939)) ([8a87c92](https://github.com/PicPeak/picpeak/commit/8a87c9274b2950a500ed1d17bc30fa573fcbf0c0))
* **security:** bump sanitize-html to 2.17.5 (CVE-2026-53606) ([#937](https://github.com/PicPeak/picpeak/issues/937)) ([fe615c8](https://github.com/PicPeak/picpeak/commit/fe615c82e48de42399d8be47f796878835b90c5d))
* **security:** close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs) ([#943](https://github.com/PicPeak/picpeak/issues/943)) ([82d6871](https://github.com/PicPeak/picpeak/commit/82d68711cf7b74b9c81a0eed3fd0905d668d5df7))
* **security:** resolve DNS before vetting external hostnames (SSRF cluster) ([#941](https://github.com/PicPeak/picpeak/issues/941)) ([b700569](https://github.com/PicPeak/picpeak/commit/b7005692b33595cf9df52892ce2f94342ca21fe5))
## [3.98.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.98.0-beta.0...v3.98.1-beta.0) (2026-08-01)
### Bug Fixes
* **uploads:** prevent cross-photo contamination from filename collisions and non-atomic writes ([#931](https://github.com/PicPeak/picpeak/issues/931)) ([#933](https://github.com/PicPeak/picpeak/issues/933)) ([defeae9](https://github.com/PicPeak/picpeak/commit/defeae96349e4b68a2db6d66ad68b255e98f9e3b))
## [3.98.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.6-beta.0...v3.98.0-beta.0) (2026-07-31)
### Features
* **gallery:** mouse-wheel zoom at cursor in the lightbox ([#885](https://github.com/PicPeak/picpeak/issues/885)) ([#927](https://github.com/PicPeak/picpeak/issues/927)) ([926a4a5](https://github.com/PicPeak/picpeak/commit/926a4a540d6f6a1e134ca4e611f850edf6338378))
* **gallery:** multi-select feedback filters + sort direction controls ([#889](https://github.com/PicPeak/picpeak/issues/889)) ([#929](https://github.com/PicPeak/picpeak/issues/929)) ([3bcded7](https://github.com/PicPeak/picpeak/commit/3bcded78a448f5b099e87a73c7f1e44e859882aa))
* **gallery:** per-event toggle to hide the logo on the password page ([#894](https://github.com/PicPeak/picpeak/issues/894)) ([#928](https://github.com/PicPeak/picpeak/issues/928)) ([08ff9f2](https://github.com/PicPeak/picpeak/commit/08ff9f20e73a12bc89fad539781c4f48972f48e1))
## [3.97.6-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.5-beta.0...v3.97.6-beta.0) (2026-07-30)
### Bug Fixes
* **security:** close GHSA-g94x (cross-gallery photo read) + GHSA-pv6w (admin DB export) ([#924](https://github.com/PicPeak/picpeak/issues/924)) ([03087c7](https://github.com/PicPeak/picpeak/commit/03087c798c8414505fcd694df7cd53bc08126b32))
## [3.97.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.4-beta.0...v3.97.5-beta.0) (2026-07-30)
### Bug Fixes
* **admin:** code-review follow-ups on [#910](https://github.com/PicPeak/picpeak/issues/910)/[#916](https://github.com/PicPeak/picpeak/issues/916) (MIME resolver + expiry reactivity) ([#921](https://github.com/PicPeak/picpeak/issues/921)) ([252475f](https://github.com/PicPeak/picpeak/commit/252475fce2ce8d5e16915c4d3558576ad720189b))
## [3.97.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.3-beta.0...v3.97.4-beta.0) (2026-07-29)
### Bug Fixes
* **admin:** expose view/download counters in the admin photos list ([#895](https://github.com/PicPeak/picpeak/issues/895) follow-up) ([#914](https://github.com/PicPeak/picpeak/issues/914)) ([aca3c8e](https://github.com/PicPeak/picpeak/commit/aca3c8e4bc33e74c81c4d2f2a15baf490c967134))
* **admin:** stop marking events expired up to 24h early ([#909](https://github.com/PicPeak/picpeak/issues/909)) ([#916](https://github.com/PicPeak/picpeak/issues/916)) ([487f55f](https://github.com/PicPeak/picpeak/commit/487f55f2d9463d85898555472cd66ae69d1d0f31))
## [3.97.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.2-beta.0...v3.97.3-beta.0) (2026-07-29)
### Bug Fixes
* **admin:** serve videos with their real MIME type in the admin photo view ([#908](https://github.com/PicPeak/picpeak/issues/908)) ([#910](https://github.com/PicPeak/picpeak/issues/910)) ([67c56c5](https://github.com/PicPeak/picpeak/commit/67c56c5b61fc9a25f5d0b7346fb042211bc1d1de))
## [3.97.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.1-beta.0...v3.97.2-beta.0) (2026-07-29)
### Bug Fixes
* **analytics:** make per-photo view/download counters actually count ([#895](https://github.com/PicPeak/picpeak/issues/895)) ([#904](https://github.com/PicPeak/picpeak/issues/904)) ([78116e2](https://github.com/PicPeak/picpeak/commit/78116e2e8bf681c5483f06e9b1490dc8239e8576))
## [3.97.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.97.0-beta.0...v3.97.1-beta.0) (2026-07-29)
### Bug Fixes
* **tests:** raise migration-boot hook timeout pins to the 120s default ([#900](https://github.com/PicPeak/picpeak/issues/900)) ([d9ad982](https://github.com/PicPeak/picpeak/commit/d9ad982373861cd05f23167f1e2e50eaebfb7bba))
## [3.97.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.96.1-beta.0...v3.97.0-beta.0) (2026-07-29)
### Features
* **feedback:** let guests remove their star rating ([#884](https://github.com/PicPeak/picpeak/issues/884)) ([#893](https://github.com/PicPeak/picpeak/issues/893)) ([6a048d0](https://github.com/PicPeak/picpeak/commit/6a048d08bd5d1d16f5ec2d2e580831086a32c71b))
## [3.96.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.96.0-beta.0...v3.96.1-beta.0) (2026-07-29)
### Bug Fixes
* **gallery:** keep the lightbox toolbar from masking the photo ([#888](https://github.com/PicPeak/picpeak/issues/888)) ([#892](https://github.com/PicPeak/picpeak/issues/892)) ([ec66cd2](https://github.com/PicPeak/picpeak/commit/ec66cd2684b5ee608f23304ec0da029f38a3eed4))
## [3.96.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.95.5-beta.0...v3.96.0-beta.0) (2026-07-29)
### Features
* **gallery:** quick return from zoomed to fit-to-screen in the lightbox ([#886](https://github.com/PicPeak/picpeak/issues/886)) ([#891](https://github.com/PicPeak/picpeak/issues/891)) ([97f6889](https://github.com/PicPeak/picpeak/commit/97f68899a221e3bc9b4e30c7d9a19eb16f062ba6))
## [3.95.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.95.4-beta.0...v3.95.5-beta.0) (2026-07-29)
### Bug Fixes
* **gallery:** don't close the lightbox when clicking beside the photo ([#883](https://github.com/PicPeak/picpeak/issues/883)) ([#890](https://github.com/PicPeak/picpeak/issues/890)) ([34c2992](https://github.com/PicPeak/picpeak/commit/34c2992521fcb4a495398f27f6044d696b4d17c3))
## [3.95.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.95.3-beta.0...v3.95.4-beta.0) (2026-07-27)
### Bug Fixes
* sync gallery feedback filters after lightbox like/rating in simple mode ([#882](https://github.com/PicPeak/picpeak/issues/882)) ([33f1bc4](https://github.com/PicPeak/picpeak/commit/33f1bc42a9441cba4c4cef81217a3073bbfd4e8b))
## [3.95.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.95.2-beta.0...v3.95.3-beta.0) (2026-07-27)
### Bug Fixes
* **security:** close 5 Trivy alerts — postcss/tar bumps + drop npm from the runtime image ([#878](https://github.com/PicPeak/picpeak/issues/878)) ([08be2b8](https://github.com/PicPeak/picpeak/commit/08be2b84f18073b63fa131c692c50b6df849a0ca))
## [3.95.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.95.1-beta.0...v3.95.2-beta.0) (2026-07-27)
### Bug Fixes
* **backup:** make backup settings actually apply ([#871](https://github.com/PicPeak/picpeak/issues/871)) ([#874](https://github.com/PicPeak/picpeak/issues/874)) ([a2e7234](https://github.com/PicPeak/picpeak/commit/a2e723413e64819f0d8c0c03636ed04af42a47e4))
## [3.95.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.95.0-beta.0...v3.95.1-beta.0) (2026-07-26)
### Bug Fixes
* **security:** bump backend deps to close all 14 open Trivy code-scanning alerts ([#869](https://github.com/PicPeak/picpeak/issues/869)) ([38b8d47](https://github.com/PicPeak/picpeak/commit/38b8d476d17d5a28724dbd81c79d57e23d65a2fa))
## [3.95.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.94.2-beta.0...v3.95.0-beta.0) (2026-07-24)
### Features
* **auth:** OIDC logout-to-IdP — phase 3 ([#798](https://github.com/PicPeak/picpeak/issues/798)) ([#865](https://github.com/PicPeak/picpeak/issues/865)) ([219d07b](https://github.com/PicPeak/picpeak/commit/219d07b04adf54756317d3cc3069f834aa2b460e))
## [3.94.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.94.1-beta.0...v3.94.2-beta.0) (2026-07-23)
### Bug Fixes
* **gallery:** block password form in Instagram in-app browser and unmask login errors ([#863](https://github.com/PicPeak/picpeak/issues/863)) ([323dcae](https://github.com/PicPeak/picpeak/commit/323dcae91702b8a77d2db801b63398a76f16fee2))
## [3.94.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.94.0-beta.0...v3.94.1-beta.0) (2026-07-22)
### Bug Fixes
* **tests:** raise jest timeouts to survive the growing migration chain ([#860](https://github.com/PicPeak/picpeak/issues/860)) ([40eb03f](https://github.com/PicPeak/picpeak/commit/40eb03f0d80458f6c7dc4f6e6430668451edadac))
## [3.94.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.93.0-beta.0...v3.94.0-beta.0) (2026-07-22)
### Features
* **auth:** OIDC role mapping + login policy — phase 2 ([#798](https://github.com/PicPeak/picpeak/issues/798)) ([#854](https://github.com/PicPeak/picpeak/issues/854)) ([f8a95d2](https://github.com/PicPeak/picpeak/commit/f8a95d29d2feb5f651ff6a0bcfa1b5b1540f114a))
* **feedback:** emoji reactions on photos ([#839](https://github.com/PicPeak/picpeak/issues/839)) ([#855](https://github.com/PicPeak/picpeak/issues/855)) ([3d6c984](https://github.com/PicPeak/picpeak/commit/3d6c9848dcbace1d1ce74890460e369854be65c7))
* **gallery:** reveal mode — hide gallery from guests until reveal ([#838](https://github.com/PicPeak/picpeak/issues/838)) ([#856](https://github.com/PicPeak/picpeak/issues/856)) ([2f05fcc](https://github.com/PicPeak/picpeak/commit/2f05fcc39deaf226a6cc8796ebee9b40bc89e9ae))
### Bug Fixes
* **dates:** normalize SQLite epoch timestamps at remaining API surfaces ([#485](https://github.com/PicPeak/picpeak/issues/485) follow-up) ([#857](https://github.com/PicPeak/picpeak/issues/857)) ([c6ec93e](https://github.com/PicPeak/picpeak/commit/c6ec93eef9f18e8867e86691800a60379bb16591))
## [3.93.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.92.2-beta.0...v3.93.0-beta.0) (2026-07-19)
### Features
* **events:** gallery QR code + printable table-card/poster PDFs ([#847](https://github.com/PicPeak/picpeak/issues/847)) ([60cdd07](https://github.com/PicPeak/picpeak/commit/60cdd07085c750cee358cbe59420668cbc538473))
* **notifications:** surface guest activity in the admin bell ([#849](https://github.com/PicPeak/picpeak/issues/849)) ([cb5b319](https://github.com/PicPeak/picpeak/commit/cb5b319f1022655fbc1e442d0d1e6d8337f0e637))
* **slideshow:** guest-scannable share-link QR overlay ([#848](https://github.com/PicPeak/picpeak/issues/848)) ([e8dad4b](https://github.com/PicPeak/picpeak/commit/e8dad4b40ddb816cc2f9a94be456f793adce20d7))
### Bug Fixes
* **crm:** pass trx to logActivity inside transactions — audit rows silently lost on SQLite ([#851](https://github.com/PicPeak/picpeak/issues/851)) ([a6a3c9f](https://github.com/PicPeak/picpeak/commit/a6a3c9f9f8ecb84500d5ac68e90639c362f2461a))
## [3.92.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.92.1-beta.0...v3.92.2-beta.0) (2026-07-19)
### Bug Fixes
* **file-watcher:** bound concurrent photo processing ([#846](https://github.com/PicPeak/picpeak/issues/846)) ([8337a71](https://github.com/PicPeak/picpeak/commit/8337a716b169e66f8edf8619c64622e6853dae81))
* **security:** read the password-complexity key the settings UI writes ([#843](https://github.com/PicPeak/picpeak/issues/843)) ([8060fed](https://github.com/PicPeak/picpeak/commit/8060fedf6aaea5359c3bf04696fd00ec8500b51a))
* **uploads:** keep videos when thumbnail generation fails ([#845](https://github.com/PicPeak/picpeak/issues/845)) ([0310c46](https://github.com/PicPeak/picpeak/commit/0310c46fdd5b03274f761abfb4c8b552e2f8b666))
## [3.92.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.92.0-beta.0...v3.92.1-beta.0) (2026-07-19)
### Bug Fixes
* **uploads:** support configured raw formats ([f7fd893](https://github.com/PicPeak/picpeak/commit/f7fd89387be80ea9b3b5c11d06828a4c1a0d4af5))
## [3.92.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.91.0-beta.0...v3.92.0-beta.0) (2026-07-18)
### Features
* **uploads:** DNG / camera-RAW support via embedded-preview extraction ([#821](https://github.com/PicPeak/picpeak/issues/821)) ([8c260c4](https://github.com/PicPeak/picpeak/commit/8c260c4eebedb69f349505d0befbbb5afb182b2c))
## [3.91.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.2-beta.0...v3.91.0-beta.0) (2026-07-18)
### Features
* **uploads:** HEIC/HEIF support + dynamic format hint on guest upload ([#821](https://github.com/PicPeak/picpeak/issues/821)) ([ee9d2f7](https://github.com/PicPeak/picpeak/commit/ee9d2f70d3342d65edb795a688f0f5f611429964))
### Bug Fixes
* **gallery:** serve JPEG preview for non-displayable originals in lightbox (codex review of [#832](https://github.com/PicPeak/picpeak/issues/832)) ([808d305](https://github.com/PicPeak/picpeak/commit/808d3055497bb4e4a372acafa49ef9baf257f008))
* **uploads:** register HEIC/HEIF with the file validator + fix admin format hint (codex review of [#832](https://github.com/PicPeak/picpeak/issues/832)) ([c9b64d9](https://github.com/PicPeak/picpeak/commit/c9b64d9c1a8744c9ee5e068366a500ae0dab36bc))
## [3.90.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.1-beta.0...v3.90.2-beta.0) (2026-07-17)
### Bug Fixes
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([0245e44](https://github.com/PicPeak/picpeak/commit/0245e445cafd165ada3c5a15abb258ae2c1c857e))
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([b97b130](https://github.com/PicPeak/picpeak/commit/b97b130cadebaef38e59cc227fa6578ac886110f))
* **update:** target docker-compose.production.yml in dashboard update steps ([51a505e](https://github.com/PicPeak/picpeak/commit/51a505e3798895e544f943673e81a365265f319c))
* **update:** target docker-compose.production.yml in dashboard update steps + gate mailhog ([2a0361a](https://github.com/PicPeak/picpeak/commit/2a0361a83b4ca0a600bb4fd447e338533ce63420))
* **uploads:** apply configured max file size to guest uploads ([#613](https://github.com/PicPeak/picpeak/issues/613) follow-up) ([29f1d23](https://github.com/PicPeak/picpeak/commit/29f1d23a0a645208f22453e62d99fe79b55c7db4))
* **uploads:** apply configured max file size to guest uploads ([#613](https://github.com/PicPeak/picpeak/issues/613) follow-up) ([1e38d84](https://github.com/PicPeak/picpeak/commit/1e38d84808ee2a2b176c75d5ec4975fba710e63c))
* **uploads:** tighten guest max-file-size setting (codex review of [#823](https://github.com/PicPeak/picpeak/issues/823)) ([43c6d22](https://github.com/PicPeak/picpeak/commit/43c6d22bdd93179865703da6350094c9b95388d8))
* **uploads:** tighten guest max-file-size setting (codex review of [#823](https://github.com/PicPeak/picpeak/issues/823)) ([e03d13e](https://github.com/PicPeak/picpeak/commit/e03d13efde843c7a7275cd41c855b402538756e7))
## [3.90.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.0-beta.0...v3.90.1-beta.0) (2026-07-17)
### Bug Fixes
* **security:** remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq) ([e7ca8bd](https://github.com/PicPeak/picpeak/commit/e7ca8bdb7f30d999039125c0f0ef89bdc92d5a69))
* **security:** remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq) ([6cd546e](https://github.com/PicPeak/picpeak/commit/6cd546e86ae38819c0fdc24044f86106503fa020))
## [3.90.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.89.0-beta.0...v3.90.0-beta.0) (2026-07-16)
### Features
* **auth:** OIDC SSO for admin users — phase 1 ([f12606b](https://github.com/PicPeak/picpeak/commit/f12606b4e0d2fbe4f2f57a345b393448063d6614))
## [3.89.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.88.1-beta.0...v3.89.0-beta.0) (2026-07-16)
### Features
* **security:** harden .picpeak restore robustness — sessions, roles, sequences ([a77c2c2](https://github.com/PicPeak/picpeak/commit/a77c2c2c573a79f0194ff2b911acaa5f46c11f26))
* **security:** harden .picpeak restore robustness — sessions, roles, sequences ([340d91b](https://github.com/PicPeak/picpeak/commit/340d91bdd53a595694edfa6f3d691b240a2babcd))
### Bug Fixes
* **security:** close 4 open security advisories (backup takeover, share-login bypass, ZIP slip, chunked-upload traversal) ([7ebc232](https://github.com/PicPeak/picpeak/commit/7ebc2326204ad0572e6a1fc121b5d232da06cec3))
* **security:** harden .picpeak restore operator-preservation (GHSA-qxfx follow-up) ([38fd41a](https://github.com/PicPeak/picpeak/commit/38fd41aad3fcb12a249aaa2eb3d98fbffbde537a))
* **security:** preserve current admin on .picpeak restore (GHSA-qxfx-4493-4v8f) ([348894e](https://github.com/PicPeak/picpeak/commit/348894efefa5a7b49d32feb22a98045b93076138))
* **security:** reject ZIP-slip entries in archive/backup restore (GHSA-jfhw-fj23-fx6x) ([9cd6b08](https://github.com/PicPeak/picpeak/commit/9cd6b08441e8633751b9fb73daca5ca0555c950b))
* **security:** sanitize chunked-upload filename (GHSA-pc72-jf53-w28j) ([31bc01c](https://github.com/PicPeak/picpeak/commit/31bc01cb4bbf65b48b3a5c3c94ad35e487df9fcc))
* **security:** share-login must not bypass gallery password (GHSA-9hmx-68vc-qpqw) ([7dace04](https://github.com/PicPeak/picpeak/commit/7dace044dcc1c3b5a13c4704510c87616632618c))
## [3.88.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.88.0-beta.0...v3.88.1-beta.0) (2026-07-16)
### Bug Fixes
* **security:** mask backup credentials on read + unblock MFA login during maintenance ([eadf282](https://github.com/PicPeak/picpeak/commit/eadf282755829cb51e6ea37221be31d8c9af41c5))
* **security:** mask backup credentials on read + unblock MFA login during maintenance ([07f2c90](https://github.com/PicPeak/picpeak/commit/07f2c900556738e993fb63764210b541d7692c9d))
## [3.88.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.87.0-beta.0...v3.88.0-beta.0) (2026-07-15)
### Features
* **setup:** event-types step in first-run wizard + un-hardcode event type dependencies ([109aba8](https://github.com/PicPeak/picpeak/commit/109aba859820bf80440d056baf183ecf2657fee3))
* **setup:** event-types step in first-run wizard + un-hardcode event type deps ([#800](https://github.com/PicPeak/picpeak/issues/800)) ([7eb6357](https://github.com/PicPeak/picpeak/commit/7eb6357b4a9bf3914674a63afa386a5fcf8c2161))
### Bug Fixes
* **event-types:** harden setup window + catalog validation (codex review) ([f8ba669](https://github.com/PicPeak/picpeak/commit/f8ba6697163b4d9aa0fa0014cb5b0810371c04ae))
* **event-types:** un-hardcode event type dependencies in v1 API and CRM ([d64eef8](https://github.com/PicPeak/picpeak/commit/d64eef8abf2915230b3cdd38a3bbb8af1a12c6d2))
* **event-types:** un-hardcode event type dependencies in v1 API and CRM ([#800](https://github.com/PicPeak/picpeak/issues/800)) ([5da1c3a](https://github.com/PicPeak/picpeak/commit/5da1c3a12f603a230091426b1d7be0eac83da22c))
* **gallery:** show feedback filter chips on desktop for galleries without categories ([0751a08](https://github.com/PicPeak/picpeak/commit/0751a08aa661a430c1609cd8c118347291cbaa14))
* **gallery:** show feedback filter chips on desktop for galleries without categories ([#802](https://github.com/PicPeak/picpeak/issues/802)) ([b928338](https://github.com/PicPeak/picpeak/commit/b9283386a57431ac8bd395347f9acb9bbdf82e8e))
## [3.87.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.86.0-beta.0...v3.87.0-beta.0) (2026-07-11)
### Features
* **invoices:** configurable VAT note under MwSt. line + fix multi-page page-number overlap ([#794](https://github.com/PicPeak/picpeak/issues/794)) ([ffd4a7e](https://github.com/PicPeak/picpeak/commit/ffd4a7eee64b6418df1c9cc6843d86dc0f41d2ec))
* **invoices:** configurable VAT/free-text note + fix multi-page page-number overlap ([#794](https://github.com/PicPeak/picpeak/issues/794)) ([1476884](https://github.com/PicPeak/picpeak/commit/1476884dd04202f5f18d50d458b6176b0535c71b))
## [3.86.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.85.0-beta.0...v3.86.0-beta.0) (2026-07-10)
### Features
* **categories:** per-event category ordering — global default + override ([#782](https://github.com/PicPeak/picpeak/issues/782)) ([d51112e](https://github.com/PicPeak/picpeak/commit/d51112e761d2fd83f1939841fbf4c05e625fc34d))
* **categories:** per-event category ordering — global default + override ([#782](https://github.com/PicPeak/picpeak/issues/782)) ([4698402](https://github.com/PicPeak/picpeak/commit/4698402b5493cfbdb1e2b6d81c6f58829e17a703))
### Bug Fixes
* **categories:** address PR [#790](https://github.com/PicPeak/picpeak/issues/790) review — event ownership, migration renumber, nits ([a4b4485](https://github.com/PicPeak/picpeak/commit/a4b4485d322514690c5400ca7ab9a91bc25c3e48))
## [3.85.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.84.1-beta.0...v3.85.0-beta.0) (2026-07-10)
### Features
* **slideshow:** per-event play order + category filter ([#202](https://github.com/PicPeak/picpeak/issues/202)) ([5467642](https://github.com/PicPeak/picpeak/commit/54676424f2f7ed50e74cb8e144cbdaa5a96e65c3))
## [3.84.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.84.0-beta.0...v3.84.1-beta.0) (2026-07-10)
### Bug Fixes
* **ci:** publish v-prefixed image tags via type=ref,event=tag ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([1f3bc3c](https://github.com/PicPeak/picpeak/commit/1f3bc3c3430414b5b6cb2141d887a8b5855a04af))
* **ci:** publish v-prefixed image tags via type=ref,event=tag ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([39db7bf](https://github.com/PicPeak/picpeak/commit/39db7bf6cb5c39fcdf71c875a4aaf704f34447fa))
## [3.84.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.83.1-beta.0...v3.84.0-beta.0) (2026-07-10)
### Features
* **admin:** GitHub repo button in the sidebar footer ([#778](https://github.com/PicPeak/picpeak/issues/778)) ([279e047](https://github.com/PicPeak/picpeak/commit/279e0472c71c6a37ba091a9c7a31f5571c0a8df6))
* **admin:** GitHub repo button in the sidebar footer ([#778](https://github.com/PicPeak/picpeak/issues/778)) ([d3d7df4](https://github.com/PicPeak/picpeak/commit/d3d7df46f214028ba89063079d356bc0430083f5))
### Bug Fixes
* **ci:** publish v-prefixed image tags so :vX.Y.Z resolves ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([2ee4146](https://github.com/PicPeak/picpeak/commit/2ee4146d9a6fd026e7b7be3ba774de9a0cf6e96a))
* **ci:** publish v-prefixed image tags so :vX.Y.Z resolves ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([784d059](https://github.com/PicPeak/picpeak/commit/784d059c3da5b36e2b6794ebf2e34bc15c8a9824))
### Documentation
* **releasing:** align stable version to main on promote (Option A) ([df5aeab](https://github.com/PicPeak/picpeak/commit/df5aeaba416726cc0123f32ddf88e4a30dc28908))
* **releasing:** align stable version to main on promote (Option A) ([5dea0c9](https://github.com/PicPeak/picpeak/commit/5dea0c969558f50833973ff742257780f5842612))
## [3.83.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.83.0-beta.0...v3.83.1-beta.0) (2026-07-09)
### Bug Fixes
* **release:** target stable in release-please + undo bogus 2.7.0 bump ([274ef0c](https://github.com/PicPeak/picpeak/commit/274ef0cd731765b057a5d62d5f41c14cb3a1564b))
* **release:** target stable in release-please.yml + undo the bogus 2.7.0 bump ([65ac6ed](https://github.com/PicPeak/picpeak/commit/65ac6eddacb79857e9a9651d3c869e7bfdd92887))
## [3.83.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.6-beta.0...v3.83.0-beta.0) (2026-07-08)
### Features
* **messages:** create/select quote, contract, invoice, gallery from a message ([0dbf863](https://github.com/PicPeak/picpeak/commit/0dbf863f60b919560b766f78b107ebac9612bd9d))
* **messages:** search bar + Archive/Delete with Archived & Deleted folders ([99d5996](https://github.com/PicPeak/picpeak/commit/99d5996561a2dcff2d431692d5bab5c7286d1f6f))
* **messages:** unified Messages email client (flag-gated, default off) ([a71b9b5](https://github.com/PicPeak/picpeak/commit/a71b9b5ed721df17b61062ae3a2361d448c95cf7))
### Bug Fixes
* **messages:** PR [#769](https://github.com/PicPeak/picpeak/issues/769) nits — server-side search, bare-email recipient, DE i18n ([1e08a4f](https://github.com/PicPeak/picpeak/commit/1e08a4fb156d34ee8ddff69b0a7612001aa6d67e))
* **messages:** PR [#769](https://github.com/PicPeak/picpeak/issues/769) review — escape reply sender (XSS), gate backend routes, exact customer match ([bb235e7](https://github.com/PicPeak/picpeak/commit/bb235e72e58359f55f8aeccf2f22c671584fdbd7))
* **messages:** show the resolved customer's name in the doc-action modal ([2c5c1d5](https://github.com/PicPeak/picpeak/commit/2c5c1d561bbe567b9d7615e7c2d071d08bb6d63c))
## [3.82.6-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.5-beta.0...v3.82.6-beta.0) (2026-07-07)
### Bug Fixes
* **workflows:** backfill existing invoices + anchor dunning grace to due date when enabled ([#750](https://github.com/PicPeak/picpeak/issues/750)) ([9596342](https://github.com/PicPeak/picpeak/commit/9596342d6a9ef107193cfc123487a8061f4a91ca))
* **workflows:** scope dunning backfill to its own flow via targetWorkflowId ([da3a77d](https://github.com/PicPeak/picpeak/commit/da3a77dac40a892158167aec939a1458d488a951))
## [3.82.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.4-beta.0...v3.82.5-beta.0) (2026-07-07)
### Bug Fixes
* **admin:** stop the event-date field crashing the page on backspace ([760a201](https://github.com/PicPeak/picpeak/commit/760a201b6070a4edfe8192bcddce948c5f0c3fec))
## [3.82.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.3-beta.0...v3.82.4-beta.0) (2026-07-07)
### Bug Fixes
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([0c2d319](https://github.com/PicPeak/picpeak/commit/0c2d319fc1ed67843cc60afdcaea5807ea49226f))
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([fcc3e91](https://github.com/PicPeak/picpeak/commit/fcc3e9195d6f63b2dffddfa72a867a3e32325e81))
* **email:** sibling billing emails follow customer language too ([c0008be](https://github.com/PicPeak/picpeak/commit/c0008be39bc8a9d354e48ce8d6bd89662bc53ebb))
## [3.82.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.2-beta.0...v3.82.3-beta.0) (2026-07-06)
### Bug Fixes
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([a88da99](https://github.com/PicPeak/picpeak/commit/a88da99c8d35c0c7cb7f96a235e984edad74ac7c))
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([96fe478](https://github.com/PicPeak/picpeak/commit/96fe478bf87a3350185206b3d6f15133138b995d))
* **branding:** unify hero logo SIZE the same way as visibility ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([60b03b1](https://github.com/PicPeak/picpeak/commit/60b03b17287539b3ad5e5d32f4eda8622f0575e4))
## [3.82.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.1-beta.0...v3.82.2-beta.0) (2026-07-05)
### Bug Fixes
* **og:** broaden social-crawler coverage (Bluesky Cardyb, WeChat-scraper, fediverse, etc.) ([a0a28a4](https://github.com/PicPeak/picpeak/commit/a0a28a47777db9ca9e60a5134c8d86503c060e79))
* **og:** route branded short URLs + slideshow links to OG, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([0dffe0c](https://github.com/PicPeak/picpeak/commit/0dffe0ce92339e0608b3ef660e84c31a62f4a98c))
* **og:** route branded short URLs + slideshow to OG handler, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([a87ad77](https://github.com/PicPeak/picpeak/commit/a87ad77d8d5215c88f5d95cc7aebaa1769938ec0))
## [3.82.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.0-beta.0...v3.82.1-beta.0) (2026-07-05)
### Bug Fixes
* **invoices:** correct payment-check email template key so dunning email sends ([9a76333](https://github.com/PicPeak/picpeak/commit/9a763337b658299aae0d7c985071c4a775000f99))
* **invoices:** correct payment-check email template key so dunning email sends ([3682de1](https://github.com/PicPeak/picpeak/commit/3682de195b46eae692db3ff4a1476b00d3a6e216))
## [3.82.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.81.0-beta.0...v3.82.0-beta.0) (2026-07-03)
### Features
* **setup:** final community step ([#732](https://github.com/PicPeak/picpeak/issues/732)) + fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([a5f49e3](https://github.com/PicPeak/picpeak/commit/a5f49e32350564ee4d3894f33e9611e9244cc994))
* **setup:** final community/thank-you step ([#732](https://github.com/PicPeak/picpeak/issues/732)); fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([dadaaee](https://github.com/PicPeak/picpeak/commit/dadaaeea7781cb62811256b512003e5c4d6ad95e))
## [3.81.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.80.0-beta.0...v3.81.0-beta.0) (2026-07-03)
### Features
* admin two-factor authentication (TOTP) with recovery codes + CLI reset ([cf07361](https://github.com/PicPeak/picpeak/commit/cf073615effa8a91e19374ad3e9924e6e7322950))
* **admin-ui:** TOTP MFA enrollment + two-step login; remove stub 2FA toggle ([96e3c68](https://github.com/PicPeak/picpeak/commit/96e3c68b9d6b35a82abcad664a6da7b19150b4fd))
* **auth:** admin TOTP MFA — enrollment, login challenge, recovery, CLI reset ([72e2ef6](https://github.com/PicPeak/picpeak/commit/72e2ef6721b0572ed34455de901aa357eacd8c76))
### Bug Fixes
* event creation 500s on PostgreSQL (NaN slideshow seed) + stray "0" boolean renders ([b187f58](https://github.com/PicPeak/picpeak/commit/b187f588b4d12af7a7849f8558c0085573d4af76))
* **security:** close cross-event thumbnail leak, bulk-op ownership bypass, + hardening ([081f3ed](https://github.com/PicPeak/picpeak/commit/081f3edcdffc65a77000cc638e364ea9dc03767f))
* **security:** cross-event thumbnail leak, bulk-op ownership bypass + auth hardening ([b732974](https://github.com/PicPeak/picpeak/commit/b732974779803b67097c81ae6bce2de0f2910794))
## [3.80.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.1-beta.0...v3.80.0-beta.0) (2026-07-03)
### Features
* **backup:** fold .picpeak restore into the Restore wizard's Upload source ([86324e7](https://github.com/PicPeak/picpeak/commit/86324e7da75069e61686b1b77495f02c33b12e1a))
* first-run setup wizard (feature selection + config) and portable .picpeak backup roundtrip ([e513e83](https://github.com/PicPeak/picpeak/commit/e513e8345b73e37ebedc9c9ec09665ffc5773e23))
* **setup:** add restore-from-backup branch to the first-run wizard ([a95ee47](https://github.com/PicPeak/picpeak/commit/a95ee473ae7fb2bf3c94c610724476701145d113))
* **setup:** per-feature config step after feature selection ([07b450a](https://github.com/PicPeak/picpeak/commit/07b450a954a53781d23a71749552e4101c637777))
### Bug Fixes
* **backup:** address .picpeak review — table filter, superuser guard, tests ([fa7665c](https://github.com/PicPeak/picpeak/commit/fa7665c5b1ad18a4db4f0b59eb4c197a3c9a36e2))
* **setup:** keep the first-run wizard light regardless of dark mode ([d4b143f](https://github.com/PicPeak/picpeak/commit/d4b143f313d00f2a30abcbc1a880c140d7455e7b))
## [3.79.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.0-beta.0...v3.79.1-beta.0) (2026-07-02)
+20 -11
View File
@@ -73,23 +73,32 @@ cd picpeak
# Install dependencies
cd backend && npm install
cd ../frontend && npm install
cd ..
# Set up environment
cp .env.example .env
# Edit .env with your settings
# Start Postgres and Redis (the app itself runs on the host, see below)
docker compose up -d postgres redis
# Start development servers
docker-compose -f docker-compose.dev.yml up
# Backend config — note this is backend/.env, not the root one
cp backend/.env.example backend/.env
# JWT_SECRET must be set: the host process validates it and exits without one.
# (The containers generate it themselves; `npm run dev` does not.)
# Backend, with nodemon hot reload — http://localhost:3001
cd backend && npm run dev
# Frontend, with Vite hot reload, in a second shell — http://localhost:5173
cd frontend && npm run dev
```
**After pulling changes that touch `backend/package.json` / `backend/package-lock.json` (or the frontend equivalents)**, rebuild the affected image so the live-mounted source can `require()` the new deps:
Open **http://localhost:5173**. Vite proxies `/api` to the backend on `3001`, so
you do not need the root `.env` for this loop at all — that one configures the
compose stack.
```bash
docker compose -f docker-compose.dev.yml up -d --build backend
# (or `frontend`, or both)
```
Running the two Node processes on the host is the fastest loop: both reload on save, and you get a real debugger and stack traces without rebuilding an image.
The dev compose bakes `node_modules` into the image while live-mounting `./backend/src` and `./frontend/src` from disk. A dep added on disk won't be picked up until the image is rebuilt — typical symptom is a `MODULE_NOT_FOUND` restart loop on the affected container.
**Prefer everything in containers?** `docker compose up -d` builds `backend`, `frontend` and `ml` from source using the production Dockerfiles. That works, but there is no hot reload — you rebuild on every change (`docker compose up -d --build backend`).
> `docker-compose.dev.yml` is listed in `.gitignore` and is not part of the repo. If you keep a local one for live-mounting `./backend/src` and `./frontend/src` against `backend/Dockerfile.dev` / `frontend/Dockerfile.dev`, remember it bakes `node_modules` into the image: after pulling a change to `backend/package.json`, rebuild that image or you will get a `MODULE_NOT_FOUND` restart loop.
### Running Tests
+178
View File
@@ -0,0 +1,178 @@
# All-in-one image (#1042): one container, one Node process.
#
# The backend serves the built frontend itself via server.js's SERVE_FRONTEND
# block (SPA fallback, OG crawler intercept, brand-title render, immutable
# asset caching) — no nginx, no supervisor, no bundled Postgres/Redis. SQLite
# is the explicit default engine; pointing DB_HOST/DB_USER/DB_PASSWORD (+
# DATABASE_CLIENT=pg) at an external Postgres works exactly like the backend
# image. Build context is the REPO ROOT (both backend/ and frontend/ are
# needed): docker build -f Dockerfile.aio .
#
# KEEP IN SYNC: the runtime stage below mirrors backend/Dockerfile's
# production stage (base image, apk set, npm removal, nodejs user, fontconfig
# registration, directory layout, healthcheck, entrypoint). When
# backend/Dockerfile changes, change this file too — the aio smoke job in
# docker-build.yml catches boot-level drift, not package-level drift.
# ---------------------------------------------------------------------------
# Frontend build — mirrors frontend/Dockerfile's builder stage
# ---------------------------------------------------------------------------
FROM node:22-alpine AS frontend-builder
ARG CACHEBUST=1
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci --legacy-peer-deps
COPY frontend/ .
RUN npm run build
# ---------------------------------------------------------------------------
# Backend deps — mirrors backend/Dockerfile's builder stage
# ---------------------------------------------------------------------------
FROM node:22-alpine AS backend-builder
ARG CACHEBUST=1
WORKDIR /app
COPY backend/package*.json ./
RUN npm ci --omit=dev
# ---------------------------------------------------------------------------
# Runtime — mirrors backend/Dockerfile's production stage + the frontend dist
# ---------------------------------------------------------------------------
FROM node:22-alpine
ARG CACHEBUST=1
ARG BUILD_DATE
ARG VCS_REF
ARG VERSION
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
LABEL org.opencontainers.image.description="PicPeak all-in-one (backend + frontend, single container)"
LABEL org.opencontainers.image.licenses="MIT"
WORKDIR /app
# Explicit engine selection (#1038/#1042): SQLite is this image's DEFAULT
# engine — set explicitly, never inferred, and wait-for-db.sh skips its
# Postgres readiness wait for it. Point the container at an external Postgres
# by overriding DATABASE_CLIENT=pg and setting DB_HOST/DB_USER/DB_PASSWORD,
# exactly like the backend image. The boot resolver still logs the engine and
# refuses the populated-both conflict.
# STORAGE_PATH: getStoragePath() falls back to path.join(__dirname,
# '../../../storage') — which resolves to the container-root `/storage` here,
# writable by root but EACCES for the nodejs user after the su-exec drop.
# Compose masks this by setting STORAGE_PATH=/app/storage; this image must
# pin the same path (it is the directory the Dockerfile creates and chowns).
ENV NODE_ENV=production \
DATABASE_CLIENT=sqlite3
# See backend/Dockerfile for the rationale of each of the following blocks.
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# sqlite — DatabaseBackupService.createSQLiteBackup() SPAWNS the `sqlite3`
# CLI for `.backup` and PRAGMA integrity_check; the npm module does not
# ship that binary. backend/Dockerfile omits it because compose always runs
# Postgres — this image defaults to SQLite, so without it every database
# backup fails with ENOENT.
RUN apk add --no-cache dumb-init postgresql-client sqlite ffmpeg su-exec \
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
fc-cache -f
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
COPY --from=backend-builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs backend/ .
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
RUN printf '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \
fc-cache -f /app/assets/fonts
# ---------------------------------------------------------------------------
# One volume, one layout (#1042 scope: "single data layout on one volume")
# ---------------------------------------------------------------------------
# /data/db picpeak.db (+ -wal/-shm) and SETUP_TOKEN
# /data/storage originals, thumbnails, archives
# /data/logs application logs
# /data/backup built-in backup output; /backup symlinks here
#
# `-v picpeak:/data` and nothing else to remember — back up /data and you have
# backed up the install. /backup is where migrations 029 + 030 seed the backup
# destinations, so it is symlinked in rather than left dangling.
ENV DATA_ROOT=/data \
DATA_DIR=/data/db \
DATABASE_PATH=/data/db/picpeak.db \
STORAGE_PATH=/data/storage \
LOG_DIR=/data/logs \
BACKUP_DIR=/data/backup
# FRONTEND_URL is deliberately NOT set here (#705). It used to default to
# http://localhost:3000 so share links would not come out relative, but a
# baked-in value OVERRIDES the general_site_url setting the setup wizard
# writes — so a single-container install could never configure its own public
# address, and the Settings field would show as env-pinned for everyone.
# getFrontendBaseUrl() now resolves the setting, then the origin the request
# arrived on, and getAbsoluteFrontendUrl() still ends at http://localhost:3000,
# so links stay absolute without pinning anything. Override with
# -e FRONTEND_URL=https://photos.example.com for config-as-code deployments.
# /app/storage is a second entrance to the same volume. The business-document
# writers (quoteService, invoice sending/reminders, contract signatures) build
# their paths from `path.join(process.cwd(), 'storage', ...)` and never consult
# STORAGE_PATH. Compose hides that because it sets STORAGE_PATH=/app/storage
# with WORKDIR /app, so the two happen to be the same directory; here they are
# not, and /app is root-owned, so a quote or invoice PDF would fail to write as
# UID 1001 — and be lost with the container even if it succeeded. Teaching
# those services STORAGE_PATH is the real fix and belongs in its own change;
# the symlink restores the coincidence compose already relies on.
RUN mkdir -p /data/db /data/storage/events/active /data/storage/events/archived \
/data/storage/thumbnails /data/logs \
/data/backup/picpeak /data/backup/database && \
ln -s /data/backup /backup && \
ln -s /data/storage /app/storage && \
chown -R nodejs:nodejs /data
VOLUME ["/data"]
# The frontend bundle, served by server.js's SERVE_FRONTEND block. Explicit
# opt-in rather than the dist-exists autodetect, so the behavior is pinned
# even if the autodetect heuristic ever changes.
COPY --from=frontend-builder --chown=nodejs:nodejs /app/dist /app/frontend/dist
ENV SERVE_FRONTEND=true \
FRONTEND_DIR=/app/frontend/dist
# Marks this as the single-container build. The backend refuses to enable face
# recognition (#1074) when it sees this, on performance grounds: that feature
# needs a separate ML container this image does not contain, and it would add
# a second image-processing pipeline competing with Sharp for the CPU and
# memory of a container sized for one photographer plus guests browsing. The
# failure would not be loud — just a slow install that looks broken.
#
# An explicit marker rather than inferring it from SERVE_FRONTEND or the
# SQLite path: legitimate multi-container deployments do both of those, and
# none of them should lose the feature by accident.
ENV PICPEAK_SINGLE_CONTAINER=true
# No USER directive — same as backend/Dockerfile: the container starts as root
# so wait-for-db.sh can chown bind-mounted volumes to UID 1001, then drops
# privileges via su-exec (#484).
EXPOSE 3000
# Shell form so it resolves $PORT: a hard-coded 3000 marks an otherwise healthy
# container unhealthy forever the moment anyone overrides the port.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider "http://localhost:${PORT:-3000}/health" || exit 1
ENTRYPOINT ["dumb-init", "--"]
# --max-http-header-size matches nginx's `large_client_header_buffers 4 32k`.
# Requests reach Node directly here, and its 16 KiB default would reject a guest
# carrying several per-gallery JWT cookies before Express ever saw them.
CMD ["./wait-for-db.sh", "node", "--max-http-header-size=32768", "server.js"]
+133 -460
View File
@@ -1,90 +1,48 @@
# 📸 PicPeak - Open Source Photo Sharing for Events
> [!IMPORTANT]
> **PicPeak has moved to its own GitHub organization.**
>
> - **Docker images** are now published at `ghcr.io/picpeak/picpeak/{backend,frontend}`. The old path (`ghcr.io/the-luap/picpeak/...`) is no longer served — update your `docker-compose.yml`.
> - **Branches**: active development is now on `main` (was `beta`); the curated stable channel is now `stable` (was `main`). Existing PRs and clones auto-redirect via GitHub.
>
> See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit and full details.
<div align="center">
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
# 📸 PicPeak
**Open-source, self-hosted photo sharing for events.**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-theluap-FFDD00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/theluap)
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support the project ](https://buymeacoffee.com/theluap)
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support ☕](https://buymeacoffee.com/theluap)
</div>
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
---
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Built for photographers and event organizers, it makes it simple to share beautiful, time-limited photo galleries with clients while keeping full control over your data and branding.
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
> [!IMPORTANT]
> **PicPeak has moved to its own GitHub organization.** Docker images are now at `ghcr.io/picpeak/picpeak/{backend,frontend,aio,ml}` (and on Docker Hub as `picpeak/{backend,frontend,aio,ml}`) and active development is on `main`. The old `ghcr.io/the-luap/...` path still responds but its tags are **frozen** at 2026-05-27 — if updates never arrive, check your image path first. See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit.
## Contents
- [Live Demo](#-live-demo)
- [Quick Start](#-quick-start)
- [Why PicPeak?](#-why-picpeak)
- [Features](#-features)
- [Documentation](#-documentation)
- [Comparison](#-comparison-with-alternatives)
- [Tech Stack](#-tech-stack)
- [Contributing & Support](#-contributing)
- [License](#-license)
## 🎮 Live Demo
Try PicPeak without installing anything:
Try PicPeak without installing anything — [demo.picpeak.app](https://demo.picpeak.app) · [admin panel](https://demo.picpeak.app/admin)
| | |
| Email | Password |
|---|---|
| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
| **Email** | `demo@picpeak.app` |
| **Password** | `Demo2026!` |
| `demo@picpeak.app` | `Demo2026!` |
> The demo resets periodically. Uploaded content may be removed without notice.
## 🌟 Why Choose PicPeak?
Unlike expensive SaaS solutions, PicPeak gives you:
- **💰 No Monthly Fees** - One-time setup, unlimited galleries
- **🔒 Complete Data Control** - Your photos stay on your server
- **🎨 White-Label Ready** - Full branding customization
- **📱 Mobile-First Design** - Beautiful on all devices
- **🚀 Lightning Fast** - Optimized performance and caching
- **🌍 Multi-Language** - Built-in i18n support (EN, DE)
## ✨ Key Features
### For Photographers
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
- 🔗 **External Media (Reference Mode)** - Browse and import from a readonly external folder library without copying originals
-**Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
- 🔐 **Password Protection** - Secure client galleries
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
- 📽️ **Live Slideshow** - A separate fullscreen "Diashow" link per event for projectors at live events — auto-picks-up new uploads while it runs, with transitions, a logo watermark, and image-fit/colour options ([guide](docs/live-slideshow.md))
- 🎨 **Custom Themes** - Match your brand perfectly
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
### For Clients
- 🖼️ **Beautiful Galleries** - Clean, modern interface
- 📱 **Mobile Optimized** - Swipe through photos on any device
- ⬇️ **Bulk Downloads** - Download all photos with one click
- 🔍 **Smart Search** - Find photos quickly
- 📤 **Guest Uploads** - Optional client photo uploads
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
### Technical Excellence
- 🐳 **Docker Ready** - Deploy in minutes
- 🔄 **Auto-Processing** - Automatic thumbnail generation
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
- 💾 **Smart Storage** - Automatic archiving of expired galleries
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
- 📈 **Scalable** - From small studios to large agencies
### For Studios — CRM & Accounting (Beta · off by default)
- 📝 **Quotes → Contracts → Invoices** - One deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
- ⏱️ **Hours Logging & Calendar** - Per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
- 🧾 **Inbound Supplier Invoices & Expenses** - Capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
- 📊 **Tax Report & Accountant Export** - Period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export, scopable to income-only or cost-only
- 🌍 **VAT & Multi-currency** - Single VAT-code registry snapshotted onto each document; data-driven per-country rates
- ⚠️ **Verify locally** - Feature-flagged off by default. Seeded contracts, QR/IBAN and tax defaults are **examples only** — review your own legal **and tax** regulations first (see disclaimers below)
## 🚀 Quick Start
Get PicPeak running in under 5 minutes:
@@ -96,8 +54,8 @@ cd picpeak
# Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser (see below). Edit .env only to
# customise (domain, SMTP, storage paths, …) — nothing is required.
# admin account is created in the browser. Edit .env only to customise
# (domain, SMTP, storage paths, …) — nothing is required.
cp .env.example .env
# Start with Docker Compose
@@ -106,288 +64,92 @@ docker compose up -d
# Access at http://localhost:3000
```
### First run — create your admin account
On first start, open **http://localhost:3000/admin** and follow the in-browser setup to create your admin account. Full details — the one-time setup token, Docker file permissions, and ARM64 notes — are in **[First-run setup](https://docs.picpeak.app/getting-started/first-login)**.
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
> **Updating / release channels:** set `PICPEAK_CHANNEL` (`stable` default, or `beta`) in `.env`, then `docker compose pull && docker compose up -d`. See [RELEASING.md](RELEASING.md) for the promotion cadence.
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
```bash
docker compose logs backend | grep -i "setup token"
```
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
### Or: one container, no compose file
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
Note on Docker file permissions
- The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs.
- If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions).
**ARM64 (aarch64) systems:** Pre-built images include native `linux/arm64`, no platform flags or emulation needed. If you're on an older image tag that's still amd64-only, see [docker-compose.amd64.override.yml](docker-compose.amd64.override.yml) for a transitional fallback.
## 🔄 Release Channels
PicPeak offers two release channels for different needs. Stable promotions are cut from a known-good beta point every 46 weeks — see [RELEASING.md](RELEASING.md) for the maintainer's promotion criteria and cadence policy.
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Switching Channels
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
For a home server, a NAS, or a single small studio, the all-in-one image runs the whole app as one process with SQLite — no compose file, no separate database, no reverse proxy to wire up:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
docker run -d --name picpeak -p 3000:3000 \
-v picpeak:/data \
ghcr.io/picpeak/picpeak/aio:main
```
Then update your containers:
No environment variables to set — the JWT secret is generated on first start and kept on the volume.
```bash
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
Then open **http://localhost:3000/admin** and read the setup token with `docker exec picpeak cat /data/db/SETUP_TOKEN`, or open `db/SETUP_TOKEN` on the volume with any file manager if the host has no shell.
### Update Notifications
`:main` is the active-development tag, and today it is the only one the all-in-one image has — `Dockerfile.aio` landed after the current stable release, so `:stable` and `:latest` first appear for this image once the aio build reaches the `stable` branch. Switch to `:stable` then, or pin a published version tag if you would rather not track `main`.
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
The compose stack above is still the right choice for anything busier — SQLite takes one writer at a time, and Postgres is what scales. You can move to it later without reinstalling: take a `.picpeak` backup and restore it into the full stack. See **[Single-container install](https://docs.picpeak.app/deployment/single-container)** for the volume layout, the external-Postgres variant, TLS, and the limits.
```bash
UPDATE_CHECK_ENABLED=false
```
### Docker images
| | GHCR | Docker Hub |
|---|---|---|
| Backend | `ghcr.io/picpeak/picpeak/backend` | [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) |
| Frontend | `ghcr.io/picpeak/picpeak/frontend` | [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend) |
| All-in-one | `ghcr.io/picpeak/picpeak/aio` | [`picpeak/aio`](https://hub.docker.com/r/picpeak/aio) |
| ML sidecar (optional) | `ghcr.io/picpeak/picpeak/ml` | [`picpeak/ml`](https://hub.docker.com/r/picpeak/ml) |
Both registries get the same digests and the same tags — `stable`/`latest`, a pinned `x.y.z`, and `beta`/`main` for the active development channel — for `linux/amd64` and `linux/arm64`. Keep every image in one install on the **same** tag.
## 🌟 Why PicPeak?
Unlike expensive SaaS solutions, PicPeak gives you:
- **💰 No Monthly Fees** — one-time setup, unlimited galleries
- **🔒 Complete Data Control** — your photos stay on your server
- **🎨 White-Label Ready** — full branding customization
- **📱 Mobile-First Design** — beautiful on all devices
- **🌍 Multi-Language** — built-in i18n (EN, DE)
## ✨ Features
**For photographers** — drag & drop upload, auto-expiring & password-protected galleries, automated emails, an analytics dashboard, custom themes, a public landing page, and a [Live Slideshow](https://docs.picpeak.app/features/live-slideshow) projector view that auto-picks-up new uploads during live events.
**For clients** — clean mobile-optimized galleries, one-click bulk downloads, smart search, **[People in this gallery](https://docs.picpeak.app/features/face-recognition)** face grouping (opt-in per gallery, needs the optional [ML sidecar](https://github.com/PicPeak/picpeak/blob/main/ml/README.md)), optional guest uploads, and download protection (watermarking + right-click prevention).
**Technical** — Docker-ready, automatic thumbnail generation, external media reference mode, smart archiving of expired galleries, S3-compatible [storage backends](https://docs.picpeak.app/features/storage-backends), [webhooks](https://docs.picpeak.app/features/webhooks), and security-first defaults (JWT, rate limiting, CORS).
<details>
<summary><strong>🧾 For studios — CRM &amp; Accounting (Beta, off by default)</strong></summary>
- 📝 **Quotes → Contracts → Invoices** — one deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
- ⏱️ **Hours Logging & Calendar** — per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
- 🧾 **Inbound Supplier Invoices & Expenses** — capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
- 📊 **Tax Report & Accountant Export** — period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export
- 🌍 **VAT & Multi-currency** — single VAT-code registry snapshotted onto each document
</details>
> [!WARNING]
> **CRM & Accounting — examples only, verify locally.** Feature-flagged off by default. Seeded contract blocks are written by the maintainer, **not a lawyer**; QR-bills/SEPA payloads and every tax, VAT and Treuhänder/Banana figure are computed from your input and defaults and are **jurisdiction-specific guidance only**. Have your lawyer review contracts, scan a test QR with your bank's app, and verify all numbers with your accountant / Treuhänder / tax authority before customer-facing use. Read **[the CRM disclaimers](https://docs.picpeak.app/features/crm/disclaimers)** first.
## 📖 Documentation
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings reference, API docs, webhooks, archive lifecycle, branding, and everything else. Some quick links:
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings, API, branding, and more.
- 🚀 [**Deployment**](https://docs.picpeak.app/deployment) - Docker, environment variables, reverse proxy, SSL
- ⚙️ [**Admin Settings**](https://docs.picpeak.app/guides/admin-settings) - Every tab in the Settings panel
- 🎯 [**Creating Events**](https://docs.picpeak.app/guides/creating-events) - Full event field reference
- 📽️ [**Live Slideshow**](https://docs.picpeak.app/features/live-slideshow) - Fullscreen projector view that auto-updates during live events
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Backup configuration, restore wizard, full disaster recovery
- 🔌 [**API Reference**](https://docs.picpeak.app/api) - REST endpoints, OpenAPI spec, webhooks
- 🪝 [**Webhooks**](https://docs.picpeak.app/features/webhooks) - Event payloads, signing, filters, templates
Project meta:
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
- 📜 [**License**](LICENSE) - MIT License
- 🔒 [**Security**](SECURITY.md) - Security policies
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
## 🌐 Public Landing Page
Spotlight your studio with a customizable marketing page at `/`:
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
- Use **Reset to default** anytime to restore the bundled template.
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
## 🎯 Use Cases
Perfect for:
- 💒 **Wedding Photographers** - Share ceremony photos securely
- 🎂 **Event Photography** - Birthday parties, corporate events
- 📸 **Portrait Studios** - Client galleries with download limits
- 🏢 **Corporate Events** - Internal photo sharing with branding
- 🎓 **School Photography** - Secure parent access with expiration
- 📽️ **Live Events** - Put a [Live Slideshow](docs/live-slideshow.md) on the venue projector that updates as you shoot
## 🏗️ Tech Stack
- **Backend**: Node.js, Express, SQLite/PostgreSQL
- **Frontend**: React, Tailwind CSS, Framer Motion
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](#storage-backends)
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
## 💾 Storage Backends
PicPeak supports two storage backends for photos, thumbnails, hero images, watermarks, and archive zips. Both are configured via environment variables; no code change is required to switch.
| Capability | `STORAGE_BACKEND=local` (default) | `STORAGE_BACKEND=s3` |
|---|---|---|
| Photo / thumbnail / hero storage | Local filesystem under `STORAGE_PATH` | Bucket on any S3-compatible service |
| Admin UI upload | ✅ | ✅ |
| Filesystem auto-import (chokidar watcher) | ✅ | ❌ — disabled (use the upload API) |
| Watermarks, fingerprinting, fragmentation | ✅ | ✅ (materialized to a tmp file just-in-time) |
| Bulk download zips (cached + on-the-fly) | ✅ | ✅ |
| Backups | ✅ | ✅ |
| External media reference mode (`EXTERNAL_MEDIA_ROOT`) | ✅ (always local) | ✅ (still local — not migrated) |
### Switching to an S3-compatible backend
1. Provision a bucket and credentials. The minimum IAM policy is documented in `.env.example`.
2. Set `STORAGE_BACKEND=s3` plus `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_ACCESS_KEY`, `STORAGE_S3_SECRET_KEY`. For non-AWS providers (MinIO, R2, B2, …) also set `STORAGE_S3_ENDPOINT`.
3. If you have existing local content, copy it first: `node backend/scripts/migrate-storage.js --dry-run` then `node backend/scripts/migrate-storage.js`. The script is idempotent and writes a failures CSV.
4. Restart the backend. The startup check pings the bucket and refuses to boot on misconfig.
Note: presigned-URL serving (zero-bandwidth direct downloads from S3) is intentionally **not** in v1 — every request still streams through the backend so watermarks, devtools-detection, and access logging keep working.
## 🔔 Webhooks
PicPeak POSTs event/photo lifecycle notifications to URLs you configure under **Settings → Webhooks**. Each delivery is signed `HMAC-SHA256` with a per-webhook secret in the `X-PicPeak-Signature` header so receivers can verify the request really came from your PicPeak instance.
### Event types
| Event | Fires when |
| Topic | Link |
|---|---|
| `event.created` | Gallery created (admin or API) |
| `event.published` | Draft becomes live (`is_draft: true → false`) — also fires when an event is created with `is_draft=false` |
| `event.archived` | Bulk-archive, manual archive, or auto-archive on expiry |
| `event.expired` | Expiration checker marks the gallery inactive (fires before `event.archived` in the cascade) |
| `photo.uploaded` | Admin upload, API upload, guest upload, or auto-import |
| `photo.deleted` | Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from `event.archived` to avoid flooding) |
| 🚀 Deployment (Docker, env, reverse proxy, SSL) | [docs.picpeak.app/deployment](https://docs.picpeak.app/deployment) |
| 📦 Single-container install (one `docker run`, SQLite) | [docs.picpeak.app/deployment/single-container](https://docs.picpeak.app/deployment/single-container) |
| ⚙️ Admin settings reference | [docs.picpeak.app/guides/admin-settings](https://docs.picpeak.app/guides/admin-settings) |
| 🎯 Creating events | [docs.picpeak.app/guides/creating-events](https://docs.picpeak.app/guides/creating-events) |
| 📽️ Live Slideshow | [docs.picpeak.app/features/live-slideshow](https://docs.picpeak.app/features/live-slideshow) |
| 🙂 People in galleries (face grouping) | [docs.picpeak.app/features/face-recognition](https://docs.picpeak.app/features/face-recognition) |
| 💾 Backup & Restore | [docs.picpeak.app/guides/backup-restore](https://docs.picpeak.app/guides/backup-restore) |
| 🔌 API reference | [docs.picpeak.app/api](https://docs.picpeak.app/api) |
| 🪝 Webhooks | [docs.picpeak.app/features/webhooks](https://docs.picpeak.app/features/webhooks) |
| 💾 Storage backends (local / S3) | [docs.picpeak.app/features/storage-backends](https://docs.picpeak.app/features/storage-backends) |
| 💻 System requirements & tuning | [docs.picpeak.app/deployment/system-requirements](https://docs.picpeak.app/deployment/system-requirements) |
| 🧾 CRM & Accounting | [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm) · [disclaimers](https://docs.picpeak.app/features/crm/disclaimers) |
| 🗺️ Roadmap | [GitHub Issues](https://github.com/PicPeak/picpeak/issues) |
### Payload shape
```json
{
"id": "delivery-uuid",
"type": "event.published",
"created_at": "2026-04-28T05:25:00.000Z",
"data": {
"event": { "id": 123, "slug": "wedding-smith", "share_url": "https://..." }
}
}
```
Also sent on every request:
- `X-PicPeak-Signature` — `HMAC-SHA256(secret, raw_body)` as hex
- `X-PicPeak-Event` — the event type (handy for routing without parsing the body)
- `X-PicPeak-Delivery` — UUID for idempotency on the receiver side
- `User-Agent: PicPeak-Webhooks/1.0`
### Verifying signatures
**Node.js**
```js
const crypto = require('crypto');
function verify(secret, rawBody, signature) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
```
**Python**
```python
import hmac, hashlib
def verify(secret: str, raw_body: bytes, signature: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
```
**curl + openssl** (one-liner for a quick replay)
```sh
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
[ "$SIG" = "$RECEIVED_SIG" ] && echo OK || echo MISMATCH
```
### Retries + observability
- `2xx` → success, recorded with latency
- Non-`2xx` or network error → exponential backoff: `1m → 5m → 30m → 2h → 12h`, max 5 attempts
- After max attempts: status `failed`, surfaces in **Settings → Webhooks → Deliveries** with a "Replay" button
- Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via `WEBHOOK_DELIVERY_CONCURRENCY`)
- Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log
The deliveries page (`/admin/webhooks/:id/deliveries`) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.
### SSRF protection
Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, `.local`/`.internal` hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).
For local development with a receiver on the same machine or docker network, set `WEBHOOK_ALLOW_PRIVATE_URLS=true`. Production deployments must leave this OFF.
## 💻 System Requirements
### Minimum Requirements
- **CPU**: 2 CPU cores
- **RAM**: **4 GB minimum** for a normal photo-upload workload — sharp/libvips
decodes the full uncompressed frame before resize, and the default two
worker loops at sharp-concurrency 2 can push peak RSS past 1.5 GB on a
batch of 20-MP+ photos. On a 2 GB VPS that's enough to OOM-kill the
backend mid-batch (surfaces as 503s on thumbnails — see [Low-memory
hosts](#low-memory-hosts) below for the recipe to run on 2 GB).
- **Storage**: 20GB minimum (plus photo storage needs)
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
- **Node.js**: v18.0.0 or higher
- **Database**: SQLite (included) or PostgreSQL 12+
### Docker Requirements (Recommended)
- **Docker**: v20.10.0+
- **Docker Compose**: v2.0.0+
### Low-memory hosts
Running on 2 GB RAM (e.g. an entry-level VPS) is workable but requires
tuning the upload-processor concurrency down. The backend auto-detects
total RAM at startup via `os.totalmem()` — on a host that reports < 3 GB,
it defaults `UPLOAD_PROCESSOR_CONCURRENCY` to **1** instead of 2 and logs
a one-shot warning. You can pin the value explicitly in `.env`:
```env
# Single worker loop — slower batch processing, lower peak RSS
UPLOAD_PROCESSOR_CONCURRENCY=1
```
The trade-off is throughput: a single worker processes one photo at a
time, so a 100-photo batch takes ~2× as long but won't OOM. **Health-check
note**: if the backend dies under memory pressure, the gallery serves
`503 Service Unavailable` on thumbnails until Docker's
`restart: unless-stopped` brings the container back. Persistent 503s
during/after an upload batch on a low-memory host are almost always this.
### Video Support Requirements
When enabling video uploads, consider these additional resources:
| Resource | Recommendation | Notes |
|----------|----------------|-------|
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
**Technical Notes:**
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
- Maximum upload size: **10GB per video file**
- Chunked upload support for files >100MB (resumable uploads)
- Supported formats: MP4, WebM, MOV, AVI
- Video thumbnails are automatically generated from the first few seconds
**For Nginx/Reverse Proxy:**
If using Nginx, increase the client max body size:
```nginx
client_max_body_size 10G;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
```
## 🤝 Contributing
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
See our [Contributing Guide](CONTRIBUTING.md) for details.
**Project meta:** [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
## 📊 Comparison with Alternatives
@@ -404,168 +166,79 @@ See our [Contributing Guide](CONTRIBUTING.md) for details.
| Quotes / Contracts / Invoices | 🧪 Beta | ❌ | ❌ | ✅ |
| Incoming Invoices & Accounting | 🧪 Beta | ❌ | ❌ | ❌ |
*You still bring your own server (own hardware or a VPS) and, if you want one, a domain.
**Limited only by your server storage.
***Pixieset's "unlimited" is photos only; video is capped by plan (roughly 010 h depending on tier).
🧪 Beta = built but feature-flagged off by default (see [Beta Features](#-beta-features-use-at-your-own-risk)).
<sub>*You bring your own server and, optionally, a domain. **Limited only by your server storage. ***Pixieset's "unlimited" is photos only; video is capped by plan. 🧪 Beta = built but feature-flagged off by default.</sub>
## 🛡 Security
## 🏗 Tech Stack
PicPeak takes security seriously:
- 🔐 Password hashing with bcrypt
- 🎫 JWT-based authentication
- 🚦 Rate limiting on all endpoints
- 🛡️ CORS protection
- 📝 Activity logging
- 🔒 Secure file access
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
- **Backend**: Node.js, Express, SQLite/PostgreSQL
- **Frontend**: React, Tailwind CSS, Framer Motion
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](https://docs.picpeak.app/features/storage-backends)
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
- **External media**: point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals read-only, index quickly, and generate thumbnails on demand
## 📸 Screenshots
### 🎛️ **Admin Dashboard**
Get a complete overview of your photo galleries, analytics, and system status.
<details>
<summary>Click to see the admin dashboard, analytics, and event management</summary>
### 🎛️ Admin Dashboard
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
### 📊 **Analytics & Insights**
Track gallery performance, view statistics, and monitor user engagement.
### 📊 Analytics & Insights
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
### 📁 **Event Management**
Organize and manage your photo galleries with intuitive event management tools.
### 📁 Event Management
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
### ✨ **Key Interface Highlights**
<details>
<summary>👆 Click to see more interface details</summary>
#### What makes PicPeak's interface special:
- **🎨 Clean Design**: Modern, photographer-friendly interface
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
- **⚡ Fast Loading**: Optimized for quick photo browsing
- **🔒 Secure Access**: Password-protected galleries with expiration
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
</details>
## 🗺️ Roadmap
## 🤝 Contributing
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
We love contributions! PicPeak is built by photographers, for photographers — whether you're fixing bugs, adding features, or improving docs. See the [Contributing Guide](CONTRIBUTING.md) to get started.
### 🚧 Beta Features (Use at your own risk)
These features are currently in beta testing and may have limited functionality or stability:
| Feature | Description | Status |
|---------|-------------|--------|
| **CRM & Accounting Module** | Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are **examples only** and need legal / financial / **tax** review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta |
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
### 📋 Future Enhancements
| Feature | Description | Priority | Status |
|---------|-------------|----------|---------|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
| **External Media Library (Reference Mode)** | Use an external folder library as a readonly source with import and ondemand thumbnail generation | High | ✅ Implemented |
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security). See [SECURITY.md](SECURITY.md) for the policy.
## ☕ Support the Project
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds the time spent on new features, bug fixes, and keeping the demo + docs running.
<p align="left">
<a href="https://buymeacoffee.com/theluap" target="_blank">
<img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=theluap&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" alt="Buy Me A Coffee" />
</a>
</p>
Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR.
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider [buying me a coffee](https://buymeacoffee.com/theluap) — it directly funds new features, bug fixes, and keeping the demo + docs running. You can also ⭐ star the repo, share it, file good bug reports, or open a PR.
## 🙏 Acknowledgments
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. It's developed with AI assistance, but human-tested end-to-end, security-audited, and human-reviewed for quality.
### 👥 Contributors
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
- [**@the-luap**](https://github.com/the-luap) — creator and lead maintainer. Started the project and built PicPeak's foundation and the entire gallery experience (events, galleries, uploads, sharing, download protection, templates), plus backup & restore, analytics, system health, branding/theming, and WhatsApp notifications — and the architecture every later feature builds on.
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
**[@the-luap](https://github.com/the-luap)** — creator and lead maintainer
- Gallery foundation (events, uploads, sharing, download protection, templates)
- Backup & restore, analytics, branding/theming
- The architecture every later feature builds on
**[@Luca-Timo](https://github.com/Luca-Timo)**
- Native Apple Silicon multi-arch images
- CRM & accounting suite (quotes/contracts/invoices)
- Hours logging & Treuhänder/Banana tax export
- Gallery header/banner decoupling
**[@Rekoo-PS](https://github.com/Rekoo-PS)** — bug reports & product feedback
- Login-loop fix, mobile-lightbox overhaul, bulk-delete workflow
- Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
### 🤖 AI-Assisted Development
This project was generated with the assistance of AI technology, but has been:
- ✅ **Fully tested end-to-end** by human developers
- 🔒 **Security audited** with comprehensive security checks
- 👨‍💻 **Human-reviewed** for code quality and best practices
- 🧪 **Production-tested** in real-world scenarios
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
## ⚠️ CRM & Accounting disclaimers — examples only, verify locally
The CRM & accounting modules (contracts, invoices, QR-bills, the tax
report and the accountant exports) ship seeded content and computed
figures that are intended as a **starting point only**:
- **Contract blocks** (image rights, NDA, model release, cancellation,
jurisdiction, …) are written by the maintainer, **not by a lawyer**.
Every operator must have their lawyer review and adapt them before
sending any contract to a customer.
- **QR-bills and SEPA EPC payloads** are rendered from the data you
typed. Picpeak is open source — please scan a test invoice with your
bank's app to check the QR actually works. We are not responsible for
any mistakes that come from sending an invoice with bad data on it.
- **Tax, VAT & accounting figures** (the tax report, VAT-payable, the
per-rate breakdown, the Treuhänder / Banana export, etc.) are computed
from the data you enter and the defaults you configure. They are
**guidance only and jurisdiction-specific** — tax rules, VAT rates,
deduction schemes (e.g. the Liechtenstein 20 % Gewinnungskosten flat
rate) and filing duties differ by country and change over time. **Every
operator must check their own tax / VAT regulations and verify the
numbers with their accountant / Treuhänder / tax authority before
relying on any figure or export.** Picpeak makes no warranty that the
output is correct for your jurisdiction or situation.
Read [`docs/crm-disclaimers.md`](docs/crm-disclaimers.md) before
enabling the Contracts, Invoices or Accounting features.
## 📄 License
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
## 🚀 Ready to Get Started?
1.**Star this repository** to show your support
2. 📖 Read the [docs at docs.picpeak.app](https://docs.picpeak.app)
3. 🐛 Report issues or request features
4. 🤝 Join our community and contribute!
---
<p align="center">
Made with ❤️ by photographers, for photographers
<br>
<a href="https://www.picpeak.app">Homepage</a>
<a href="https://demo.picpeak.app">Live Demo</a>
<a href="https://github.com/PicPeak/picpeak">GitHub</a>
<a href="https://docs.picpeak.app">Documentation</a> •
<a href="https://www.picpeak.app">Homepage</a> ·
<a href="https://demo.picpeak.app">Live Demo</a> ·
<a href="https://docs.picpeak.app">Documentation</a> ·
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
</p>
+18 -4
View File
@@ -52,13 +52,19 @@ The actual mechanics, in order:
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
5. **Pin the stable version to match `main` (number alignment — see Versioning).** Determine `X.Y.Z` = the `main` tip's **base** version (its `vX.Y.Z-beta.N`, dropping the `-beta.N` suffix), and add an empty commit on the release branch:
```bash
git commit --allow-empty -m "chore: release X.Y.Z" -m "Release-As: X.Y.Z"
```
The `Release-As:` footer forces release-please to cut exactly `X.Y.Z`. Without it, release-please computes the next MINOR from the *previous stable* tag (e.g. `3.45.0` → `3.46.0`) while `main` is already at `3.84.x`, so the stable number drifts ever further behind for the same code.
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
6. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
7. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
8. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
8. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
9. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
## Hotfix path (backport to current stable)
@@ -83,6 +89,14 @@ PicPeak follows [Semantic Versioning](https://semver.org/) with one project-spec
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
### Stable ↔ pre-release number alignment
The two channels run **independent** release-please counters: `main` bumps on every merge (racing ahead), while `stable` only bumps on a promotion. Left to itself, `stable` computes each promotion as the next MINOR from the *previous stable tag*, so the two drift far apart — e.g. `main` at `v3.83.x-beta.0` while `stable` sat at `v3.45.0` for the **same code**, which reads as "stable is 38 versions behind" when it isn't.
To keep the numbers legible, **a promotion sets the stable version to the current `main` base version** (the `X.Y.Z` of the `main` tip's `vX.Y.Z-beta.N`, minus the suffix). Promoting a `main` at `v3.84.2-beta.0` therefore cuts stable `v3.84.2`, and the stable number tracks `main` instead of lagging. This is forced with the `Release-As:` commit in step 5 of the cut procedure — the one-time catch-up jump (e.g. `3.45.0 → 3.84.x`) is expected and happens only on the first aligned promotion.
> **Release-engineering note (2026-07):** `release-please.yml` (the stable workflow) *must* keep `target-branch: stable`. Without it, release-please defaults to the repo's default branch (`main`), reads `main`'s stale `.release-please-manifest.json`, and cuts a wrong/regressed version — this is what produced a bogus `v2.7.0` once. If a promotion ever yields an unexpected version, check that first.
## Things that don't go through this process
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
+4 -2
View File
@@ -170,10 +170,12 @@ If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your ad
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
2. Read the **one-time setup token** from the 0600 file the backend writes it to
(it is not logged — that would leave a live credential in `docker logs`):
```bash
docker compose logs backend | grep -i "setup token"
docker compose exec backend cat /app/data/SETUP_TOKEN
```
Only if that write fails does the backend log the token instead.
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
## 🌐 Access Methods
+1 -3
View File
@@ -1,9 +1,7 @@
node_modules
npm-debug.log
.env
storage/events/active/*
storage/events/archived/*
storage/thumbnails/*
storage
data/*.db
logs/*
coverage
+16
View File
@@ -9,6 +9,16 @@ PORT=3001
# Generate with: openssl rand -base64 32
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
# Generate with: openssl rand -base64 32
#MFA_ENCRYPTION_KEY=
# Auth cookie Secure flag
# unset - default: 'auto' in production, false in dev (#427)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
@@ -96,6 +106,12 @@ ARCHIVE_PATH=/app/storage/events/archived
# EVENTS_PATH=./storage/events
# ARCHIVE_PATH=./storage/events/archived
# File watcher (auto-import from the events/active folder, local storage only)
# Max photos processed in parallel by the watcher. The boot scan and bulk
# folder drops fire one handler per file — this bound keeps thumbnail
# generation from exhausting memory on small hosts. Default: 2
# FILE_WATCHER_CONCURRENCY=2
# Analytics Backend Configuration (OPTIONAL)
# Used for server-side tracking only
# Primary configuration should be done through Admin UI > Settings > Analytics
+33 -12
View File
@@ -27,17 +27,35 @@ FROM node:22-alpine
WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
# knexfile.js picks its config block by NODE_ENV, and the `development` block
# defaults to sqlite3. Leaving NODE_ENV unset here meant every deployment that
# doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
# silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD, while
# wait-for-db.sh (shell, reads DB_HOST directly) reported "PostgreSQL is up" in
# the same log. The compose files still override this, so nothing changes for
# compose users. See #1038.
ENV NODE_ENV=production
# Upgrade the npm CLI in the final image so its bundled deps are patched
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime
# dependencies come from the builder stage (COPY --from=builder node_modules
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs
# Node >=22.9, satisfied by node:22-alpine.
RUN npm install -g npm@11
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
# stage's declaration never reached this stage. Consuming it in the RUN below
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
# image always picks up current Alpine security updates instead of reusing a
# stale cached upgrade layer.
ARG CACHEBUST=1
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
# Remove the npm CLI from the final image. Nothing runs npm here: the
# entrypoint is node, runtime deps are COPY'd from the builder stage, and
# wait-for-db.sh invokes the migration runners via node directly. npm's
# bundled node_modules kept tripping Trivy (sigstore, tar 7.5.19,
# brace-expansion 5.0.7 — even npm 12.0.1 still ships the vulnerable
# copies), so shipping no npm ends that alert class instead of chasing
# per-release patches. Note: `docker exec … npm run <script>` no longer
# works in the container — use `node migrations/run-migrations-safe.js`
# and friends instead.
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# Install dumb-init for proper signal handling, postgresql-client for database
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
@@ -59,9 +77,12 @@ RUN npm install -g npm@11
# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
# documents (see docs/accounting-inbound-invoices.md).
# documents (see https://docs.picpeak.app/features/accounting/incoming-invoices).
# exiftool extracts the embedded full-res JPEG preview from RAW/DNG uploads
# (Apple ProRAW etc.) — sharp's libvips has no raw loader, so the pipeline
# thumbnails/displays that preview while keeping the original for download.
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
fontconfig ttf-dejavu ttf-liberation poppler-utils && \
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
fc-cache -f
# Create non-root user
+4 -1
View File
@@ -8,7 +8,10 @@ RUN apk upgrade --no-cache
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
RUN apk add --no-cache dumb-init ffmpeg
# exiftool: extract embedded JPEG previews from RAW/DNG uploads (#821) — kept in
# sync with the production Dockerfile so dev/native runtimes don't accept a DNG
# and then fail it with ENOENT.
RUN apk add --no-cache dumb-init ffmpeg exiftool
# Copy package files
COPY package*.json ./
@@ -0,0 +1,207 @@
/**
* Restoring an archive must put the photos back into their categories.
*
* 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` from 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, with a 200 response.
*
* These pin the manifest as the source of truth, with the directory as the
* fallback that keeps foldered and legacy archives working.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('archive restore restores categories (flat archives included)', () => {
let tmpDir; let db; let cleanup; let app; let storagePath;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-restore-cat-'));
storagePath = path.join(tmpDir, 'storage');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
process.env.STORAGE_PATH = storagePath;
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
// bootCrmDb points STORAGE_PATH at its own tmp dir; follow it rather than
// fighting it, so the archives the tests write are where the route looks.
storagePath = process.env.STORAGE_PATH;
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
app = express();
app.use(express.json());
app.use('/admin/archives', require('../../src/routes/adminArchives'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
await db('photos').del();
await db('photo_categories').del();
await db('events').del();
});
/** A one-pixel JPEG is enough; the route only stats the extracted file. */
const PIXEL = Buffer.from(
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a'
+ 'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA'
+ 'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==',
'base64',
);
async function writeArchive(name, entries) {
// Required lazily: the suite calls jest.resetModules() in beforeAll, and
// archiver's readable-stream copy does not survive being split across the
// two module registries.
const archiver = require('archiver');
const archivePath = path.join(storagePath, 'archives', name);
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(archivePath);
const zip = archiver('zip', { zlib: { level: 0 } });
output.on('close', resolve);
zip.on('error', reject);
zip.pipe(output);
for (const [entryName, buffer] of Object.entries(entries)) {
zip.append(buffer, { name: entryName });
}
zip.finalize();
});
return path.join('archives', name);
}
async function seedArchivedEvent(archiveRelPath, slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-06-27',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
is_archived: 1, // sqlite stores booleans as 0/1, see utils/dbCompat
archive_path: archiveRelPath,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
const categoryOf = async (filename) => {
const photo = await db('photos').where('filename', filename).first();
if (!photo || !photo.category_id) return null;
const category = await db('photo_categories').where('id', photo.category_id).first();
return category ? category.name : null;
};
it('takes the category from the manifest when the archive is flat', async () => {
// Exactly the shape a gallery-root event archives to: no directories.
const manifest = JSON.stringify([
{ filename: 'a.jpg', original_filename: 'DSC_0001.jpg', category_name: 'Polterabend' },
{ filename: 'b.jpg', original_filename: 'DSC_0002.jpg', category_name: 'Ceremony' },
]);
const archiveRelPath = await writeArchive('flat.zip', {
'a.jpg': PIXEL,
'b.jpg': PIXEL,
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'flat-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
// The whole bug: both of these used to be null.
expect(await categoryOf('a.jpg')).toBe('Polterabend');
expect(await categoryOf('b.jpg')).toBe('Ceremony');
});
it('reuses an existing category row instead of creating a duplicate', async () => {
const archiveRelPath = await writeArchive('reuse.zip', {
'c.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'c.jpg', original_filename: 'DSC_0003.jpg', category_name: 'Party' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'reuse-event');
await db('photo_categories').insert({
event_id: eventId, name: 'Party', slug: 'party', created_at: new Date(),
});
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('c.jpg')).toBe('Party');
const rows = await db('photo_categories').where({ event_id: eventId, name: 'Party' });
expect(rows).toHaveLength(1);
});
it('still falls back to the directory for legacy archives with no manifest', async () => {
// No manifest at all — the shape every archive had before the manifest
// landed. The directory is the only signal left, and it must keep working.
//
// `individual/` is what a REAL archive contains: entry names are the
// storage key minus `events/active/{slug}`, and that layout is
// `individual/` / `collages/`. Categories have never been directories, so
// the fallback invents a category with that name — not useful, but better
// than losing every category, and this pins what actually happens rather
// than a category-shaped folder no archive produces.
const archiveRelPath = await writeArchive('foldered.zip', {
'individual/d.jpg': PIXEL,
});
const eventId = await seedArchivedEvent(archiveRelPath, 'foldered-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('d.jpg')).toBe('individual');
});
it('honours a manifest that says UNCATEGORIZED, instead of inventing one from the directory', async () => {
// The case the manifest-first change was for. A real archive puts every
// photo under `individual/`, so a photo the manifest records as having no
// category used to come back filed under a category called "individual" —
// the manifest being authoritative for "category X" but not for "none".
const manifest = JSON.stringify([
{ filename: 'u.jpg', original_filename: 'DSC_7000.jpg', category_name: null },
]);
const archiveRelPath = await writeArchive('uncategorized.zip', {
'individual/u.jpg': PIXEL,
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'uncategorized-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('u.jpg')).toBeNull();
// And no junk category row was created as a side effect.
const rows = await db('photo_categories').where({ event_id: eventId });
expect(rows).toHaveLength(0);
});
});
@@ -40,7 +40,7 @@ jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('GET /api/admin/system-health/backup-coverage', () => {
let db;
@@ -29,7 +29,7 @@ jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('GET /api/admin/system-health/backup-integrity', () => {
let cleanup;
@@ -0,0 +1,253 @@
/**
* POST /admin/thumbnails/regenerate for external/reference photos (#1129).
*
* The route used to resolve every source as `storage/events/active/<path>` and
* `fs.access` it. External and reference rows do not live there — their
* originals sit under `events.external_path` — so every one of them failed the
* check and was counted as an error.
*
* That alone would be inert. What made it destructive is that the tier
* deletion runs FIRST (deliberately, so S3 and external rows are not skipped):
* on a reference install the button dropped every ?w= tier and rebuilt
* nothing, while the UI reported success — the response is sent before the
* background loop starts.
*
* The background work is fired with setImmediate, so every assertion here has
* to wait for it to drain rather than trusting the response.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('admin thumbnail regeneration (#1129)', () => {
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
// One instance, not a fresh object per call — the route and the
// assertions have to be looking at the same mock.
jest.doMock('../../src/services/storage', () => {
const instance = { delete: jest.fn().mockResolvedValue(undefined) };
return { getStorage: () => instance };
});
jest.doMock('../../src/services/imageProcessor', () => ({
ensureThumbnail: jest.fn().mockResolvedValue('thumbnails/thumb_ext1_shot.jpg'),
ensurePreviewImage: jest.fn().mockResolvedValue('previews/p.jpg'),
deleteThumbnailTiers: jest.fn().mockResolvedValue(undefined),
deletePreviewTiers: jest.fn().mockResolvedValue(undefined),
}));
// bootCrmDb, not run-migrations: the latter calls process.exit(0) on
// success, which ends the jest worker mid-suite.
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
imageProcessor = require('../../src/services/imageProcessor');
storage = require('../../src/services/storage').getStorage();
app = express();
app.use(express.json());
app.use('/admin/thumbnails', require('../../src/routes/adminThumbnails'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
jest.clearAllMocks();
await db('photos').del();
await db('events').del();
});
async function seedEvent() {
const [row] = await db('events').insert({
slug: 'nas-wedding', event_type: 'wedding', event_name: 'nas',
event_date: '2026-01-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: 'nas-share', expires_at: new Date().toISOString(),
source_mode: 'reference', external_path: 'weddings/2026-08',
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function seedPhoto(eventId, overrides = {}) {
const [row] = await db('photos').insert({
event_id: eventId, filename: 'shot.jpg', path: 'nas-wedding/shot.jpg',
type: 'individual', ...overrides,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/** The work runs in setImmediate; give it room to finish. */
const drain = () => new Promise((resolve) => setTimeout(resolve, 150));
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'external',
external_relpath: 'shot.jpg',
thumbnail_path: 'thumbnails/stale.jpg',
});
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
expect(res.status).toBe(200);
await drain();
// The whole bug: this used to be zero calls and one logged
// "Original file not found" per photo.
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
it('nulls thumbnail_path so the valid-thumbnail short-circuit cannot skip the rebuild', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'external',
external_relpath: 'shot.jpg',
thumbnail_path: 'thumbnails/still-on-disk.jpg',
});
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
// Without this the endpoint is a no-op whenever the OLD thumbnail is still
// readable — which is the normal case after a settings change, and exactly
// when the admin pressed the button.
const [photoArg] = imageProcessor.ensureThumbnail.mock.calls[0];
expect(photoArg.thumbnail_path).toBeNull();
expect(photoArg.source_origin).toBe('external');
// Carried through so ensureThumbnail can resolve off the mount rather than
// under events/active.
expect(photoArg.external_relpath).toBe('shot.jpg');
});
it('still drops the responsive tiers first', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, { source_origin: 'external', external_relpath: 'shot.jpg' });
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
// They are keyed by width outside thumbnail_path and carry no settings
// version, so leaving them serves the old fit to phones indefinitely.
expect(imageProcessor.deleteThumbnailTiers).toHaveBeenCalledTimes(1);
});
it('leaves videos alone rather than handing a container file to Sharp', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, { source_origin: 'managed', media_type: 'video', filename: 'clip.mp4' });
await seedPhoto(eventId, { source_origin: 'managed', filename: 'still.jpg' });
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(res.body.count).toBe(1);
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
expect(imageProcessor.ensureThumbnail.mock.calls[0][0].filename).toBe('still.jpg');
});
/**
* On S3, ensureThumbnail downloads the source to a randomly-named temp file,
* and for non-RAW input withProcessableImage passes no outputBasename — so
* generateThumbnail derives the key from that random name and it differs on
* every run. Nulling thumbnail_path hides the old key from everything that
* would otherwise clean it up, so each regeneration would strand a full
* thumbnail in the bucket, once per photo per run.
*/
describe('superseded canonical renditions', () => {
it('removes the old thumbnail when the key moved', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_OLDRANDOM_shot.jpg',
});
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEWRANDOM_shot.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).toHaveBeenCalledWith('thumbnails/thumb_OLDRANDOM_shot.jpg');
});
it('does NOT delete when the key is unchanged — that is the new file', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_stable.jpg',
});
// Local storage resolves to a stable path, so the key is identical.
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_stable.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).not.toHaveBeenCalled();
});
it.each([
['a Windows-style legacy path', 'thumbnails\\thumb_ext1_shot.jpg'],
['a leading ./', './thumbnails/thumb_ext1_shot.jpg'],
['a doubled separator', 'thumbnails//thumb_ext1_shot.jpg'],
])('does not delete the file it just wrote when the old path is %s', async (_name, stored) => {
const eventId = await seedEvent();
await seedPhoto(eventId, { source_origin: 'managed', thumbnail_path: stored });
// Both storage backends fold these to the same key, so this is the SAME
// object — deleting it would remove the freshly generated thumbnail and
// leave the row pointing at nothing.
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_ext1_shot.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).not.toHaveBeenCalled();
});
it('counts the photo as regenerated even if the old object cannot be removed', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_OLD.jpg',
});
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEW.jpg');
storage.delete.mockRejectedValueOnce(new Error('bucket said no'));
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
// Losing the old object is untidy; the regeneration itself succeeded.
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
});
it('scopes to one event when asked', async () => {
const a = await seedEvent();
await seedPhoto(a, { source_origin: 'external', external_relpath: 'a.jpg' });
const [b] = await db('events').insert({
slug: 'other', event_type: 'wedding', event_name: 'other', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: 'other-share', expires_at: new Date().toISOString(),
}).returning('id');
await seedPhoto(typeof b === 'object' ? b.id : b, { source_origin: 'managed' });
const res = await request(app).post('/admin/thumbnails/regenerate').send({ eventId: a });
await drain();
expect(res.body.count).toBe(1);
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,109 @@
/**
* Backup credential exposure regression tests.
*
* The generic settings reads (GET /admin/settings, GET /admin/settings/:type)
* masked the recaptcha/umami/rybbit keys but returned backup_s3_secret_key
* and backup_rsync_ssh_key (an SSH PRIVATE KEY) in plaintext to any
* settings.view holder; GET /admin/backup/config returned them too. Both now
* mask, and PUT /admin/backup/config skips the mask sentinel so the edit
* form round-trips without clobbering stored credentials.
*/
const request = require('supertest');
const express = require('express');
const { bootCrmDb } = require('./helpers/crmDb');
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => {
req.admin = { id: 1, username: 'test-admin' };
next();
},
}));
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
requireSuperAdmin: () => (_req, _res, next) => next(),
}));
describe('backup credential masking', () => {
let db;
let cleanup;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Upsert: several backup_* keys are pre-seeded by the backup migrations.
const seed = [
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('s3'), setting_type: 'backup' },
{ setting_key: 'backup_s3_endpoint', setting_value: JSON.stringify('https://s3.example.com'), setting_type: 'backup' },
{ setting_key: 'backup_s3_bucket', setting_value: JSON.stringify('backups'), setting_type: 'backup' },
{ setting_key: 'backup_s3_access_key', setting_value: JSON.stringify('AKIAEXAMPLE'), setting_type: 'backup' },
{ setting_key: 'backup_s3_secret_key', setting_value: JSON.stringify('super-secret-s3-key'), setting_type: 'backup' },
{ setting_key: 'backup_rsync_ssh_key', setting_value: JSON.stringify('-----BEGIN OPENSSH PRIVATE KEY-----abc'), setting_type: 'backup' },
];
for (const row of seed) {
await db('app_settings').insert(row).onConflict('setting_key').merge();
}
app = express();
app.use(express.json());
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('masks the credentials in GET /admin/backup/config', async () => {
const res = await request(app).get('/api/admin/backup/config').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
// Non-secret fields stay readable for the form.
expect(res.body.backup_s3_bucket).toBe('backups');
});
it('masks the credentials in the generic GET /admin/settings/:type read', async () => {
const res = await request(app).get('/api/admin/settings/backup').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
});
it('masks the credentials in the generic GET /admin/settings read', async () => {
const res = await request(app).get('/api/admin/settings').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
});
it('PUT /admin/backup/config keeps the stored secret when the sentinel round-trips', async () => {
await request(app)
.put('/api/admin/backup/config')
.send({
backup_destination_type: 's3',
backup_s3_endpoint: 'https://s3.example.com',
backup_s3_bucket: 'renamed-bucket',
backup_s3_access_key: 'AKIAEXAMPLE',
backup_s3_secret_key: '••••••••',
backup_rsync_ssh_key: '••••••••',
})
.expect(200);
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
expect(JSON.parse(secret.setting_value)).toBe('super-secret-s3-key');
const sshKey = await db('app_settings').where({ setting_key: 'backup_rsync_ssh_key' }).first();
expect(JSON.parse(sshKey.setting_value)).toBe('-----BEGIN OPENSSH PRIVATE KEY-----abc');
const bucket = await db('app_settings').where({ setting_key: 'backup_s3_bucket' }).first();
expect(JSON.parse(bucket.setting_value)).toBe('renamed-bucket');
});
it('PUT /admin/backup/config stores a genuinely new secret', async () => {
await request(app)
.put('/api/admin/backup/config')
.send({ backup_s3_secret_key: 'rotated-s3-key' })
.expect(200);
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
expect(JSON.parse(secret.setting_value)).toBe('rotated-s3-key');
});
});
@@ -23,7 +23,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('backupService — configurable walker (backup_paths)', () => {
let db;
@@ -177,4 +177,203 @@ describe('backupService — configurable walker (backup_paths)', () => {
const filesOn = await backupService.getFilesToBackup(true);
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
});
// Issue #871 — the "What to Backup" checkboxes were stored but never read.
describe('UI opt-out toggles (issue #871)', () => {
it('unchecking Thumbnails excludes thumbnails/', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({
backup_include_thumbnails: false,
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
});
it('unchecking Photos excludes events/active', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({
backup_include_photos: false,
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('thumbnails/E1/thumb.jpg');
expect(rels).not.toContain('events/active/E1/photo.jpg');
});
it('defaults to including everything when the keys were never saved', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('thumbnails/E1/thumb.jpg');
expect(rels).toContain('events/active/E1/photo.jpg');
});
it("accepts the UI's plural backup_include_archives for the archived gate", async () => {
seedFile('events/archived/E4/archived.jpg');
const files = await backupService.getFilesToBackup({
backup_include_archives: true,
});
expect(files.map((f) => f.relativePath)).toContain('events/archived/E4/archived.jpg');
});
it('the UI plural key beats the migration-seeded singular key', async () => {
// Migration seeds backup_include_archived=true on every install; the
// form only ever writes the plural key, so unchecking Archives must
// win over the stale seeded value.
seedFile('events/archived/E5/archived.jpg');
const files = await backupService.getFilesToBackup({
backup_include_archived: true, // seeded default
backup_include_archives: false, // what the admin actually chose
});
expect(files.map((f) => f.relativePath)).not.toContain('events/archived/E5/archived.jpg');
});
it('rsync gets the de-selected paths and noise filters as --exclude args', async () => {
const excluded = await backupService.resolveExcludedBackupPaths({
backup_include_thumbnails: false,
backup_include_archives: false,
});
expect(excluded.map((r) => r.path)).toEqual(
expect.arrayContaining(['thumbnails', 'events/archived'])
);
const args = backupService.buildRsyncArgs(
{ backup_rsync_host: 'backup.example.com', backup_rsync_path: '/srv/backups' },
excluded.map((r) => `/${r.path}/`)
);
const excludes = args
.map((a, i) => (a === '--exclude' ? args[i + 1] : null))
.filter(Boolean);
expect(excludes).toEqual(expect.arrayContaining([
'.nfs*',
'/thumbnails/',
'/events/archived/',
]));
});
it('rows toggled off via include_in_default also become rsync excludes', async () => {
// The enabled-only loader hides these rows from the walker, but rsync
// syncs the whole storage root, so they must still appear as excludes.
await db('backup_paths').where('path', 'previews').update({
include_in_default: false,
});
const excluded = await backupService.resolveExcludedBackupPaths({});
expect(excluded.map((r) => r.path)).toContain('previews');
});
});
// Issue #871 — .nfs* silly-rename artifacts were uploaded to S3.
it('never backs up filesystem noise (.nfs*, .DS_Store)', async () => {
seedFile('thumbnails/E1/.nfs000000000000006600000008');
seedFile('events/active/E1/.DS_Store');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels.some((r) => r.includes('.nfs'))).toBe(false);
expect(rels.some((r) => r.includes('.DS_Store'))).toBe(false);
});
it('the walker honors backup_exclude_patterns (previously rsync-only)', async () => {
seedFile('events/active/E1/photo.jpg');
seedFile('events/active/E1/scratch.tmp');
const files = await backupService.getFilesToBackup({
backup_exclude_patterns: ['*.tmp'],
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('events/active/E1/scratch.tmp');
});
it('glob patterns are literal outside the star (.nfs* must not eat anfs-…)', async () => {
seedFile('events/active/E1/anfs-photo.jpg');
seedFile('events/active/E1/notes-tmp');
const files = await backupService.getFilesToBackup({
backup_exclude_patterns: ['*.tmp'],
});
const rels = files.map((f) => f.relativePath);
// '.nfs*' used to compile to /^.nfs.*$/ whose dot matched any char;
// '*.tmp' used to compile to /^.*.tmp$/ which also matched 'notes-tmp'.
expect(rels).toContain('events/active/E1/anfs-photo.jpg');
expect(rels).toContain('events/active/E1/notes-tmp');
});
// Issue #871 — weekly schedules silently ran daily, and the dashboard's
// "next backup" was a hardcoded "tomorrow 02:00".
describe('schedule resolution + next run (issue #871)', () => {
it('a named label beats the stray default cron the UI used to send', () => {
expect(backupService.resolveScheduleCron({
backup_schedule: 'weekly',
backup_schedule_cron: '0 3 * * *', // old UI default, sent unconditionally
})).toBe('0 3 * * 0');
});
it('custom schedules use the cron field', () => {
expect(backupService.resolveScheduleCron({
backup_schedule: 'custom',
backup_schedule_cron: '15 5 * * 2',
})).toBe('15 5 * * 2');
});
it('falls back to the default daily cron', () => {
expect(backupService.resolveScheduleCron({})).toBe('0 2 * * *');
});
it('getNextScheduledRun is null when backups are disabled', () => {
expect(backupService.getNextScheduledRun(null)).toBeNull();
expect(backupService.getNextScheduledRun({ backup_enabled: false })).toBeNull();
});
it('getNextScheduledRun returns the real next weekly fire time', () => {
const iso = backupService.getNextScheduledRun({
backup_enabled: true,
backup_schedule: 'weekly',
backup_schedule_cron: '0 3 * * *',
});
const next = new Date(iso);
expect(Number.isNaN(next.getTime())).toBe(false);
expect(next.getTime()).toBeGreaterThan(Date.now());
expect(next.getDay()).toBe(0); // Sunday
expect(next.getHours()).toBe(3); // 03:00
});
});
// Issue #871 — "Backup Size: 167.6 TB": file_size_bytes is a bigInteger
// column, node-postgres returns int8 as a string, and the S3 path did
// `backedUpSize += size` — string concatenation.
it('getDatabaseBackupInfo coerces file_size_bytes to a number', async () => {
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
backup_type: 'full',
status: 'completed',
file_path: '/backups/db/dump.sql.gz',
// Simulate the PG int8-as-string driver behaviour (sqlite stores
// whatever it is handed, so the string round-trips).
file_size_bytes: '421988',
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
});
const info = await backupService.getDatabaseBackupInfo();
expect(typeof info.size).toBe('number');
expect(info.size).toBe(421988);
});
});
@@ -34,7 +34,7 @@ jest.mock('../../src/services/databaseBackup', () => ({
DatabaseBackupService: class {},
}));
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('backupService — inline DB dump + fail-loud guard', () => {
let db;
@@ -23,7 +23,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('backupService — per-Stage-B-path statistics', () => {
let db;
@@ -14,7 +14,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('backupService — config + file collection + manifest (smoke)', () => {
let db;
@@ -7,7 +7,7 @@
const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('booking cutover — draft invoices on hold', () => {
let db; let cleanup; let adminId; let customerId; let quoteService;
@@ -0,0 +1,227 @@
/**
* Backfilling captured_at on a library imported before #1172.
*
* The point of the endpoint, rather than a migration: it resolves originals
* through resolvePhotoFilePath, which is the only path that reaches an
* external row. The thumbnail regenerator resolves under
* storage/events/active/<photo.path>, which never exists for those (#1129) —
* so it cannot be the model.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
const sharp = require('sharp');
describe('capture date backfill (#1172)', () => {
let tmpDir; let db; let app; let mediaRoot;
const writeJpegWithExif = async (abs, iso) => {
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, '0');
const exifDate = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} `
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 9, g: 9, b: 9 } } })
.withExif({ IFD2: { DateTimeOriginal: exifDate } }).jpeg().toFile(abs);
};
const settle = async () => { for (let i = 0; i < 60; i++) { await new Promise((r) => setTimeout(r, 50)); const s = await status(); if (!s.body.isRunning) return s; } throw new Error('backfill did not settle'); };
const status = () => request(app).get('/api/admin/photos/repair-capture-dates/status');
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-capfill-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capfill-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seed({ relpath, exifIso, writeFile = true, archived = false }) {
await db('photos').del();
await db('events').del();
const [e] = await db('events').insert({
slug: 'capfill', event_type: 'wedding', event_name: 'capfill', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: `capfill-${Math.random()}`, expires_at: new Date().toISOString(),
source_mode: 'reference', external_path: 'trip', is_archived: archived,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
if (writeFile) await writeJpegWithExif(path.join(mediaRoot, 'trip', relpath), exifIso);
const [p] = await db('photos').insert({
event_id: eventId, filename: path.basename(relpath), path: `capfill/${path.basename(relpath)}`,
// Root-relative, as this branch stores it (#1163) — the file lives at
// <mediaRoot>/trip/<relpath>.
type: 'individual', source_origin: 'external', external_relpath: `trip/${relpath}`,
uploaded_at: new Date().toISOString(), captured_at: null,
}).returning('id');
return { eventId, photoId: typeof p === 'object' ? p.id : p };
}
it('fills captured_at for an external photo the thumbnail regenerator cannot reach', async () => {
const { photoId } = await seed({ relpath: 'a.jpg', exifIso: '2026-06-01T09:45:03Z' });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.status).toBe(200);
expect(res.body.count).toBe(1);
const done = await settle();
expect(done.body.lastResult.success).toBe(1);
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeTruthy();
});
it('counts a photo with no EXIF separately from a failure', async () => {
// "The mount is broken" and "these files carry no date" need different
// answers from an operator, so they are not the same number.
await db('photos').del(); await db('events').del();
const { photoId } = await seed({ relpath: 'plain.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
await sharp({ create: { width: 40, height: 30, channels: 3, background: { r: 1, g: 1, b: 1 } } })
.jpeg().toFile(path.join(mediaRoot, 'trip', 'plain.jpg'));
await request(app).post('/api/admin/photos/repair-capture-dates');
const done = await settle();
expect(done.body.lastResult).toMatchObject({ success: 0, noExif: 1, failed: 0 });
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeNull();
});
it('counts an unreachable original as a failure, not as missing EXIF', async () => {
await seed({ relpath: 'gone.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
await request(app).post('/api/admin/photos/repair-capture-dates');
const done = await settle();
expect(done.body.lastResult).toMatchObject({ success: 0, noExif: 0, failed: 1 });
});
it('reports nothing to do once every photo has a date', async () => {
const { photoId } = await seed({ relpath: 'b.jpg', exifIso: '2026-06-02T09:00:00Z' });
await db('photos').where({ id: photoId }).update({ captured_at: new Date().toISOString() });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
expect((await status()).body.withoutCaptureDate).toBe(0);
});
it('skips a watcher-imported video, which carries media_type "image"', async () => {
// fileWatcher.processNewPhoto sets type='video' and a video/* mime but
// never media_type (fileWatcher.js:128-130), so the row keeps the 'image'
// default from migration 048. Filtering on media_type alone queued it every
// run: extractCaptureDate returns null for a video, captured_at stays null,
// and the backlog never cleared.
const { eventId } = await seed({ relpath: 'clip.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
await db('photos').del();
await db('photos').insert({
event_id: eventId, filename: 'clip.mp4', path: 'capfill/clip.mp4',
type: 'video', media_type: 'image', mime_type: 'video/mp4',
source_origin: 'external', external_relpath: 'trip/clip.mp4',
uploaded_at: new Date().toISOString(), captured_at: null,
});
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
const s = await status();
// And it is not counted as a permanent backlog either.
expect(s.body.total).toBe(0);
expect(s.body.withoutCaptureDate).toBe(0);
});
it('never reports more dated photos than it has photos', async () => {
// Both counts come from one aggregate; as two queries an import committing
// between them produced withCaptureDate > total and a negative backlog.
const { photoId } = await seed({ relpath: 'counted.jpg', exifIso: '2026-06-05T08:00:00Z' });
await db('photos').where({ id: photoId }).update({ captured_at: new Date().toISOString() });
const s = await status();
expect(s.body.total).toBe(1);
expect(s.body.withCaptureDate).toBe(1);
expect(s.body.withoutCaptureDate).toBe(0);
expect(s.body.withoutCaptureDate).toBeGreaterThanOrEqual(0);
});
it('skips archived events instead of failing them on every run', async () => {
// Archiving deletes the originals and keeps the rows, so an archived photo
// can never get a date. Counting it would fail it every pass and leave the
// status endpoint permanently reporting a backlog.
await seed({ relpath: 'archived.jpg', exifIso: '2026-06-04T09:00:00Z', archived: true });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
const s = await status();
expect(s.body.total).toBe(0);
expect(s.body.withoutCaptureDate).toBe(0);
expect(s.body.isRunning).toBe(false);
});
it('does not overwrite a date written while it was running', async () => {
// whereNull on the update: an import or a replacement finishing mid-run has
// already written a better value than this pass would.
const { photoId } = await seed({ relpath: 'c.jpg', exifIso: '2026-06-03T09:00:00Z' });
const claimed = '2020-01-01T00:00:00.000Z';
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(1);
await db('photos').where({ id: photoId }).update({ captured_at: claimed });
const done = await settle();
expect(new Date((await db('photos').where({ id: photoId }).first()).captured_at).toISOString()).toBe(claimed);
expect(done.body.lastResult.success).toBe(0);
// Read but not written, so it is accounted for rather than dropped.
expect(done.body.lastResult.skipped).toBe(1);
});
it('does not date a row whose file was replaced while it was reading (#1201)', async () => {
// replacePhoto swaps a NEW file under an existing row and rewrites
// path/filename (reachable from replace_by_name). The replacement carries
// no date of its own, so captured_at is still NULL and the whereNull guard
// alone would let the previous file's EXIF date land on it. The write is
// fenced on the identity that was read, so the row is skipped instead —
// and not counted as updated either.
const { photoId } = await seed({ relpath: 'orig.jpg', exifIso: '2026-06-03T09:00:00Z' });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(1);
// Simulate the replacement landing before the loop writes.
await db('photos').where({ id: photoId })
.update({ path: 'capfill/replaced.jpg', filename: 'replaced.jpg' });
const done = await settle();
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeNull();
expect(done.body.lastResult.success).toBe(0);
// Not an error and not "no EXIF" — the date was found, another writer just
// got there first. It stays in the backlog for the next run.
expect(done.body.lastResult).toMatchObject({ noExif: 0, failed: 0, skipped: 1 });
});
});
@@ -0,0 +1,211 @@
/**
* Layered per-event category ordering (#782).
*
* Two ordering layers, resolved per event:
* - GLOBAL default — photo_categories.display_order (migration 159),
* set via POST /reorder-global; applies everywhere.
* - PER-EVENT override — event_category_order (migration 160), set via
* POST /reorder; overrides the default for one gallery.
* - DELETE /reorder/:eventId clears an event's override.
*
* Verified against a real SQLite DB with the full core-migration set applied.
*/
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
jest.setTimeout(120000);
describe('category ordering (#782)', () => {
let db;
let cleanup;
let token;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = buildRouteApp('/api/admin/categories', require('../../src/routes/adminCategories'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
const auth = (r) => r.set('Authorization', `Bearer ${token}`);
async function insertEvent(slug) {
await db('events').insert({
event_type: 'wedding', password_hash: 'x',
expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug, share_link: slug,
event_name: slug, event_date: '2026-01-01',
});
return (await db('events').where({ slug }).first()).id;
}
async function insertCat(name, { is_global = false, event_id = null, display_order = 0 } = {}) {
const res = await db('photo_categories').insert({
name,
slug: name.toLowerCase().replace(/\s+/g, '-'),
is_global: is_global ? 1 : 0,
event_id,
display_order,
}).returning('id');
return res[0]?.id ?? res[0];
}
const getEvent = (eventId) => auth(request(app).get(`/api/admin/categories/event/${eventId}`)).expect(200);
describe('migration 159 backfill', () => {
it('seeds display_order from alphabetical order, scoped per event', async () => {
const eventId = await insertEvent('backfill-ev');
await insertCat('Reception', { event_id: eventId });
await insertCat('Ceremony', { event_id: eventId });
await insertCat('Pre-Ceremony', { event_id: eventId });
// Re-run the migration: addColumn is guarded (no-op); the backfill loop
// re-runs and assigns per-scope alphabetical order — what an upgrade does.
await require('../../migrations/core/159_add_category_display_order').up(db);
const evCats = await db('photo_categories').where({ event_id: eventId }).orderBy('display_order', 'asc');
expect(evCats.map((c) => c.name)).toEqual(['Ceremony', 'Pre-Ceremony', 'Reception']);
expect(evCats.map((c) => c.display_order)).toEqual([1, 2, 3]);
});
});
describe('global default order (POST /reorder-global)', () => {
it('reverses the global order and every non-customised event follows it', async () => {
const before = (await auth(request(app).get('/api/admin/categories/global')).expect(200)).body;
expect(before.length).toBeGreaterThan(1);
const reversedIds = before.map((c) => c.id).reverse();
const res = await auth(request(app).post('/api/admin/categories/reorder-global'))
.send({ orderedIds: reversedIds })
.expect(200);
expect(res.body.map((c) => c.id)).toEqual(reversedIds);
// A fresh event (no override) shows globals in the new global order.
const eventId = await insertEvent('follows-global');
const globalsInEvent = (await getEvent(eventId)).body.filter((c) => c.is_global).map((c) => c.id);
expect(globalsInEvent).toEqual(reversedIds);
});
});
describe('per-event override (POST /reorder)', () => {
it('pins a custom order for one event without affecting another', async () => {
const eventA = await insertEvent('override-a');
const eventB = await insertEvent('override-b');
const a1 = await insertCat('A-Ceremony', { event_id: eventA });
const a2 = await insertCat('A-Reception', { event_id: eventA });
// Current resolved list for A (globals + A's two categories).
const listA = (await getEvent(eventA)).body;
// Put A-Reception first, then A-Ceremony, then the globals in their order.
const globalsA = listA.filter((c) => c.is_global).map((c) => c.id);
const desired = [a2, a1, ...globalsA];
const res = await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventA, orderedIds: desired })
.expect(200);
expect(res.body.map((c) => c.id)).toEqual(desired);
// override_position is set on every row for a customised event.
expect(res.body.every((c) => c.override_position != null)).toBe(true);
// Event B is untouched — no override, follows the global default.
const listB = (await getEvent(eventB)).body;
expect(listB.every((c) => c.override_position == null)).toBe(true);
});
it('accepts global ids but rejects another events category', async () => {
const eventId = await insertEvent('scope-ev');
const own = await insertCat('Own', { event_id: eventId });
const global = (await db('photo_categories').where('is_global', 1).first()).id;
const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') });
// A global id is allowed (globals can be arranged per event).
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [own, global] })
.expect(200);
// A foreign event's category is out of scope.
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [own, foreign] })
.expect(400);
});
});
describe('reset (DELETE /reorder/:eventId)', () => {
it('clears the override and reverts to the global default', async () => {
const eventId = await insertEvent('reset-ev');
const c1 = await insertCat('R-One', { event_id: eventId });
const list = (await getEvent(eventId)).body;
const globals = list.filter((c) => c.is_global).map((c) => c.id);
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [c1, ...globals] })
.expect(200);
expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true);
const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200);
expect(res.body.every((c) => c.override_position == null)).toBe(true);
expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined();
});
});
describe('event ownership (PR #790 review)', () => {
let limitedToken;
let foreignEventId;
beforeAll(async () => {
const bcrypt = require('bcrypt');
// A non-super_admin role that DOES hold settings.view + settings.edit —
// the exact case the review flagged (settings.edit is grantable).
const roleRes = await db('roles').insert({ name: 'gallery-mgr', display_name: 'Gallery Mgr' }).returning('id');
const roleId = roleRes[0]?.id ?? roleRes[0];
const permIds = await db('permissions').whereIn('name', ['settings.view', 'settings.edit']).pluck('id');
await db('role_permissions').insert(permIds.map((permission_id) => ({ role_id: roleId, permission_id })));
const a2 = await db('admin_users').insert({
username: 'limited', email: 'limited@example.com',
password_hash: await bcrypt.hash('x', 4), role_id: roleId,
must_change_password: false, created_at: new Date(),
}).returning('id');
limitedToken = mintAdminToken(a2[0]?.id ?? a2[0]);
// An event owned by a DIFFERENT admin (the seeded super_admin).
const owner = (await db('admin_users').where({ username: 'tester' }).first()).id;
await db('events').insert({
event_type: 'wedding', password_hash: 'x',
expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug: 'owned-ev', share_link: 'owned-ev',
event_name: 'Owned', event_date: '2026-01-01', created_by: owner,
});
foreignEventId = (await db('events').where({ slug: 'owned-ev' }).first()).id;
});
const limitedAuth = (r) => r.set('Authorization', `Bearer ${limitedToken}`);
it('blocks a non-owner from reading, reordering or resetting another event', async () => {
await limitedAuth(request(app).get(`/api/admin/categories/event/${foreignEventId}`)).expect(403);
await limitedAuth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: foreignEventId, orderedIds: [1] }).expect(403);
await limitedAuth(request(app).delete(`/api/admin/categories/reorder/${foreignEventId}`)).expect(403);
});
});
describe('POST / (create) appends to the end of its scope', () => {
it('assigns display_order = max + 1 within the event', async () => {
const eventId = await insertEvent('append-ev');
await insertCat('First', { event_id: eventId, display_order: 1 });
await insertCat('Second', { event_id: eventId, display_order: 2 });
const res = await auth(request(app).post('/api/admin/categories'))
.send({ name: 'Third', is_global: false, event_id: eventId })
.expect(200);
expect(res.body.display_order).toBe(3);
});
});
});
@@ -0,0 +1,413 @@
/**
* CRM mint-and-send paths — integration tests (#587).
*
* Pins the three document "mint" flows end-to-end through the real
* HTTP → route → service → DB → email-queue → file pipeline:
*
* 1. POST /api/admin/quotes/:id/send (draft → sent + PDF + token + email)
* 2. POST /api/admin/invoices/:id/cancel (issued → cancelled + Storno row)
* — the issue spec named this /:id/storno; the real route is
* /:id/cancel (invoiceService.cancelInvoice → createStorno).
* 3. POST /api/admin/contracts/:id/countersign
* (signed_by_customer → fully_signed + stamped PDF + sha256 + email)
*
* Real SQLite with the full core-migration run (helpers/crmDb), real
* pdfkit/pdf-lib rendering — no mock-fs, no network.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
// Full migration run + cold-requiring pdfService/emailProcessor is slow
// under CI load; match the other CRM integration suites.
jest.setTimeout(120000);
const CUSTOMER_EMAIL = 'customer@example.com';
// 1x1 transparent PNG — smallest valid signature pad output.
const SIGNATURE_DATA_URL = 'data:image/png;base64,'
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
// SQLite round-trips dates inconsistently (epoch ms number, numeric
// string, or ISO string) — parse robustly before comparing.
const toMillis = (v) => {
if (typeof v === 'number') return v;
if (typeof v === 'string' && /^\d+$/.test(v)) return Number(v);
return Date.parse(v);
};
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
// Count embedded image XObjects per page via pdf-lib — used to prove BOTH
// signature stamps (customer + admin) made it into the final PDF instead of
// only asserting file existence/hash (codex review of #850 round 2).
async function countImagesPerPage(pdfPath) {
const { PDFDocument, PDFName, PDFDict } = require('pdf-lib');
const doc = await PDFDocument.load(fs.readFileSync(pdfPath));
return doc.getPages().map((page) => {
const resources = page.node.Resources();
const xobjects = resources && resources.lookupMaybe(PDFName.of('XObject'), PDFDict);
if (!xobjects) return 0;
let images = 0;
for (const [, ref] of xobjects.entries()) {
const stream = page.doc.context.lookup(ref);
const subtype = stream && stream.dict && stream.dict.get(PDFName.of('Subtype'));
if (subtype && subtype.toString() === '/Image') images += 1;
}
return images;
});
}
let db;
let cleanup;
let tmpDir;
// Real (symlink-resolved) storage root — on macOS os.tmpdir() returns
// /var/... while the services persist under process.cwd() which
// resolves to /private/var/....
let storageRoot;
let adminId;
let customerId;
let token;
let quoteApp;
let invoiceApp;
let contractApp;
let quoteService;
let invoiceService;
let contractService;
const prevCwd = process.cwd();
const auth = { get Authorization() { return `Bearer ${token}`; } };
async function enableFlag(key) {
const updated = await db('feature_flags').where({ key }).update({ value: true });
if (!updated) await db('feature_flags').insert({ key, value: true });
}
// ----- per-path seed helpers -----------------------------------------
async function seedQuote() {
const id = await quoteService.createQuote({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 0,
eventName: 'Testshooting',
lineItems: [
{ position: 1, quantity: 1, description: 'Photo package', unit_price_minor: 150000, discount_percent: 0 },
],
}, adminId);
return id;
}
async function seedIssuedInvoice(status = 'sent') {
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 7.7,
lineItems: [
{ position: 1, quantity: 1, description: 'Wedding coverage', unit_price_minor: 200000, discount_percent: 0 },
],
}, adminId);
const id = invoiceIds[0];
// Fast-forward past the send step — Storno only applies to issued
// documents (sent/paid/overdue), and rendering+sending the original
// is covered by the quote path already.
await db('invoices').where({ id }).update({
status, sent_at: new Date(), updated_at: new Date(),
});
return db('invoices').where({ id }).first();
}
async function seedCustomerSignedContract() {
const id = await contractService.createContract({
customerAccountId: customerId,
title: 'Fotografie-Vertrag',
}, adminId);
// Real send + customer-sign flow (codex review of #850): a direct
// status UPDATE skipped the customer's signature asset and stamped
// PDF, so countersign exercised its unsigned-PDF fallback and a
// regression dropping the customer's signature would stay green.
const { token } = await contractService.sendContract(id, adminId);
await contractService.recordCustomerSignature({
token,
name: 'Custo Mer',
ip: '127.0.0.1',
signatureDataUrl: SIGNATURE_DATA_URL,
accepted: true,
});
return db('contracts').where({ id }).first();
}
// ----- suite ----------------------------------------------------------
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
// Business-doc PDFs (quotes/invoices/contracts) persist under
// `process.cwd()/storage/business-docs/...` — chdir into the temp dir
// so every test artifact lands isolated and gets cleaned up.
process.chdir(tmpDir);
storageRoot = path.join(fs.realpathSync(tmpDir), 'storage', 'business-docs');
// Fail-fast on the pre-existing logActivity-inside-transaction
// deadlock: createContract and createStorno call logActivity() from
// inside a knex transaction WITHOUT passing the trx as executor, so
// the audit insert tries to grab a second connection from the
// single-connection SQLite pool while the trx holds it. In
// production that stalls each call for the full 60 s acquire
// timeout (the error is then swallowed by logActivity's catch);
// here we shrink the timeout so the same swallowed failure costs
// 2 s instead of blowing the per-test budget. Behaviour under test
// is unchanged — the mint paths themselves never wait on this.
db.client.pool.acquireTimeoutMillis = 2000;
// node-sqlite3 detects Date bind params via `InstanceOf(global.Date)`
// against the NATIVE realm's Date — under jest's vm sandbox the
// service code's `new Date()` is a different constructor, the check
// fails, and the value stringifies to the literal "[object Object]"
// (the exact pathology helpers/crmDb.js documents for
// createPublicToken). Normalize Date bindings to ISO strings before
// they reach the driver so the real service inserts round-trip the
// same way they do outside jest.
// Patch on the prototype — knex mints transaction clients via
// Object.create(prototype), so an instance-level patch would miss
// every query issued inside a db.transaction().
const clientProto = Object.getPrototypeOf(db.client);
const origQuery = clientProto._query;
clientProto._query = function patchedQuery(connection, obj) {
if (obj && Array.isArray(obj.bindings)) {
obj.bindings = obj.bindings.map(
(b) => (b && typeof b === 'object' && typeof b.toISOString === 'function' ? b.toISOString() : b),
);
}
return origQuery.call(this, connection, obj);
};
({ adminId, customerId } = await seedMinimal(db));
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
// CRM surfaces are feature-flagged; migration 107 seeds them OFF.
await enableFlag('quotes');
await enableFlag('bills');
await enableFlag('contracts');
quoteService = require('../../src/services/quoteService');
invoiceService = require('../../src/services/invoiceService');
contractService = require('../../src/services/contractService');
quoteApp = buildRouteApp('/api/admin/quotes', require('../../src/routes/adminQuotes'));
invoiceApp = buildRouteApp('/api/admin/invoices', require('../../src/routes/adminInvoices'));
contractApp = buildRouteApp('/api/admin/contracts', require('../../src/routes/adminContracts'));
}, 120000);
afterAll(async () => {
process.chdir(prevCwd);
if (cleanup) await cleanup();
});
describe('POST /api/admin/quotes/:id/send', () => {
test('draft quote: 200 → sent + sent_at + PDF on disk + action token + quote_sent email', async () => {
const quoteId = await seedQuote();
await db('email_queue').del();
const res = await request(quoteApp)
.post(`/api/admin/quotes/${quoteId}/send`)
.set(auth);
expect(res.status).toBe(200);
expect(res.body.sent).toBe(true);
expect(res.body.token).toMatch(/^[0-9a-f]{64}$/);
// DB state
const quote = await db('quotes').where({ id: quoteId }).first();
expect(quote.status).toBe('sent');
expect(quote.sent_at).toBeTruthy();
// PDF persisted inside the isolated storage root
expect(quote.pdf_path).toBeTruthy();
expect(quote.pdf_path.startsWith(path.join(storageRoot, 'quote'))).toBe(true);
expect(fs.existsSync(quote.pdf_path)).toBe(true);
expect(fs.statSync(quote.pdf_path).size).toBeGreaterThan(0);
// Action token row: right quote, future expiry
const tokenRow = await db('quote_action_tokens').where({ token: res.body.token }).first();
expect(tokenRow).toBeTruthy();
expect(tokenRow.quote_id).toBe(quoteId);
expect(toMillis(tokenRow.expires_at)).toBeGreaterThan(Date.now());
// Email queued to the customer's primary address
const emails = await db('email_queue').where({ email_type: 'quote_sent' });
expect(emails).toHaveLength(1);
expect(emails[0].recipient_email).toBe(CUSTOMER_EMAIL);
const emailData = JSON.parse(emails[0].email_data);
expect(emailData.quote_number).toBe(quote.quote_number);
});
test('already-sent quote: 409 (spec said 400; service throws 409)', async () => {
const quoteId = await seedQuote();
await request(quoteApp).post(`/api/admin/quotes/${quoteId}/send`).set(auth).expect(200);
const res = await request(quoteApp)
.post(`/api/admin/quotes/${quoteId}/send`)
.set(auth);
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/cannot send a quote with status 'sent'/i);
});
});
describe('POST /api/admin/invoices/:id/cancel (Storno mint)', () => {
test('sent invoice: original cancelled, Storno row minted with negated totals + lineage', async () => {
const original = await seedIssuedInvoice('sent');
await db('email_queue').del();
const res = await request(invoiceApp)
.post(`/api/admin/invoices/${original.id}/cancel`)
.set(auth);
// Route responds via successResponse default — 200, not the 201
// the issue spec assumed.
expect(res.status).toBe(200);
expect(res.body.cancelled).toBe(true);
expect(res.body.stornoId).toBeGreaterThan(0);
const storno = await db('invoices').where({ id: res.body.stornoId }).first();
expect(storno.kind).toBe('storno');
expect(storno.cancels_invoice_id).toBe(original.id);
expect(storno.deal_uuid).toBe(original.deal_uuid);
// Negated amounts
expect(storno.net_amount_minor).toBe(-original.net_amount_minor);
expect(storno.vat_amount_minor).toBe(-original.vat_amount_minor);
expect(storno.total_amount_minor).toBe(-original.total_amount_minor);
// Freshly sequenced number from the same series
expect(typeof storno.invoice_number).toBe('string');
expect(storno.invoice_number.length).toBeGreaterThan(0);
expect(storno.invoice_number).not.toBe(original.invoice_number);
// Line items snapshotted onto the Storno
const originalItems = await db('invoice_line_items').where({ invoice_id: original.id });
const stornoItems = await db('invoice_line_items').where({ invoice_id: storno.id });
expect(stornoItems).toHaveLength(originalItems.length);
// Original flipped + back-linked
const refreshed = await db('invoices').where({ id: original.id }).first();
expect(refreshed.status).toBe('cancelled');
expect(refreshed.cancellation_storno_id).toBe(storno.id);
// sendStorno side effects (codex review of #850): cancelInvoice
// swallows a sendStorno failure by design, so without these
// assertions a broken render/persist/queue leg would stay green.
const sentStorno = await db('invoices').where({ id: storno.id }).first();
expect(sentStorno.status).toBe('sent');
expect(sentStorno.pdf_path).toBeTruthy();
expect(fs.existsSync(sentStorno.pdf_path)).toBe(true);
const stornoEmails = await db('email_queue').where({ email_type: 'storno_issued' });
expect(stornoEmails.length).toBeGreaterThanOrEqual(1);
expect(stornoEmails[0].recipient_email).toBe(CUSTOMER_EMAIL);
});
test('paid invoice can be cancelled via Storno too (refund document leg)', async () => {
const original = await seedIssuedInvoice('paid');
const res = await request(invoiceApp)
.post(`/api/admin/invoices/${original.id}/cancel`)
.set(auth);
expect(res.status).toBe(200);
expect(res.body.stornoId).toBeGreaterThan(0);
const refreshed = await db('invoices').where({ id: original.id }).first();
expect(refreshed.status).toBe('cancelled');
});
test('already-cancelled invoice: 409 ALREADY_CANCELLED', async () => {
const original = await seedIssuedInvoice('sent');
await request(invoiceApp).post(`/api/admin/invoices/${original.id}/cancel`).set(auth).expect(200);
const res = await request(invoiceApp)
.post(`/api/admin/invoices/${original.id}/cancel`)
.set(auth);
expect(res.status).toBe(409);
expect(res.body.code).toBe('ALREADY_CANCELLED');
});
});
describe('POST /api/admin/contracts/:id/countersign', () => {
test('customer-signed contract: 200 → fully_signed + stamped PDF + sha256 + signature asset + email with attachment', async () => {
const contract = await seedCustomerSignedContract();
await db('email_queue').del();
const res = await request(contractApp)
.post(`/api/admin/contracts/${contract.id}/countersign`)
.set(auth)
.send({ name: 'Admin Tester', signatureDataUrl: SIGNATURE_DATA_URL });
expect(res.status).toBe(200);
expect(res.body.status).toBe('fully_signed');
const row = await db('contracts').where({ id: contract.id }).first();
expect(row.status).toBe('fully_signed');
expect(row.signed_admin_name).toBe('Admin Tester');
expect(row.signed_by_admin_at).toBeTruthy();
// The customer's own signature (from the real sign flow in the seed)
// must survive countersigning — layered, not replaced.
expect(row.signed_customer_signature_path).toBeTruthy();
expect(fs.existsSync(row.signed_customer_signature_path)).toBe(true);
expect(row.signed_customer_name).toBe('Custo Mer');
// Admin signature image persisted under the storage root
expect(row.signed_admin_signature_path).toBeTruthy();
expect(row.signed_admin_signature_path.startsWith(
path.join(storageRoot, 'contract', 'signatures'),
)).toBe(true);
expect(fs.existsSync(row.signed_admin_signature_path)).toBe(true);
// Stamped, fully-signed PDF written and hashed. The issue spec
// called this `integrity_hash`; the real column is
// `signed_pdf_sha256` (plus `pdf_sha256` for the unsigned base).
expect(row.signed_pdf_render_failed_at).toBeFalsy();
expect(row.signed_pdf_path).toBeTruthy();
expect(fs.existsSync(row.signed_pdf_path)).toBe(true);
expect(row.signed_pdf_sha256).toMatch(/^[0-9a-f]{64}$/);
expect(sha256(fs.readFileSync(row.signed_pdf_path))).toBe(row.signed_pdf_sha256);
// BOTH stamps must be embedded in the final document — a regression
// stamping the admin onto the unsigned base PDF would keep every
// path/hash assertion above green (codex review of #850 round 2).
const imagesPerPage = await countImagesPerPage(row.signed_pdf_path);
const maxImagesOnAPage = Math.max(...imagesPerPage);
expect(maxImagesOnAPage).toBeGreaterThanOrEqual(2);
// contract_fully_signed email to the customer's primary address,
// carrying the signed PDF as attachment (plus the audit cert).
const emails = await db('email_queue').where({ email_type: 'contract_fully_signed' });
const customerCopy = emails.find((e) => e.recipient_email === CUSTOMER_EMAIL);
expect(customerCopy).toBeTruthy();
const emailData = JSON.parse(customerCopy.email_data);
expect(emailData.contract_number).toBe(contract.contract_number);
expect(Array.isArray(emailData.attachments)).toBe(true);
const pdfAttachment = emailData.attachments.find(
(a) => a.filename === `${contract.contract_number}-signed.pdf`,
);
expect(pdfAttachment).toBeTruthy();
expect(pdfAttachment.contentType).toBe('application/pdf');
expect(fs.existsSync(pdfAttachment.contentPath)).toBe(true);
});
test('draft contract: 409 — countersign requires sent/signed_by_customer', async () => {
const draftId = await contractService.createContract({
customerAccountId: customerId,
title: 'Noch nicht versendet',
}, adminId);
const res = await request(contractApp)
.post(`/api/admin/contracts/${draftId}/countersign`)
.set(auth)
.send({ name: 'Admin Tester' });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/cannot counter-sign a contract with status 'draft'/i);
});
});
@@ -14,7 +14,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService,
// nodemailer, etc.) on first use; the global 5 s per-test budget is
// too tight for that. Bump it for this file only.
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('discount line items (negative unit_price_minor)', () => {
let db;
@@ -0,0 +1,256 @@
/**
* Download resolutions (#858).
*
* Pins the contracts that are easy to break later:
*
* - the global → per-event cascade, including NULL = inherit
* - the picker never offers a size ABOVE the standard (a photographer who
* lowers the standard is not silently handing out full-res), and 'Original'
* only reappears when the admin explicitly allows it
* - `fit: 'inside'` + no-upscaling resize semantics, which is exactly what
* the requester asked for on the issue
* - a guest-supplied resolution is validated against the policy rather than
* trusted
*/
const sharp = require('sharp');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Both modules under test pull in src/database/db.js transitively. bootCrmDb
// only works when it runs BEFORE the first require of db.js (it sets
// TEST_DATABASE_PATH, which knexfile reads at module-init time), so these are
// required lazily in beforeAll rather than at module scope — otherwise knex
// binds to the shared default SQLite file and every run after the first one
// fails with "table `migrations` already exists".
let resolveEventDownloadPolicy;
let pickRequestedResolution;
let parseResolution;
let invalidateDownloadGlobals;
let resizeToBox;
describe('Download resolutions (#858)', () => {
let db;
let cleanup;
const setGlobal = async (key, value) => {
await db('app_settings').where({ setting_key: key }).del();
await db('app_settings').insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'download',
updated_at: new Date().toISOString(),
});
invalidateDownloadGlobals();
};
const PRESETS = [
{ label: 'Large', width: 3000, height: 2000 },
{ label: 'Medium', width: 1500, height: 1000 },
{ label: 'Small', width: 800, height: 600 },
];
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
({
resolveEventDownloadPolicy,
pickRequestedResolution,
parseResolution,
invalidateDownloadGlobals,
} = require('../../src/utils/downloadResolutions'));
({ resizeToBox } = require('../../src/services/imageProcessor'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await setGlobal('download_resolutions', PRESETS);
await setGlobal('download_standard_resolution', 'original');
await setGlobal('download_resolution_picker_enabled', false);
await setGlobal('download_allow_original', false);
});
describe('cascade', () => {
it('inherits the global standard when the event has no override', async () => {
await setGlobal('download_standard_resolution', '1500x1000');
const policy = await resolveEventDownloadPolicy({ download_standard_resolution: null });
expect(policy.standard).toBe('1500x1000');
expect(policy.standardBox).toEqual({ width: 1500, height: 1000 });
});
it('lets an event override the global standard', async () => {
await setGlobal('download_standard_resolution', '1500x1000');
const policy = await resolveEventDownloadPolicy({ download_standard_resolution: '800x600' });
expect(policy.standard).toBe('800x600');
});
it('treats a NULL picker flag as inherit and an explicit false as override', async () => {
await setGlobal('download_resolution_picker_enabled', true);
expect((await resolveEventDownloadPolicy({ download_resolution_picker_enabled: null })).pickerEnabled).toBe(true);
expect((await resolveEventDownloadPolicy({ download_resolution_picker_enabled: false })).pickerEnabled).toBe(false);
});
});
describe('choice list', () => {
it('never offers a size larger than the standard', async () => {
await setGlobal('download_standard_resolution', '1500x1000');
const { choices } = await resolveEventDownloadPolicy({});
expect(choices.map((c) => c.id)).toEqual(['1500x1000', '800x600']);
// The regression that matters: 3000x2000 must not be reachable.
expect(choices.some((c) => c.id === '3000x2000')).toBe(false);
});
it('bounds EACH dimension, not the pixel area (codex review round 2)', async () => {
// 2000x700 is 1.4MP — under 1500x1000's 1.5MP — so an area comparison
// would offer it and hand back a 2000px-wide file despite a 1500px cap.
await setGlobal('download_resolutions', [
...PRESETS,
{ label: 'Wide', width: 2000, height: 700 },
]);
await setGlobal('download_standard_resolution', '1500x1000');
const { choices } = await resolveEventDownloadPolicy({});
expect(choices.some((c) => c.id === '2000x700')).toBe(false);
});
it('omits Original when the standard is capped and the admin has not allowed it', async () => {
await setGlobal('download_standard_resolution', '1500x1000');
const { choices } = await resolveEventDownloadPolicy({});
expect(choices.some((c) => c.id === 'original')).toBe(false);
});
it('re-adds Original when the admin explicitly allows it', async () => {
await setGlobal('download_standard_resolution', '1500x1000');
await setGlobal('download_allow_original', true);
const { choices } = await resolveEventDownloadPolicy({});
expect(choices[0].id).toBe('original');
});
it('offers Original when the standard already is original', async () => {
const { choices } = await resolveEventDownloadPolicy({});
expect(choices[0].id).toBe('original');
expect(choices.map((c) => c.id)).toContain('3000x2000');
});
});
describe('request validation', () => {
it('falls back to the standard when nothing is requested', async () => {
await setGlobal('download_standard_resolution', '1500x1000');
const policy = await resolveEventDownloadPolicy({});
expect(pickRequestedResolution(policy, undefined)).toBe('1500x1000');
});
it('refuses any explicit request while the picker is off', async () => {
const policy = await resolveEventDownloadPolicy({});
expect(policy.pickerEnabled).toBe(false);
expect(pickRequestedResolution(policy, '800x600')).toBeNull();
});
it('refuses a size that is not on the offered list', async () => {
await setGlobal('download_resolution_picker_enabled', true);
await setGlobal('download_standard_resolution', '1500x1000');
const policy = await resolveEventDownloadPolicy({});
// Above the standard → not offered → rejected rather than silently served.
expect(pickRequestedResolution(policy, '3000x2000')).toBeNull();
expect(pickRequestedResolution(policy, '9999x9999')).toBeNull();
expect(pickRequestedResolution(policy, '800x600')).toBe('800x600');
});
it('parses only well-formed resolution ids', () => {
expect(parseResolution('original')).toBeNull();
expect(parseResolution(null)).toBeNull();
expect(parseResolution('abc')).toBeNull();
expect(parseResolution('0x0')).toBeNull();
expect(parseResolution('1500x1000')).toEqual({ width: 1500, height: 1000 });
});
});
describe('job dedup identity (codex review round 1)', () => {
// The leak this pins: a PIN client's archive contains hidden photos. If the
// dedup key ignored the visibility scope, a guest asking for the same size
// would be handed the client's job token — and the delivery route only
// checked the event id.
let jobService;
beforeAll(() => {
jobService = require('../../src/services/downloadJobService');
});
it('separates client and guest archives of the same size and photo set', () => {
const guest = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'public');
const client = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'hidden');
expect(guest).not.toBe(client);
});
it('keys on the RESOLVED photo set, so a stale archive is not reused', () => {
const before = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'public');
const afterUpload = jobService.dedupKey(1, '1500x1000', [1, 2, 3, 4], false, 'public');
const afterHide = jobService.dedupKey(1, '1500x1000', [1, 2], false, 'public');
expect(new Set([before, afterUpload, afterHide]).size).toBe(3);
});
it('is order-independent for the same set', () => {
expect(jobService.dedupKey(1, 'original', [3, 1, 2], true, 'public'))
.toBe(jobService.dedupKey(1, 'original', [1, 2, 3], true, 'public'));
});
it('maps access levels onto the two visibility scopes', () => {
expect(jobService.visibilityScopeFor('client')).toBe('hidden');
expect(jobService.visibilityScopeFor('guest')).toBe('public');
expect(jobService.visibilityScopeFor(undefined)).toBe('public');
});
});
describe('resize semantics', () => {
const make = (w, h) => sharp({
create: { width: w, height: h, channels: 3, background: { r: 10, g: 100, b: 200 } },
}).jpeg().toBuffer();
const box = { width: 1500, height: 1000 };
it('fits a 3:2 photo exactly into a 3:2 box', async () => {
const out = await sharp(await resizeToBox(await make(6000, 4000), box)).metadata();
expect([out.width, out.height]).toEqual([1500, 1000]);
});
it('treats the box as an "up to" bound for other aspect ratios', async () => {
// Portrait: height is the binding edge, width comes out smaller.
const portrait = await sharp(await resizeToBox(await make(4000, 6000), box)).metadata();
expect(portrait.height).toBe(1000);
expect(portrait.width).toBeLessThan(1500);
const fourThree = await sharp(await resizeToBox(await make(4000, 3000), box)).metadata();
expect(fourThree.height).toBe(1000);
expect(fourThree.width).toBeLessThan(1500);
});
it('never upscales an image already smaller than the box', async () => {
const out = await sharp(await resizeToBox(await make(800, 600), box)).metadata();
expect([out.width, out.height]).toEqual([800, 600]);
});
it('passes the buffer through untouched for the original size', async () => {
const src = await make(4000, 3000);
expect(await resizeToBox(src, null)).toBe(src);
});
it('keeps the source format so the filename and mime type stay honest', async () => {
// A .gif re-encoded as JPEG would ship mislabelled bytes, since the
// download routes keep the original filename and mime type.
const gif = await sharp({
create: { width: 4000, height: 3000, channels: 3, background: { r: 1, g: 2, b: 3 } },
}).gif().toBuffer();
const out = await sharp(await resizeToBox(gif, box)).metadata();
expect(out.format).toBe('gif');
expect(out.width).toBe(1333);
});
it('returns the input rather than throwing on an undecodable source', async () => {
const junk = Buffer.from('not an image');
expect(await resizeToBox(junk, box)).toBe(junk);
});
});
});
@@ -0,0 +1,50 @@
/**
* Catalog-driven event-type defaults (#800 follow-up).
*
* The contract→event conversion used to hardcode `event_type: 'wedding'` and
* the v1 API validated against a fixed whitelist. Both now follow the live
* event_types catalog; these tests pin the shared resolver.
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('resolveDefaultEventType follows the catalog', () => {
let db;
let cleanup;
let eventTypeService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Require AFTER bootCrmDb so the service shares this db instance
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
eventTypeService = require('../../src/services/eventTypeService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it("prefers the 'other' catch-all while it is active", async () => {
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
});
it('falls over to the first active type when other is deactivated', async () => {
const other = await db('event_types').where({ slug_prefix: 'other' }).first();
await db('event_types').where({ id: other.id }).update({ is_active: 0 });
const resolved = await eventTypeService.resolveDefaultEventType();
expect(resolved).not.toBe('other');
expect(await db('event_types').where({ slug_prefix: resolved }).first()).toBeTruthy();
await db('event_types').where({ id: other.id }).update({ is_active: 1 });
});
it("returns the literal 'other' only for an empty catalog", async () => {
const rows = await db('event_types').select('*');
await db('event_types').del();
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
await db('event_types').insert(rows);
});
});
@@ -6,7 +6,7 @@
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll.
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('event type slug rename cascade', () => {
let db;
@@ -0,0 +1,133 @@
/**
* Setup-window event type deletion (#800).
*
* The first-run setup wizard may delete the seeded SYSTEM event types —
* but ONLY while the `setup_wizard_completed` flag is unset (migration 161
* seeds it false on a fresh install, true when an admin already exists).
* These tests pin the whole contract:
*
* - fresh install → flag false → system types deletable (in-use checks
* still apply), and the per-type reminder template goes with the type
* - reminder-template self-heal does NOT resurrect templates for slugs
* that no longer exist in the catalog
* - after markSetupWizardCompleted() → system deletion is refused again
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('event type deletion during the setup window (#800)', () => {
let db;
let cleanup;
let eventTypeService;
let setupService;
let ensureEventReminderTemplatesSeeded;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Require AFTER bootCrmDb so every service shares this db instance
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
eventTypeService = require('../../src/services/eventTypeService');
setupService = require('../../src/services/setupService');
({ ensureEventReminderTemplatesSeeded } = require('../../src/services/eventReminderTemplates'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('migration 161 seeds the flag false on a fresh (admin-less) install', async () => {
const row = await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).first();
expect(row).toBeTruthy();
expect(JSON.parse(row.setting_value)).toBe(false);
expect(await setupService.isSetupWizardCompleted()).toBe(false);
});
it('refuses to delete a system type that events already use, even in the window', async () => {
const corporate = await db('event_types').where({ slug_prefix: 'corporate' }).first();
await db('events').insert({
slug: 'corporate-test-2026-01-01',
event_name: 'Test',
event_type: 'corporate',
event_date: '2026-01-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: 'share-corporate-test',
expires_at: new Date(Date.now() + 86400000),
});
await expect(eventTypeService.deleteEventType(corporate.id))
.rejects.toMatchObject({ code: 'IN_USE' });
});
it('deletes an unused system type in the window, taking its reminder template along', async () => {
// Seed the per-type reminder templates first so there is something to clean up.
await ensureEventReminderTemplatesSeeded(db);
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeTruthy();
const wedding = await db('event_types').where({ slug_prefix: 'wedding' }).first();
expect(wedding.is_system).toBeTruthy();
const result = await eventTypeService.deleteEventType(wedding.id);
expect(result.success).toBe(true);
expect(await db('event_types').where({ slug_prefix: 'wedding' }).first()).toBeFalsy();
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
// The deleted slug must NOT stay creatable through the legacy fallback —
// the live catalog is authoritative while it has rows.
expect(await eventTypeService.isValidEventType('wedding')).toBe(false);
expect(await eventTypeService.isValidEventType('birthday')).toBe(true);
});
it('does not resurrect reminder templates for deleted types on the next self-heal pass', async () => {
// The seeder caches success per process — reset the module to force a
// genuine second pass, exactly what a backend restart would run.
jest.resetModules();
const fresh = require('../../src/services/eventReminderTemplates');
await fresh.ensureEventReminderTemplatesSeeded(db);
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
// Types still in the catalog keep their templates.
expect(await db('email_templates').where({ template_key: 'event_reminder_birthday' }).first()).toBeTruthy();
expect(await db('email_templates').where({ template_key: 'event_reminder_default' }).first()).toBeTruthy();
});
it('re-locks system types once the wizard is marked complete', async () => {
await setupService.markSetupWizardCompleted();
expect(await setupService.isSetupWizardCompleted()).toBe(true);
const birthday = await db('event_types').where({ slug_prefix: 'birthday' }).first();
await expect(eventTypeService.deleteEventType(birthday.id))
.rejects.toMatchObject({ code: 'SYSTEM_TYPE' });
// Custom (non-system) types remain deletable as before.
const custom = await eventTypeService.createEventType({ name: 'Family', slug_prefix: 'family' });
const result = await eventTypeService.deleteEventType(custom.id);
expect(result.success).toBe(true);
});
it('fails closed when the completion marker row is missing', async () => {
// A portable-backup restore can replace app_settings with a set that
// predates migration 161 (which will not rerun) — absence must mean
// "configured instance", never an open deletion window.
await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
expect(await setupService.isSetupWizardCompleted()).toBe(true);
await setupService.markSetupWizardCompleted();
});
it('refuses to delete the last remaining event type', async () => {
// Reduce the catalog to a single custom type via direct db writes (the
// service paths are already covered above), then hit the guard.
const solo = await eventTypeService.createEventType({ name: 'Solo', slug_prefix: 'solo' });
await db('events').del();
await db('event_types').whereNot('id', solo.id).del();
await expect(eventTypeService.deleteEventType(solo.id))
.rejects.toMatchObject({ code: 'LAST_TYPE' });
// Deactivating it would empty the ACTIVE catalog just the same.
await expect(eventTypeService.updateEventType(solo.id, { is_active: false }))
.rejects.toMatchObject({ code: 'LAST_ACTIVE' });
});
});
@@ -0,0 +1,161 @@
/**
* External imports must record captured_at (#1172).
*
* Managed uploads get it from photoProcessor, which external media never goes
* through — so every externally imported photo carried captured_at NULL, and
* the gallery's "Date Taken" sort fell back to uploaded_at through its
* COALESCE. On a library imported in two batches that ordered a 12-day trip by
* which folder was imported first: the reporter's first two days landed at
* positions 4204-5296 of 5555.
*
* Driven through the real route against real files carrying real EXIF, because
* the whole question is whether the import reads the file it already has open.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
const sharp = require('sharp');
describe('external import capture dates (#1172)', () => {
let tmpDir; let db; let app; let mediaRoot;
/**
* A real JPEG carrying DateTimeOriginal.
*
* IFD2, not IFD0 — DateTimeOriginal lives in the Exif IFD, and exifr does not
* see it anywhere else (IFD0 takes plain DateTime, which surfaces as
* ModifyDate instead).
*/
const writeJpegWithExif = async (rel, iso) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, '0');
const exifDate = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} `
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 10, g: 20, b: 30 } } })
.withExif({ IFD2: { DateTimeOriginal: exifDate } })
.jpeg()
.toFile(full);
return full;
};
const writeJpegNoExif = async (rel) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 200, g: 10, b: 10 } } })
.jpeg().toFile(full);
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-capdate-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capdate-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
jest.doMock('../../src/services/imageProcessor', () => {
const actual = jest.requireActual('../../src/services/imageProcessor');
return { ...actual, generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'), ensureThumbnail: jest.fn() };
});
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent() {
await db('photos').del();
await db('events').del();
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
const [e] = await db('events').insert({
slug: `capdate-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding', event_name: 'capdate', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: `capdate-${Math.random()}`, expires_at: new Date().toISOString(),
source_mode: 'reference',
}).returning('id');
return typeof e === 'object' ? e.id : e;
}
const runImport = (eventId, external_path) => request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path, recursive: true });
it('records the EXIF capture date on import', async () => {
const eventId = await seedEvent();
await writeJpegWithExif('trip/a.jpg', '2026-06-01T09:45:03Z');
await runImport(eventId, 'trip');
const photo = await db('photos').where({ event_id: eventId }).first();
expect(photo.captured_at).toBeTruthy();
// NOT asserted as an absolute instant. EXIF carries a naive wall-clock
// time and exifr resolves it against the HOST timezone, so the stored UTC
// value differs between a CEST developer machine and a UTC runner. What
// this fix is about is that the field is populated and orders correctly;
// that captured_at is not a true instant is a separate, pre-existing
// problem shared with managed uploads (#1172's own footnote).
expect(new Date(photo.captured_at).getUTCFullYear()).toBe(2026);
expect(new Date(photo.captured_at).getUTCMonth()).toBe(5); // June
});
it('imports a photo with no EXIF date rather than failing it', async () => {
// Plenty of sources carry none; that must stay an import, not an error.
const eventId = await seedEvent();
await writeJpegNoExif('trip/plain.jpg');
const res = await runImport(eventId, 'trip');
expect(res.body.imported).toBe(1);
const photo = await db('photos').where({ event_id: eventId }).first();
expect(photo.captured_at).toBeNull();
});
it('orders a two-batch import by capture time, not by batch', async () => {
// The reported shape: the FIRST days of the trip imported second. Sorting
// on COALESCE(captured_at, uploaded_at) put them after the last days,
// because uploaded_at is the import timestamp.
const eventId = await seedEvent();
await writeJpegWithExif('late/day12.jpg', '2026-06-12T10:00:00Z');
await runImport(eventId, 'late');
await writeJpegWithExif('early/day01.jpg', '2026-06-01T10:00:00Z');
await runImport(eventId, 'early');
const rows = await db('photos')
.where({ event_id: eventId })
.orderByRaw('COALESCE(captured_at, uploaded_at) asc')
.select('filename');
expect(rows.map((r) => r.filename)).toEqual(['day01.jpg', 'day12.jpg']);
});
});
@@ -0,0 +1,209 @@
/**
* Two overlapping external imports insert every file twice (#1162).
*
* The route checked for an existing external_relpath and then inserted, with
* an fs.stat and a `sharp().metadata()` read sitting in between. A reporter
* double-clicked a slow import of a 6012-file tree and got 8004 rows.
*
* Both halves of the fix are driven here through the real route:
*
* - the in-flight guard, which turns the second click into a 409 instead of
* a second full walk of the tree;
* - convergence when the guard cannot help (another replica, another
* process), which is the unique index from migration 186 firing and the
* loop counting a skip rather than dying or duplicating.
*
* The second is exercised by inserting a competing row from inside the mocked
* `sharp().metadata()` call — literally inside the window the bug lived in.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('concurrent external imports (#1162)', () => {
let tmpDir; let db; let app; let mediaRoot;
// When set, the mocked sharp metadata read inserts this row first — the
// other run winning the race between our SELECT and our INSERT.
let stealDuringMetadata = null;
let thumbnailDelayMs = 0;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extdup-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) {
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', name), 'not-a-real-jpeg');
}
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extdup-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
// The window. In production this is a real decode of a NAS-hosted file —
// hundreds of milliseconds during which the row we just proved absent can
// appear. Standing in for the other run here makes that deterministic.
jest.doMock('sharp', () => () => ({
metadata: async () => {
if (stealDuringMetadata) {
const { db: liveDb } = require('../../src/database/db');
await liveDb('photos').insert(stealDuringMetadata);
stealDuringMetadata = null;
}
return { width: 100, height: 200 };
},
}));
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(async () => {
if (thumbnailDelayMs) await new Promise((r) => setTimeout(r, thumbnailDelayMs));
return 'thumbnails/mock.jpg';
}),
ensureThumbnail: jest.fn(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent() {
await db('photos').del();
await db('events').del();
stealDuringMetadata = null;
thumbnailDelayMs = 0;
const [e] = await db('events').insert({
slug: `extdup-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'extdup',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `extdup-${Math.random()}`,
expires_at: new Date().toISOString(),
source_mode: 'reference',
}).returning('id');
return typeof e === 'object' ? e.id : e;
}
const runImport = (eventId) => request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path: 'nas', recursive: true });
async function relpathCounts(eventId) {
const rows = await db('photos').where({ event_id: eventId }).select('external_relpath');
const counts = new Map();
for (const r of rows) counts.set(r.external_relpath, (counts.get(r.external_relpath) || 0) + 1);
return counts;
}
it('rejects a second import while the first is still running', async () => {
const eventId = await seedEvent();
// Enough to keep the first request inside its loop while the second
// arrives — the "slow import looks hung, so I clicked again" case.
thumbnailDelayMs = 20;
const [first, second] = await Promise.all([runImport(eventId), runImport(eventId)]);
const statuses = [first.status, second.status].sort();
expect(statuses).toEqual([200, 409]);
const rejected = first.status === 409 ? first : second;
expect(rejected.body.error).toMatch(/already running/i);
});
it('leaves exactly one row per file after both runs', async () => {
const eventId = await seedEvent();
thumbnailDelayMs = 20;
await Promise.all([runImport(eventId), runImport(eventId)]);
const counts = await relpathCounts(eventId);
expect(counts.size).toBe(3);
expect([...counts.values()]).toEqual([1, 1, 1]);
});
it('releases the event once the import finishes, so a re-import still works', async () => {
const eventId = await seedEvent();
expect((await runImport(eventId)).status).toBe(200);
// Not 409 — the guard is per run, not a permanent lock on the event.
const second = await runImport(eventId);
expect(second.status).toBe(200);
expect(second.body.imported).toBe(0);
expect(second.body.skipped).toBe(3);
});
it('converges when another writer wins the race mid-file', async () => {
// The guard is in-process, so it cannot see a second replica. This is what
// the unique index is for: the insert bounces, and the file is counted as
// skipped rather than duplicated or lost to a 500.
const eventId = await seedEvent();
stealDuringMetadata = {
event_id: eventId,
filename: 'a.jpg',
path: 'x/a.jpg',
type: 'individual',
source_origin: 'external',
// Root-relative, as the route now writes it (#1163) — the competing
// writer has to target the same value for the race to be real.
external_relpath: path.join('nas', 'individual', 'a.jpg'),
};
const res = await runImport(eventId);
expect(res.status).toBe(200);
const counts = await relpathCounts(eventId);
expect(counts.get(path.join('nas', 'individual', 'a.jpg'))).toBe(1);
// Two imported by us, one lost to the other writer and reported honestly.
expect(res.body.imported).toBe(2);
expect(res.body.skipped).toBe(1);
});
it('does not let one contended file abort the rest of the import', async () => {
const eventId = await seedEvent();
stealDuringMetadata = {
event_id: eventId,
filename: 'a.jpg',
path: 'x/a.jpg',
type: 'individual',
source_origin: 'external',
// Root-relative, as the route now writes it (#1163) — the competing
// writer has to target the same value for the race to be real.
external_relpath: path.join('nas', 'individual', 'a.jpg'),
};
await runImport(eventId);
// All three files present — the contended one via the other writer's row.
expect((await relpathCounts(eventId)).size).toBe(3);
});
});
@@ -0,0 +1,175 @@
/**
* Importing a second folder must not move the photos already in the event (#1163).
*
* events.external_path is overwritten by every import, and external_relpath
* used to be stored relative to it — so a second import silently rebased every
* existing row onto the new folder. The reporter had 7547 of 8004 originals
* pointing at files that do not exist, and nothing said so: thumbnails are
* written to local storage during the import while the base path is still
* correct, so the grid carries on rendering.
*
* Driven through the real route and the real resolver, against a real
* directory tree — the failure is entirely about whether a file is where the
* app looks for it.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('a second external import (#1163)', () => {
let tmpDir; let db; let app; let mediaRoot; let resolvePhotoFilePath;
const touch = async (rel) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await fs.promises.writeFile(full, 'not-a-real-jpeg');
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-ext2nd-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'ext2nd-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
jest.doMock('sharp', () => () => ({ metadata: async () => ({ width: 100, height: 200 }) }));
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'),
ensureThumbnail: jest.fn(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
({ resolvePhotoFilePath } = require('../../src/services/photoResolver'));
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent() {
await db('photos').del();
await db('events').del();
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
const [e] = await db('events').insert({
slug: `ext2nd-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'ext2nd',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `ext2nd-${Math.random()}`,
expires_at: new Date().toISOString(),
source_mode: 'reference',
}).returning('id');
return typeof e === 'object' ? e.id : e;
}
const runImport = (eventId, external_path) => request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path, recursive: true });
/** Where the app would go looking for this photo's original, right now. */
async function resolved(eventId, filename) {
const event = await db('events').where({ id: eventId }).first();
const photo = await db('photos').where({ event_id: eventId, filename }).first();
return resolvePhotoFilePath(event, photo);
}
it('stores paths relative to the media root, not to the imported folder', async () => {
const eventId = await seedEvent();
await touch('Trip/Leknes/old.jpg');
await runImport(eventId, 'Trip');
const photo = await db('photos').where({ event_id: eventId }).first();
expect(photo.external_relpath).toBe(path.join('Trip', 'Leknes', 'old.jpg'));
});
it('leaves the first folders originals reachable after a second import', async () => {
const eventId = await seedEvent();
await touch('Trip/Leknes/old.jpg');
await touch('Trip/Sub/new.jpg');
await runImport(eventId, 'Trip');
const before = await resolved(eventId, 'old.jpg');
await runImport(eventId, 'Trip/Sub');
const after = await resolved(eventId, 'old.jpg');
// The regression: `after` used to be <root>/Trip/Sub/Leknes/old.jpg.
expect(after).toBe(before);
expect(fs.existsSync(after)).toBe(true);
});
it('every original in the event is still on disk afterwards', async () => {
const eventId = await seedEvent();
await touch('Trip/Leknes/a.jpg');
await touch('Trip/Leknes/b.jpg');
await touch('Trip/Sub/c.jpg');
await runImport(eventId, 'Trip');
await runImport(eventId, 'Trip/Sub');
const event = await db('events').where({ id: eventId }).first();
const photos = await db('photos').where({ event_id: eventId });
expect(photos).toHaveLength(3);
for (const photo of photos) {
expect(fs.existsSync(resolvePhotoFilePath(event, photo))).toBe(true);
}
});
it('does not re-insert a file the first import already took', async () => {
// The dedupe check compares stored paths, so it has to be comparing the
// same shape the insert writes.
const eventId = await seedEvent();
await touch('Trip/Sub/c.jpg');
await runImport(eventId, 'Trip');
const second = await runImport(eventId, 'Trip/Sub');
expect(second.body.imported).toBe(0);
expect(second.body.skipped).toBe(1);
expect(await db('photos').where({ event_id: eventId }).count('* as c').first()).toEqual({ c: 1 });
});
it('resolves a subfolder that repeats its parents name', async () => {
// The old resolver stripped the relpath's first segment when it matched the
// base path's last one, which broke exactly this layout.
const eventId = await seedEvent();
await touch('Trip/Trip/x.jpg');
await runImport(eventId, 'Trip');
const event = await db('events').where({ id: eventId }).first();
const photo = await db('photos').where({ event_id: eventId }).first();
expect(resolvePhotoFilePath(event, photo)).toBe(path.join(mediaRoot, 'Trip', 'Trip', 'x.jpg'));
});
});
@@ -0,0 +1,121 @@
/**
* PostgreSQL integration test for the external-path fold (#1163).
*
* Gated the same way as picpeakRestorePg: runs only when PICPEAK_PG_TEST_URL
* points at a throwaway Postgres DB, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_fold_test" \
* npx jest __tests__/integration/externalRelpathFoldPg.test.js
*
* This exists because of a defect SQLite could not have caught. The two-pass
* rewrite parks each row on a temporary value, and that value was first written
* with a leading NUL. SQLite stores NUL in TEXT without complaint; Postgres
* rejects it outright ("invalid byte sequence for encoding UTF8"), so migration
* 187 would have rolled back on exactly the installs needing the repair — and
* only on the engine most of them run.
*
* The staging value is therefore an engine-level contract, not an
* implementation detail, and it is pinned here on the engine that constrains it.
*/
const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('external relpath fold on Postgres', () => {
let pgDb; let mediaRoot; let fold;
const touch = async (rel, bytes) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await fs.promises.writeFile(full, Buffer.alloc(bytes));
return bytes;
};
beforeAll(async () => {
mediaRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-foldpg-'));
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
jest.resetModules();
({ foldExternalRelpaths: fold } = require('../../src/services/externalRelpathFold'));
pgDb = knex({ client: 'pg', connection: PG_URL });
}, 60000);
afterAll(async () => {
if (pgDb) await pgDb.destroy();
await fs.promises.rm(mediaRoot, { recursive: true, force: true }).catch(() => {});
delete process.env.EXTERNAL_MEDIA_ROOT;
});
beforeEach(async () => {
await pgDb.raw('DROP TABLE IF EXISTS photos, events, app_settings CASCADE');
await pgDb.schema.createTable('events', (t) => {
t.increments('id');
t.text('external_path');
});
await pgDb.schema.createTable('photos', (t) => {
t.increments('id');
t.integer('event_id');
t.text('external_relpath');
t.bigInteger('size_bytes');
t.string('source_origin').defaultTo('managed');
});
await pgDb.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key');
t.text('setting_value');
t.string('setting_type');
t.string('updated_at');
});
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
});
const relpaths = async () =>
(await pgDb('photos').orderBy('id').select('external_relpath')).map((r) => r.external_relpath);
it('completes the two-pass repair that a NUL staging value would abort', async () => {
// The exact shape that forces staging: `photo.jpg` repairs up to
// `Trip/photo.jpg`, while the row already holding `Trip/photo.jpg` folds
// deeper. Every final value is distinct, but a final value equals another
// row's current one, so the rewrite has to park first.
const a = await touch('Trip/photo.jpg', 11);
const b = await touch('Trip/Sub/Trip/photo.jpg', 22);
await pgDb('events').insert({ id: 1, external_path: 'Trip/Sub' });
await pgDb('photos').insert([
{ event_id: 1, external_relpath: 'photo.jpg', size_bytes: a, source_origin: 'external' },
{ event_id: 1, external_relpath: 'Trip/photo.jpg', size_bytes: b, source_origin: 'external' },
]);
await fold(pgDb);
expect(await relpaths()).toEqual(['Trip/photo.jpg', 'Trip/Sub/Trip/photo.jpg']);
});
it('leaves no staging value behind', async () => {
await touch('Trip/a.jpg', 8);
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
await fold(pgDb);
const rows = await relpaths();
expect(rows).toEqual(['Trip/a.jpg']);
expect(rows.some((r) => r.includes('staging'))).toBe(false);
});
it('folds and marks in one transaction', async () => {
await touch('Trip/a.jpg', 8);
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
await fold(pgDb);
// Second run is a no-op: the marker committed with the rewrites.
await fold(pgDb);
expect(await relpaths()).toEqual(['Trip/a.jpg']);
});
});
@@ -0,0 +1,217 @@
/**
* Auto-category rule engine (#1074 phase 3).
*
* The rules themselves are simple enough to read. What needs testing is the
* promise around them: this engine may only ever fill an EMPTY category, and
* everything it touches must be reversible. A photographer's own assignment
* is a decision; this is a heuristic, and the heuristic never wins.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-autocat-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'autocat-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let engine;
async function seedEvent(slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/** A scanned photo with `faceCount` faces, each `faceSide` px square. */
async function addScannedPhoto(eventId, faceCount, { faceSide = 400, categoryId = null } = {}) {
const [p] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
width: 1000,
height: 1000,
processing_status: 'complete',
face_status: 'done',
face_count: faceCount,
category_id: categoryId,
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
for (let i = 0; i < faceCount; i++) {
await db('photo_faces').insert({
photo_id: photoId,
event_id: eventId,
bbox_x: 10, bbox_y: 10, bbox_w: faceSide, bbox_h: faceSide,
det_score: 0.95,
model_version: 'test-v1',
created_at: new Date().toISOString(),
});
}
return photoId;
}
async function enable(on) {
const existing = await db('app_settings')
.where('setting_key', 'face_auto_categorize_enabled').first();
if (existing) {
await db('app_settings')
.where('setting_key', 'face_auto_categorize_enabled')
.update({ setting_value: JSON.stringify(on) });
}
}
async function categoryOf(photoId) {
const photo = await db('photos').where({ id: photoId }).first();
if (!photo.category_id) return null;
const cat = await db('photo_categories').where({ id: photo.category_id }).first();
return cat?.slug ?? null;
}
describe('faceAutoCategories (#1074 phase 3)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
engine = require('../../src/services/faceAutoCategories');
await enable(true);
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('rules', () => {
it('sorts by face count, and by face size for portraits', async () => {
const eventId = await seedEvent('rules');
// 400px face in a 1000x1000 frame = 16% of the frame, over the 8% floor.
const portrait = await addScannedPhoto(eventId, 1, { faceSide: 400 });
const details = await addScannedPhoto(eventId, 0);
const small = await addScannedPhoto(eventId, 3);
const group = await addScannedPhoto(eventId, 9);
await engine.categorizeEvent(eventId);
expect(await categoryOf(details)).toBe('details');
expect(await categoryOf(portrait)).toBe('portraits');
expect(await categoryOf(small)).toBe('small-groups');
expect(await categoryOf(group)).toBe('groups');
});
it('does not call a distant single face a portrait', async () => {
// One person in a wide landscape is not a portrait of them. 60px in a
// 1000x1000 frame is 0.36% — far below the 8% floor.
const eventId = await seedEvent('small-face');
const distant = await addScannedPhoto(eventId, 1, { faceSide: 60 });
await engine.categorizeEvent(eventId);
expect(await categoryOf(distant)).toBeNull();
});
it('ignores photos that have not been scanned', async () => {
const eventId = await seedEvent('unscanned');
const [p] = await db('photos').insert({
event_id: eventId, filename: 'u.jpg', path: '/tmp/u.jpg', type: 'individual',
processing_status: 'complete', face_status: 'pending',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
await engine.categorizeEvent(eventId);
expect(await categoryOf(photoId)).toBeNull();
});
});
describe('the promise', () => {
it('NEVER overwrites a category a person chose', async () => {
// The single most important behaviour in this file.
const eventId = await seedEvent('no-overwrite');
const [c] = await db('photo_categories').insert({
name: 'Ceremony', slug: 'ceremony', is_global: false, event_id: eventId,
created_at: new Date().toISOString(),
}).returning('id');
const ceremonyId = typeof c === 'object' ? c.id : c;
// 9 faces — the rules would call this "groups" if they were allowed to.
const claimed = await addScannedPhoto(eventId, 9, { categoryId: ceremonyId });
await engine.categorizeEvent(eventId);
expect(await categoryOf(claimed)).toBe('ceremony');
const row = await db('photos').where({ id: claimed }).first();
expect(row.auto_categorized).toBeFalsy();
});
it('marks only what it assigned, so undo is exact', async () => {
const eventId = await seedEvent('undo');
const [c] = await db('photo_categories').insert({
name: 'Ceremony', slug: 'ceremony-2', is_global: false, event_id: eventId,
created_at: new Date().toISOString(),
}).returning('id');
const ceremonyId = typeof c === 'object' ? c.id : c;
const manual = await addScannedPhoto(eventId, 4, { categoryId: ceremonyId });
const auto = await addScannedPhoto(eventId, 4);
await engine.categorizeEvent(eventId);
expect(await categoryOf(auto)).toBe('small-groups');
const result = await engine.undoEvent(eventId);
expect(result.cleared).toBe(1);
// The automatic one is cleared...
expect(await categoryOf(auto)).toBeNull();
// ...and the photographer's own choice survives untouched.
expect(await categoryOf(manual)).toBe('ceremony-2');
});
it('is a no-op while the setting is off', async () => {
const eventId = await seedEvent('disabled');
const photoId = await addScannedPhoto(eventId, 0);
await enable(false);
const result = await engine.categorizeEvent(eventId);
await enable(true);
expect(result.skipped).toBe(true);
expect(await categoryOf(photoId)).toBeNull();
});
it('is idempotent — a second run assigns nothing new', async () => {
const eventId = await seedEvent('idempotent');
await addScannedPhoto(eventId, 0);
await addScannedPhoto(eventId, 7);
const first = await engine.categorizeEvent(eventId);
const second = await engine.categorizeEvent(eventId);
expect(first.assigned).toBe(2);
expect(second.assigned).toBe(0);
});
it('reuses one category per slug rather than creating duplicates', async () => {
const eventId = await seedEvent('reuse');
await addScannedPhoto(eventId, 0);
await addScannedPhoto(eventId, 0);
await addScannedPhoto(eventId, 0);
await engine.categorizeEvent(eventId);
const details = await db('photo_categories')
.where({ slug: 'details' })
.where(function () { this.where('event_id', eventId).orWhere('is_global', true); });
expect(details).toHaveLength(1);
});
});
});
@@ -0,0 +1,325 @@
/**
* Clustering engine (#1074).
*
* Uses synthetic embeddings with known identities rather than real faces: the
* question here is whether the ALGORITHM groups vectors correctly, which is
* separable from whether the model produces good vectors. Model quality is
* the spike's job.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-faceclust-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceclust-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let clustering;
/** Deterministic unit vector for identity `id`, jittered by `variant`. */
function makeEmbedding(id, variant = 0, dim = 64) {
const vec = new Float32Array(dim);
for (let i = 0; i < dim; i++) {
vec[i] = Math.sin((i + 1) * (id + 1) * 0.7) + variant * 0.02 * Math.cos(i * 3.1);
}
let norm = 0;
for (let i = 0; i < dim; i++) norm += vec[i] * vec[i];
norm = Math.sqrt(norm);
for (let i = 0; i < dim; i++) vec[i] /= norm;
return vec;
}
async function seedEvent(slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function insertFace(eventId, embedding, overrides = {}) {
const [p] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
const row = {
photo_id: photoId,
event_id: eventId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
det_score: 0.99,
embedding: clustering.packEmbedding(embedding),
model_version: 'test-v1',
created_at: new Date().toISOString(),
...overrides,
};
const [f] = await db('photo_faces').insert(row).returning('id');
return { ...row, id: typeof f === 'object' ? f.id : f };
}
describe('faceClustering (#1074)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
clustering = require('../../src/services/faceClustering');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('embedding round-trip', () => {
it('survives pack/unpack through the BLOB column exactly', async () => {
const original = makeEmbedding(1);
const eventId = await seedEvent('roundtrip');
const face = await insertFace(eventId, original);
const stored = await db('photo_faces').where({ id: face.id }).first();
const restored = clustering.unpackEmbedding(stored.embedding);
expect(restored).toHaveLength(original.length);
for (let i = 0; i < original.length; i++) {
expect(restored[i]).toBeCloseTo(original[i], 6);
}
});
it('returns null for a corrupt blob rather than throwing', () => {
expect(clustering.unpackEmbedding(Buffer.from([1, 2, 3]))).toBeNull();
expect(clustering.unpackEmbedding(null)).toBeNull();
});
});
describe('assignment', () => {
it('groups the same identity and separates different ones', async () => {
const eventId = await seedEvent('grouping');
const faces = [];
// Three identities, four shots each, interleaved so assignment order
// is not conveniently grouped.
for (let variant = 0; variant < 4; variant++) {
for (const identity of [1, 2, 3]) {
faces.push(await insertFace(eventId, makeEmbedding(identity, variant)));
}
}
await clustering.assignFaces(eventId, faces);
const people = await db('event_people').where({ event_id: eventId });
expect(people).toHaveLength(3);
// Every face of one identity must share a person id.
const rows = await db('photo_faces').where({ event_id: eventId }).select('id', 'person_id');
const byPerson = new Map();
for (const r of rows) {
byPerson.set(r.person_id, (byPerson.get(r.person_id) || 0) + 1);
}
expect([...byPerson.values()].sort()).toEqual([4, 4, 4]);
});
it('leaves low-quality faces unassigned instead of spawning junk people', async () => {
const eventId = await seedEvent('quality-floor');
const good = await insertFace(eventId, makeEmbedding(5));
// Tiny bbox — below the 40px floor.
const tiny = await insertFace(eventId, makeEmbedding(6), { bbox_w: 12, bbox_h: 12 });
// Weak detection score.
const weak = await insertFace(eventId, makeEmbedding(7), { det_score: 0.2 });
await clustering.assignFaces(eventId, [good, tiny, weak]);
const rows = await db('photo_faces')
.whereIn('id', [good.id, tiny.id, weak.id])
.select('id', 'person_id');
const map = Object.fromEntries(rows.map((r) => [r.id, r.person_id]));
expect(map[good.id]).not.toBeNull();
// Still stored — they show in "this photo contains" — just unassigned.
expect(map[tiny.id]).toBeNull();
expect(map[weak.id]).toBeNull();
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
});
it('never mixes embedding spaces from different model versions', async () => {
const eventId = await seedEvent('model-version');
const a = await insertFace(eventId, makeEmbedding(9), { model_version: 'v1' });
await clustering.assignFaces(eventId, [a]);
// Same vector, different model. Comparable numerically, meaningless
// semantically — it must NOT join the v1 cluster.
const b = await insertFace(eventId, makeEmbedding(9), { model_version: 'v2' });
await clustering.assignFaces(eventId, [b]);
const people = await db('event_people').where({ event_id: eventId });
expect(people).toHaveLength(2);
});
});
describe('merge and split', () => {
it('merge moves every face and removes the source person', async () => {
const eventId = await seedEvent('merge');
const f1 = await insertFace(eventId, makeEmbedding(11));
const f2 = await insertFace(eventId, makeEmbedding(21));
await clustering.assignFaces(eventId, [f1, f2]);
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
expect(people).toHaveLength(2);
await clustering.mergePeople(eventId, [people[1].id], people[0].id);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
const remaining = await db('event_people').where({ event_id: eventId }).first();
expect(remaining.face_count_total).toBe(2);
const orphaned = await db('photo_faces')
.where({ event_id: eventId }).whereNull('person_id');
expect(orphaned).toHaveLength(0);
});
it('split pulls the named faces into a new person', async () => {
const eventId = await seedEvent('split');
const faces = [];
for (let v = 0; v < 4; v++) faces.push(await insertFace(eventId, makeEmbedding(13, v)));
await clustering.assignFaces(eventId, faces);
const person = await db('event_people').where({ event_id: eventId }).first();
expect(person.face_count_total).toBe(4);
const newId = await clustering.splitPerson(eventId, person.id, [faces[0].id, faces[1].id]);
expect(newId).toBeTruthy();
const original = await db('event_people').where({ id: person.id }).first();
const created = await db('event_people').where({ id: newId }).first();
expect(original.face_count_total).toBe(2);
expect(created.face_count_total).toBe(2);
});
it('deletes a person left with no faces rather than keeping a ghost', async () => {
const eventId = await seedEvent('empty-person');
const f = await insertFace(eventId, makeEmbedding(15));
await clustering.assignFaces(eventId, [f]);
const person = await db('event_people').where({ event_id: eventId }).first();
await db('photo_faces').where({ id: f.id }).update({ person_id: null });
await clustering.recomputeCentroid(person.id);
expect(await db('event_people').where({ id: person.id }).first()).toBeUndefined();
});
});
describe('regressions from external review', () => {
it('merge carries a name and suppression onto the survivor', async () => {
// A merge used to move the faces and delete the source outright, so a
// photographer-entered name vanished and a person they had hidden came
// back guest-visible.
const eventId = await seedEvent('merge-metadata');
const a = await insertFace(eventId, makeEmbedding(61));
const b = await insertFace(eventId, makeEmbedding(62));
await clustering.assignFaces(eventId, [a, b]);
const [p1, p2] = await db('event_people').where({ event_id: eventId }).orderBy('id');
// Target is unnamed and visible; the SOURCE carries the human state.
await db('event_people').where({ id: p2.id }).update({ label: 'Anna', is_hidden: true });
await clustering.mergePeople(eventId, [p2.id], p1.id);
const survivor = await db('event_people').where({ id: p1.id }).first();
expect(survivor.label).toBe('Anna');
expect(!!survivor.is_hidden).toBe(true);
});
it('recluster keeps hidden/ignored on people that were never named', async () => {
// The old query remembered only rows with a label, so a suppressed
// bystander came back visible after one "Re-group people".
const eventId = await seedEvent('recluster-suppression');
const faces = [];
for (let v = 0; v < 3; v++) faces.push(await insertFace(eventId, makeEmbedding(71, v)));
await clustering.assignFaces(eventId, faces);
const person = await db('event_people').where({ event_id: eventId }).first();
expect(person.label).toBeNull();
await db('event_people').where({ id: person.id }).update({ is_ignored: true });
await clustering.recluster(eventId);
const after = await db('event_people').where({ event_id: eventId });
expect(after.length).toBeGreaterThan(0);
expect(after.every((p) => !!p.is_ignored)).toBe(true);
});
});
describe('recluster', () => {
it('re-derives clusters and preserves photographer-assigned names', async () => {
// This is the property that makes re-clustering safe to offer as a
// button: without it, one click silently discards every typed name.
const eventId = await seedEvent('recluster');
const faces = [];
for (let v = 0; v < 3; v++) {
faces.push(await insertFace(eventId, makeEmbedding(31, v)));
faces.push(await insertFace(eventId, makeEmbedding(32, v)));
}
await clustering.assignFaces(eventId, faces);
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
expect(people).toHaveLength(2);
await db('event_people').where({ id: people[0].id }).update({ label: 'Anna' });
await db('event_people').where({ id: people[1].id }).update({ label: 'Ben' });
const count = await clustering.recluster(eventId);
expect(count).toBe(2);
const after = await db('event_people').where({ event_id: eventId });
const labels = after.map((p) => p.label).filter(Boolean).sort();
expect(labels).toEqual(['Anna', 'Ben']);
});
it('is stable across repeated runs', async () => {
const eventId = await seedEvent('recluster-stable');
const faces = [];
for (let v = 0; v < 3; v++) {
for (const id of [41, 42]) faces.push(await insertFace(eventId, makeEmbedding(id, v)));
}
await clustering.assignFaces(eventId, faces);
const first = await clustering.recluster(eventId);
const second = await clustering.recluster(eventId);
expect(second).toBe(first);
});
});
describe('consolidate', () => {
it('refuses to merge two people the photographer named differently', async () => {
// A human assertion this heuristic does not get to overrule.
const eventId = await seedEvent('consolidate-labels');
const a = await insertFace(eventId, makeEmbedding(51));
await clustering.assignFaces(eventId, [a]);
const first = await db('event_people').where({ event_id: eventId }).first();
// A near-identical centroid that would otherwise merge.
const [inserted] = await db('event_people').insert({
event_id: eventId,
centroid: clustering.packEmbedding(makeEmbedding(51, 0.01)),
face_count_total: 1,
model_version: 'test-v1',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id');
const secondId = typeof inserted === 'object' ? inserted.id : inserted;
await db('event_people').where({ id: first.id }).update({ label: 'Anna' });
await db('event_people').where({ id: secondId }).update({ label: 'Ben' });
await clustering.consolidate(eventId);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
});
});
});
@@ -0,0 +1,239 @@
/**
* External imports are queued for face scanning, in the right order (#1090).
*
* Managed uploads are enqueued by photoProcessor, which writes face_status
* 'pending' once a photo is processed (photoProcessor.js:573 — "the only
* correct place to enqueue"). External media never goes through photoProcessor:
* adminExternalMedia inserts rows directly, so they stayed NULL and were only
* ever picked up by a manual Re-scan.
*
* The ordering matters as much as the enqueue. events.external_path is written
* only AFTER the whole import loop, so marking rows 'pending' as they are
* inserted publishes claimable work while the event still points at the old
* directory — or none at all, on a first import. The face worker polls
* continuously, would resolve those photos against the wrong path, and mark
* them permanently 'failed', a state only an explicit Re-scan clears.
*
* This drives the real route rather than re-implementing it, so removing the
* enqueue fails the first test and moving it back onto the insert fails the
* second.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('external import queues faces (#1090)', () => {
let tmpDir; let db; let app; let mediaRoot;
// Recorded from inside the per-photo thumbnail call, i.e. mid-loop.
let pendingSeenDuringLoop = 0;
let externalPathDuringLoop;
// When set to an event id, the mocked thumbnail call turns detection on
// mid-loop, standing in for an admin flipping the toggle during an import.
let flipFacesOnDuringLoop = null;
// Stands in for a concurrent Re-scan completing a row mid-import.
let markDoneDuringLoop = false;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extenq-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) {
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', name), 'not-a-real-jpeg');
}
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extenq-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
// Runs once per photo, inside the import loop — the only hook that can
// observe the intermediate state the ordering bug would expose.
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(async () => {
const { db: liveDb } = require('../../src/database/db');
const rows = await liveDb('photos').where({ face_status: 'pending' });
pendingSeenDuringLoop += rows.length;
const ev = await liveDb('events').first();
externalPathDuringLoop = ev ? ev.external_path : undefined;
if (markDoneDuringLoop) {
const rows = await liveDb('photos').orderBy('id', 'asc').limit(1);
if (rows.length) {
await liveDb('photos').where({ id: rows[0].id }).update({ face_status: 'done' });
}
}
if (flipFacesOnDuringLoop) {
await liveDb('events').where({ id: flipFacesOnDuringLoop })
.update({ face_recognition_enabled: true });
}
return 'thumbnails/mock.jpg';
}),
ensureThumbnail: jest.fn(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
// bootCrmDb runs every migrations/core/*.up() directly — knex's Migrator
// deadlocks on 001_init's nested initializeDatabase() call.
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent({ facesEnabled, flagOn }) {
await db('feature_flags').insert({ key: 'faces', value: flagOn })
.onConflict('key').merge()
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: flagOn }); });
// The flag read is TTL-cached (requireFeatureFlag.js:26-34); production
// invalidates after every write, and so must this.
require('../../src/middleware/requireFeatureFlag').invalidateFeatureFlagCache();
await db('photos').del();
await db('events').del();
const [e] = await db('events').insert({
slug: `extenq-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'extenq',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `extenq-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: facesEnabled,
source_mode: 'reference',
}).returning('id');
pendingSeenDuringLoop = 0;
externalPathDuringLoop = undefined;
markDoneDuringLoop = false;
return typeof e === 'object' ? e.id : e;
}
async function runImport(eventId) {
return request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path: 'nas', recursive: true });
}
it('queues imported photos when detection is on', async () => {
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
const res = await runImport(eventId);
expect(res.status).toBe(200);
const photos = await db('photos').where({ event_id: eventId });
expect(photos.length).toBeGreaterThan(0);
// The regression: these stayed NULL and waited for a manual Re-scan.
expect(photos.every((p) => p.face_status === 'pending')).toBe(true);
});
it('does not publish claimable rows before events.external_path is written', async () => {
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
await runImport(eventId);
// Observed from inside the loop: nothing is claimable yet. The event path
// is already committed (see the test above), so this is no longer load
// bearing for correctness — but keeping the enqueue at the end is what lets
// the feature setting be read after the loop, so the invariant stays.
expect(pendingSeenDuringLoop).toBe(0);
// ...and afterwards both are in place.
const ev = await db('events').where({ id: eventId }).first();
expect(ev.external_path).toBe('nas');
expect((await db('photos').where({ event_id: eventId, face_status: 'pending' })).length)
.toBe((await db('photos').where({ event_id: eventId })).length);
});
it('honours a toggle flipped DURING the import', async () => {
// The setting is read after the loop, not before: on a large library the
// loop runs for minutes, and the toggle endpoint only queues rows that
// already existed when it fired. Reading it up front would strand every
// photo imported after that moment at NULL forever.
const eventId = await seedEvent({ facesEnabled: false, flagOn: true });
flipFacesOnDuringLoop = eventId;
await runImport(eventId);
flipFacesOnDuringLoop = null;
const photos = await db('photos').where({ event_id: eventId });
expect(photos.length).toBeGreaterThan(0);
expect(photos.every((p) => p.face_status === 'pending')).toBe(true);
});
it('commits events.external_path before the first row is inserted', async () => {
// enqueueEvent accepts processing_status NULL (faceProcessor.js:243-246),
// which these inserts leave unset — so a toggle or Re-scan firing mid-import
// can queue partial rows. If the event still pointed at the old directory
// they would resolve against it and burn to 'failed'. Setting the path
// first also means a half-finished import leaves rows that still resolve,
// instead of rows stranded against the previous path.
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
await runImport(eventId);
// Sampled from inside the per-photo thumbnail call, i.e. while rows are
// still being inserted.
expect(externalPathDuringLoop).toBe('nas');
});
it('does not re-queue rows a concurrent scan already handled', async () => {
// Committing the event path before the loop means a toggle or Re-scan
// firing mid-import can now genuinely queue and even finish some of these
// rows. A blanket update at the end would drag 'done' rows back to
// 'pending' for a duplicate sidecar scan and knock 'processing' rows out
// from under the worker.
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
markDoneDuringLoop = true;
await runImport(eventId);
markDoneDuringLoop = false;
const done = await db('photos').where({ event_id: eventId, face_status: 'done' });
expect(done.length).toBeGreaterThan(0); // the concurrent scan's work survived
});
it('leaves face_status untouched when the per-event toggle is off', async () => {
const eventId = await seedEvent({ facesEnabled: false, flagOn: true });
await runImport(eventId);
const photos = await db('photos').where({ event_id: eventId });
expect(photos.length).toBeGreaterThan(0);
expect(photos.every((p) => p.face_status === null)).toBe(true);
});
it('leaves face_status untouched when the global flag is off', async () => {
// Installs without the feature must never accumulate face_status rows —
// the same invariant photoProcessor's guard protects.
const eventId = await seedEvent({ facesEnabled: true, flagOn: false });
await runImport(eventId);
const photos = await db('photos').where({ event_id: eventId });
expect(photos.length).toBeGreaterThan(0);
expect(photos.every((p) => p.face_status === null)).toBe(true);
});
});
@@ -0,0 +1,163 @@
/**
* External / reference photos are scannable (#1090).
*
* faceProcessor used to short-circuit every photo with source_origin
* 'external' or 'reference' to 'skipped', because resolvePhotoStorageKey
* returns null for anything outside managed storage and ensurePreviewImage
* could not build a preview for it. #1078 removed that limitation —
* ensurePreviewImage now reads externals straight off the mount and writes
* the preview into managed storage — but the guard stayed, so the whole
* feature was a no-op on external-media installs. The reporter's gallery sat
* at 0/3230 with every row 'skipped' and no error.
*
* These pin both halves: the guard is gone, and a photo whose source is
* genuinely missing still fails rather than being quietly skipped — the blanket
* skip used to absorb that case too, so a real breakage looked like an
* unsupported one.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-faceext-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceext-test-secret';
// A real, existing media root. getExternalMediaRoot only honours the env var
// if the directory exists and caches it on first call, so this has to be set
// up before anything requires externalMediaService.
process.env.EXTERNAL_MEDIA_ROOT = path.join(path.dirname(process.env.TEST_DATABASE_PATH), 'media');
fs.mkdirSync(path.join(process.env.EXTERNAL_MEDIA_ROOT, 'share', 'individual'), { recursive: true });
// Non-empty on purpose: an empty directory is read as an unmounted share
// (faceTransientSource.test.js), so a "healthy storage, dead photo" fixture
// needs a sibling present or it defers instead of failing.
fs.writeFileSync(path.join(process.env.EXTERNAL_MEDIA_ROOT, 'share', 'individual', 'sibling.jpg'), 'x');
const sharp = require('sharp');
let mockPreviewBuffer;
// Set per-test: what ensurePreviewImage returns for the photo under test.
let previewKeyResult; // eslint-disable-line prefer-const
const mockEnsurePreviewImage = jest.fn(async () => previewKeyResult);
const mockDetectFaces = jest.fn();
jest.mock('../../src/services/imageProcessor', () => ({
...jest.requireActual('../../src/services/imageProcessor'),
ensurePreviewImage: (...args) => mockEnsurePreviewImage(...args),
}));
jest.mock('../../src/services/storage', () => ({
getStorage: () => ({ get: async () => mockPreviewBuffer }),
}));
jest.mock('../../src/services/faceClient', () => ({
detectFaces: (...args) => mockDetectFaces(...args),
SidecarUnavailableError: class extends Error {},
}));
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let faceProcessor;
async function seedPhoto({ sourceOrigin = 'managed', sourceMode = 'managed' } = {}) {
const [e] = await db('events').insert({
slug: `ext-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'ext',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `ext-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
source_mode: sourceMode,
external_path: 'share',
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const [p] = await db('photos').insert({
event_id: eventId,
filename: 'ext.jpg',
path: '/tmp/ext.jpg',
type: 'individual',
width: 1920,
height: 1440,
processing_status: 'complete',
face_status: 'processing',
source_origin: sourceOrigin,
external_relpath: sourceOrigin === 'managed' ? null : 'individual/ext.jpg',
}).returning('id');
return { eventId, photoId: typeof p === 'object' ? p.id : p };
}
describe('face scanning of external/reference photos (#1090)', () => {
beforeAll(async () => {
mockPreviewBuffer = await sharp({
create: { width: 1920, height: 1440, channels: 3, background: { r: 20, g: 40, b: 80 } },
}).jpeg().toBuffer();
({ db, cleanup } = await bootCrmDb());
await db('feature_flags').insert({ key: 'faces', value: true })
.onConflict('key').merge()
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
faceProcessor = require('../../src/services/faceProcessor');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(() => {
mockEnsurePreviewImage.mockClear();
mockDetectFaces.mockClear();
previewKeyResult = 'previews/preview_ext.jpg';
mockDetectFaces.mockResolvedValue({
model_version: 'test-v1',
faces: [{
bbox: [100, 100, 50, 50],
score: 0.99,
landmarks: [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]],
yaw: 0, pitch: 0, blur: 500,
embedding: Array.from({ length: 64 }, (_, i) => (i === 0 ? 1 : 0)),
}],
});
});
it.each(['external', 'reference'])('scans a %s photo instead of skipping it', async (origin) => {
const { photoId } = await seedPhoto({ sourceOrigin: origin, sourceMode: 'reference' });
const result = await faceProcessor.processPhotoFaces(photoId);
// The regression: this used to return 'skipped' without ever building a
// preview or contacting the sidecar.
expect(result.status).not.toBe('skipped');
expect(mockEnsurePreviewImage).toHaveBeenCalled();
expect(mockDetectFaces).toHaveBeenCalled();
const photo = await db('photos').where({ id: photoId }).first();
expect(photo.face_status).toBe('done');
expect(await db('photo_faces').where({ photo_id: photoId }).first()).toBeTruthy();
});
it('fails, not skips, when the external source is genuinely gone', async () => {
// A missing file is a property of that photo, so it should be visible as a
// failure the admin can act on — not silently absorbed the way the old
// blanket skip did.
//
// The containing directory exists here on purpose. An absent directory is
// a dropped mount, which defers rather than fails
// (faceTransientSource.test.js); this is the other case — healthy storage,
// dead photo.
previewKeyResult = null;
const { photoId } = await seedPhoto({ sourceOrigin: 'external', sourceMode: 'reference' });
const result = await faceProcessor.processPhotoFaces(photoId);
expect(result.status).toBe('failed');
expect(mockDetectFaces).not.toHaveBeenCalled();
const photo = await db('photos').where({ id: photoId }).first();
expect(photo.face_status).toBe('failed');
expect(photo.face_error).toMatch(/preview/i);
});
});
@@ -0,0 +1,452 @@
/**
* Automatic consolidation reporting and the suggestion band (#1107).
*
* Centroids are built to an EXACT cosine similarity rather than jittered
* towards one, because every assertion here is about which side of a threshold
* a pair falls on. `pairAtSimilarity` returns two unit vectors whose dot
* product is the requested number to floating-point precision, and each pair
* is built on its own orthogonal basis so two different pairs are never
* accidentally similar to each other.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-facesuggest-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facesuggest-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let clustering;
// Mirrors the service: merge at match + 0.08, so with a 0.60 floor the
// suggestion band is [0.60, 0.68).
const THRESHOLDS = {
face_match_threshold: 0.6,
face_quality_min_score: 0.7,
face_quality_min_px: 40,
};
const DIM = 64;
/** Two unit vectors whose dot product is exactly `target`, on basis (i, i+1). */
function pairAtSimilarity(target, basis) {
const a = new Float32Array(DIM);
const b = new Float32Array(DIM);
const orth = Math.sqrt(1 - target * target);
a[basis] = 1;
b[basis] = target;
b[basis + 1] = orth;
return [a, b];
}
async function seedEvent(slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function insertPerson(eventId, centroid, overrides = {}) {
const [row] = await db('event_people').insert({
event_id: eventId,
centroid: clustering.packEmbedding(centroid),
face_count_total: 5,
model_version: 'test-v1',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/** One person with one real face, so merge/split have something to move. */
async function insertPersonWithFace(eventId, centroid, overrides = {}) {
const personId = await insertPerson(eventId, centroid, overrides);
const [p] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
await db('photo_faces').insert({
photo_id: photoId,
event_id: eventId,
person_id: personId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
det_score: 0.99,
embedding: clustering.packEmbedding(centroid),
model_version: 'test-v1',
created_at: new Date().toISOString(),
});
return personId;
}
/** An additional face on an existing person, so a split has something to move. */
async function addFaceTo(eventId, personId, centroid) {
const [p] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
const [f] = await db('photo_faces').insert({
photo_id: photoId,
event_id: eventId,
person_id: personId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
det_score: 0.99,
embedding: clustering.packEmbedding(centroid),
model_version: 'test-v1',
created_at: new Date().toISOString(),
}).returning('id');
return typeof f === 'object' ? f.id : f;
}
const suggest = (eventId) => clustering.suggestMerges(eventId, { thresholds: THRESHOLDS });
describe('face merge suggestions (#1107)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
clustering = require('../../src/services/faceClustering');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('the band', () => {
it('suggests a pair between the match and auto-merge thresholds', async () => {
const eventId = await seedEvent('band-inside');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
const out = await suggest(eventId);
expect(out).toHaveLength(1);
expect([out[0].person_a_id, out[0].person_b_id].sort()).toEqual([idA, idB].sort());
expect(out[0].score).toBeCloseTo(0.64, 4);
});
it('stays silent above the auto-merge threshold — consolidate() owns that pair', async () => {
const eventId = await seedEvent('band-above');
const [a, b] = pairAtSimilarity(0.75, 0);
await insertPerson(eventId, a);
await insertPerson(eventId, b);
expect(await suggest(eventId)).toEqual([]);
});
it('stays silent below the match threshold — further apart than one face would join', async () => {
const eventId = await seedEvent('band-below');
const [a, b] = pairAtSimilarity(0.5, 0);
await insertPerson(eventId, a);
await insertPerson(eventId, b);
expect(await suggest(eventId)).toEqual([]);
});
});
describe('what it refuses to ask about', () => {
it('never questions two people the photographer named differently', async () => {
const eventId = await seedEvent('named-apart');
const [a, b] = pairAtSimilarity(0.64, 0);
await insertPerson(eventId, a, { label: 'Anna' });
await insertPerson(eventId, b, { label: 'Beatrix' });
expect(await suggest(eventId)).toEqual([]);
});
it('still asks when only one of the two is named', async () => {
const eventId = await seedEvent('one-named');
const [a, b] = pairAtSimilarity(0.64, 0);
await insertPerson(eventId, a, { label: 'Anna' });
await insertPerson(eventId, b);
expect(await suggest(eventId)).toHaveLength(1);
});
it('skips a person marked "not a real person" — that answer was already given', async () => {
const eventId = await seedEvent('ignored');
const [a, b] = pairAtSimilarity(0.64, 0);
await insertPerson(eventId, a);
await insertPerson(eventId, b, { is_ignored: true });
expect(await suggest(eventId)).toEqual([]);
});
it('never crosses embedding spaces', async () => {
const eventId = await seedEvent('model-skew');
const [a, b] = pairAtSimilarity(0.64, 0);
await insertPerson(eventId, a);
await insertPerson(eventId, b, { model_version: 'test-v2' });
expect(await suggest(eventId)).toEqual([]);
});
});
describe('dismissal', () => {
it('stops suggesting a pair the photographer rejected, and survives a repeat', async () => {
const eventId = await seedEvent('dismissal');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
expect(await suggest(eventId)).toHaveLength(1);
await clustering.dismissMergeSuggestion(eventId, idB, idA); // reversed on purpose
expect(await suggest(eventId)).toEqual([]);
// A second dismissal hits the UNIQUE constraint. Dismissing twice is a
// double-click, not an error.
await expect(clustering.dismissMergeSuggestion(eventId, idA, idB)).resolves.toEqual({
dismissed: true,
});
expect(await suggest(eventId)).toEqual([]);
});
/**
* The swallow-the-duplicate branch has to discriminate, because the failure
* it must NOT swallow looks identical to the caller: returning
* "kept separate" for a decision that was never written means the pair
* silently comes back after the next scan.
*
* Tested on the predicate directly — provoking a read-only database or a
* dropped table mid-suite would corrupt the shared fixture for every other
* case in this file.
*/
it.each([
['postgres unique violation', { code: '23505', message: 'duplicate key value violates unique constraint' }, true],
['sqlite3 unique violation', { code: 'SQLITE_CONSTRAINT', message: 'UNIQUE constraint failed: event_people_merge_dismissals.event_id' }, true],
['better-sqlite3 unique violation', { code: 'SQLITE_CONSTRAINT_UNIQUE', message: 'UNIQUE constraint failed' }, true],
['sqlite foreign-key violation', { code: 'SQLITE_CONSTRAINT', message: 'FOREIGN KEY constraint failed' }, false],
['sqlite busy', { code: 'SQLITE_BUSY', message: 'database is locked' }, false],
['missing table', { code: 'SQLITE_ERROR', message: 'no such table: event_people_merge_dismissals' }, false],
['postgres read-only transaction', { code: '25006', message: 'cannot execute INSERT in a read-only transaction' }, false],
['no error at all', null, false],
])('%s → swallowed: %s', (_name, err, expected) => {
expect(clustering.isUniqueViolation(err)).toBe(expected);
});
/**
* The dismissal read is the only thing standing between the automatic pass
* and a pair the photographer explicitly separated. If it fails open, a
* timeout silently restores the merge that "Not the same" was supposed to
* prevent — so anything other than a missing table must stop the pass.
*/
it('refuses to consolidate when the dismissal list cannot be read', async () => {
const eventId = await seedEvent('dismissals-unreadable');
// Well above the auto-merge threshold, so only a refusal keeps them apart.
const [a, b] = pairAtSimilarity(0.97, 0);
await insertPersonWithFace(eventId, a);
await insertPersonWithFace(eventId, b);
// Break the read for real rather than mocking knex: dropping a selected
// column makes the query fail with something that is NOT "missing
// table", which is exactly the class that must not fail open.
await db.schema.alterTable('event_people_merge_dismissals', (t) => t.dropColumn('person_b_id'));
try {
await expect(clustering.consolidate(eventId, { thresholds: THRESHOLDS }))
.rejects.toThrow();
// Nothing merged: the pass gave up rather than overriding a decision
// it could not read.
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
} finally {
await db.schema.alterTable('event_people_merge_dismissals', (t) => {
t.integer('person_b_id').notNullable().defaultTo(0);
});
}
});
it.each([
['postgres undefined_table', { code: '42P01', message: 'relation "x" does not exist' }, true],
['sqlite missing table', { code: 'SQLITE_ERROR', message: 'no such table: x' }, true],
// The one that matters: a missing COLUMN is a broken query, not a
// pre-migration install, and must NOT be allowed to fail open.
['postgres undefined_column', { code: '42703', message: 'column "x" does not exist' }, false],
['sqlite missing column', { code: 'SQLITE_ERROR', message: 'no such column: x' }, false],
['statement timeout', { code: '57014', message: 'canceling statement due to statement timeout' }, false],
])('missing-table check — %s → %s', (_name, err, expected) => {
expect(clustering.isMissingTable(err)).toBe(expected);
});
it('normalizes the pair so one row covers both orderings', async () => {
const eventId = await seedEvent('dismissal-normalized');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idB, idA);
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
expect(rows).toHaveLength(1);
expect(rows[0].person_a_id).toBe(Math.min(idA, idB));
expect(rows[0].person_b_id).toBe(Math.max(idA, idB));
});
});
describe('one suggestion per person per round', () => {
it('does not offer A-B, A-C and B-C for a three-way fragment', async () => {
const eventId = await seedEvent('three-way');
// Three mutually similar centroids, all inside the band.
const base = new Float32Array(DIM); base[0] = 1;
const people = [];
for (let k = 0; k < 3; k++) {
const v = new Float32Array(DIM);
v[0] = 0.9;
v[1 + k] = Math.sqrt(1 - 0.81);
people.push(await insertPerson(eventId, v));
}
await insertPerson(eventId, base);
const out = await suggest(eventId);
// Every returned pair must name people not already spoken for: accepting
// the first suggestion must never leave a second one pointing at a person
// that the merge just deleted.
const seen = new Set();
for (const s of out) {
expect(seen.has(s.person_a_id)).toBe(false);
expect(seen.has(s.person_b_id)).toBe(false);
seen.add(s.person_a_id);
seen.add(s.person_b_id);
}
});
it('offers the most similar pair first', async () => {
const eventId = await seedEvent('ordering');
const [a1, b1] = pairAtSimilarity(0.62, 0);
const [a2, b2] = pairAtSimilarity(0.67, 10);
await insertPerson(eventId, a1);
await insertPerson(eventId, b1);
await insertPerson(eventId, a2);
await insertPerson(eventId, b2);
const out = await suggest(eventId);
expect(out).toHaveLength(2);
expect(out[0].score).toBeGreaterThan(out[1].score);
});
});
describe('manual splits survive the automatic pass', () => {
/**
* The regression that matters most once consolidation runs on every scan:
* a photographer splitting a wrongly-merged cluster produces two people
* who are look-alikes BY CONSTRUCTION, so their centroids sit above the
* merge threshold and the very next scan would put them straight back.
*/
it('records a split as a separation, so consolidation leaves it alone', async () => {
const eventId = await seedEvent('split-protected');
const base = new Float32Array(DIM); base[0] = 1;
// One cluster holding two near-identical faces.
const personId = await insertPersonWithFace(eventId, base);
const extraFaceId = await addFaceTo(eventId, personId, base);
const newPersonId = await clustering.splitPerson(eventId, personId, [extraFaceId]);
expect(newPersonId).toBeTruthy();
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
expect(rows).toHaveLength(1);
expect([rows[0].person_a_id, rows[0].person_b_id].sort())
.toEqual([personId, newPersonId].sort());
// Identical centroids — nothing but the recorded separation can stop
// this merge.
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toEqual([]);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
});
});
describe('consolidation reporting', () => {
it('records what an automatic pass merged, so it is not silent', async () => {
const eventId = await seedEvent('report-merged');
// 0.97 is above the 0.68 auto-merge threshold — consolidate() acts.
const [a, b] = pairAtSimilarity(0.97, 0);
await insertPersonWithFace(eventId, a);
await insertPersonWithFace(eventId, b);
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toHaveLength(1);
const event = await db('events').where({ id: eventId }).first();
expect(Number(event.faces_last_consolidated_count)).toBe(1);
expect(event.faces_last_consolidated_at).toBeTruthy();
});
it('never absorbs an ignored cluster — that would mark a real person ignored', async () => {
const eventId = await seedEvent('consolidate-ignored');
// Well above the auto-merge threshold: only the is_ignored flag can
// stop this pair.
const [a, b] = pairAtSimilarity(0.97, 0);
const real = await insertPersonWithFace(eventId, a);
const junk = await insertPersonWithFace(eventId, b, { is_ignored: true });
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toEqual([]);
// Both still standing, and the real person is still guest-visible —
// mergePeople ORs is_ignored onto the survivor, so absorbing the junk
// cluster would have hidden a real person from the gallery.
const survivors = await db('event_people').where({ event_id: eventId }).select('id', 'is_ignored');
expect(survivors.map((p) => p.id).sort()).toEqual([real, junk].sort());
const realRow = survivors.find((p) => p.id === real);
expect(realRow.is_ignored === true || realRow.is_ignored === 1).toBe(false);
});
it('never merges a pair the photographer said was not the same person', async () => {
const eventId = await seedEvent('consolidate-dismissed');
// Also above the auto-merge threshold: the dismissal is the only thing
// standing between these two, which is the point — a human "no" has to
// outrank the automatic pass, not just the suggestion list.
const [a, b] = pairAtSimilarity(0.97, 0);
const idA = await insertPersonWithFace(eventId, a);
const idB = await insertPersonWithFace(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toEqual([]);
expect(await db('event_people').where({ event_id: eventId }).count({ c: '*' }).first())
.toEqual(expect.objectContaining({ c: 2 }));
});
// NOT covered by a test: reporting what a pass merged before it died
// partway. `consolidate` calls `mergePeople` through the module-local
// binding, so a spy on the export cannot intercept it, and no realistic
// database failure lands on the second merge only. The recording therefore
// sits in a `finally` — each mergePeople is its own transaction, so a pass
// that throws has still committed what it did, and the alternative is a
// real merge going unreported. Verified by reading, not by assertion.
it('clears a previous count when a later pass merges nothing', async () => {
const eventId = await seedEvent('report-cleared');
await db('events').where({ id: eventId }).update({ faces_last_consolidated_count: 7 });
const [a, b] = pairAtSimilarity(0.5, 0);
await insertPersonWithFace(eventId, a);
await insertPersonWithFace(eventId, b);
await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
const event = await db('events').where({ id: eventId }).first();
expect(Number(event.faces_last_consolidated_count)).toBe(0);
});
});
});
@@ -0,0 +1,415 @@
/**
* Privacy and visibility guarantees for face recognition (#1074).
*
* These are the tests that matter most in this feature. Two of them cover
* defects that would be invisible in normal use:
*
* - The people strip is computed from face rows, which have no concept of
* photo visibility. Handing a guest a raw count leaks how many hidden
* photos someone appears in, and a cover face picked without scoping
* renders a crop of a photo the guest may not open.
*
* - Face embeddings are biometric data. They must not ride along in a
* .picpeak export, which gets handed to clients and moved between
* operators.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-faceprivacy-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceprivacy-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let clustering; let peopleService; let faceProcessor;
function makeEmbedding(id, variant = 0, dim = 64) {
const vec = new Float32Array(dim);
for (let i = 0; i < dim; i++) {
vec[i] = Math.sin((i + 1) * (id + 1) * 0.7) + variant * 0.02 * Math.cos(i * 3.1);
}
let norm = 0;
for (let i = 0; i < dim; i++) norm += vec[i] * vec[i];
norm = Math.sqrt(norm);
for (let i = 0; i < dim; i++) vec[i] /= norm;
return vec;
}
async function seedEvent(slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function addPhotoWithFace(eventId, embedding, { visibility = 'visible', score = 0.99 } = {}) {
const [p] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
visibility,
processing_status: 'complete',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
const row = {
photo_id: photoId,
event_id: eventId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
det_score: score,
embedding: clustering.packEmbedding(embedding),
model_version: 'test-v1',
created_at: new Date().toISOString(),
};
const [f] = await db('photo_faces').insert(row).returning('id');
return { photoId, face: { ...row, id: typeof f === 'object' ? f.id : f } };
}
describe('face privacy and visibility (#1074)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
clustering = require('../../src/services/faceClustering');
peopleService = require('../../src/services/facePeopleService');
faceProcessor = require('../../src/services/faceProcessor');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('visibility scoping', () => {
it('counts only photos the audience can actually see', async () => {
const eventId = await seedEvent('visibility-count');
const faces = [];
// Same person: 3 visible photos, 4 hidden ones.
for (let v = 0; v < 3; v++) {
faces.push((await addPhotoWithFace(eventId, makeEmbedding(1, v))).face);
}
for (let v = 3; v < 7; v++) {
faces.push((await addPhotoWithFace(eventId, makeEmbedding(1, v), { visibility: 'hidden' })).face);
}
await clustering.assignFaces(eventId, faces);
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
const clientView = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
expect(guestView).toHaveLength(1);
// The leak this test exists to prevent: 3, never 7.
expect(guestView[0].face_count).toBe(3);
expect(clientView[0].face_count).toBe(7);
});
it('never returns face_count_total to a guest', async () => {
const eventId = await seedEvent('no-total-leak');
const { face } = await addPhotoWithFace(eventId, makeEmbedding(2));
await clustering.assignFaces(eventId, [face]);
const [person] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
expect(person).not.toHaveProperty('total_face_count');
expect(person).not.toHaveProperty('is_hidden');
});
it('picks a cover face from a photo the guest may open', async () => {
const eventId = await seedEvent('cover-scoping');
// The BEST face (highest score) is in a hidden photo — a naive
// implementation would hand its crop to the guest.
const hidden = await addPhotoWithFace(eventId, makeEmbedding(3, 0), {
visibility: 'hidden', score: 0.99,
});
const visible = await addPhotoWithFace(eventId, makeEmbedding(3, 1), {
visibility: 'visible', score: 0.80,
});
await clustering.assignFaces(eventId, [hidden.face, visible.face]);
const [guestPerson] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
expect(guestPerson.cover.photo_id).toBe(visible.photoId);
expect(guestPerson.cover.photo_id).not.toBe(hidden.photoId);
});
it('prefers the cover the photographer chose (#1096)', async () => {
const eventId = await seedEvent('chosen-cover');
// The auto-pick would take the 0.99 face. The photographer picked the
// other one — without this the PATCH saved, the toast said so, and the
// avatar reverted on the very next read.
const best = await addPhotoWithFace(eventId, makeEmbedding(9, 0), { score: 0.99 });
const chosen = await addPhotoWithFace(eventId, makeEmbedding(9, 1), { score: 0.70 });
await clustering.assignFaces(eventId, [best.face, chosen.face]);
const [before] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
expect(before.cover.photo_id).toBe(best.photoId);
// Clustering must not have written one: an automatic seed here would be
// indistinguishable from a real choice the moment listPeople honours it.
const seeded = await db('event_people').where({ id: before.id }).first();
expect(seeded.cover_face_id).toBeFalsy();
await db('event_people').where({ id: before.id }).update({ cover_face_id: chosen.face.id });
const [after] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
expect(after.cover.photo_id).toBe(chosen.photoId);
});
it('carries a chosen cover through a merge', async () => {
const eventId = await seedEvent('cover-merge');
const a = await addPhotoWithFace(eventId, makeEmbedding(20, 0), { score: 0.90 });
const b = await addPhotoWithFace(eventId, makeEmbedding(60, 0), { score: 0.95 });
await clustering.assignFaces(eventId, [a.face]);
await clustering.assignFaces(eventId, [b.face]);
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
expect(people.length).toBeGreaterThan(1);
// The SOURCE carries the choice; the target has none.
await db('event_people').where({ id: people[1].id }).update({ cover_face_id: b.face.id });
await clustering.mergePeople(eventId, [people[1].id], people[0].id);
const target = await db('event_people').where({ id: people[0].id }).first();
expect(target.cover_face_id).toBe(b.face.id);
});
it('carries a chosen cover through a recluster', async () => {
const eventId = await seedEvent('cover-recluster');
const faces = [];
for (let v = 0; v < 3; v++) {
faces.push((await addPhotoWithFace(eventId, makeEmbedding(21, v), { score: 0.9 - v * 0.1 })).face);
}
await clustering.assignFaces(eventId, faces);
const [person] = await db('event_people').where({ event_id: eventId });
// Pick the WORST-scoring face, so an automatic re-pick would differ.
const chosen = faces[2].id;
await db('event_people').where({ id: person.id }).update({ cover_face_id: chosen });
await clustering.recluster(eventId);
const after = await db('event_people').where({ event_id: eventId }).whereNotNull('cover_face_id');
expect(after).toHaveLength(1);
expect(after[0].cover_face_id).toBe(chosen);
});
it('falls back to a visible face when the chosen cover is hidden from this audience', async () => {
const eventId = await seedEvent('chosen-cover-hidden');
// Choosing a cover must never override the visibility scoping — that
// would hand a guest a crop of a photo they cannot open.
const hidden = await addPhotoWithFace(eventId, makeEmbedding(10, 0), {
visibility: 'hidden', score: 0.99,
});
const visible = await addPhotoWithFace(eventId, makeEmbedding(10, 1), { score: 0.70 });
await clustering.assignFaces(eventId, [hidden.face, visible.face]);
const [person] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
await db('event_people').where({ id: person.id }).update({ cover_face_id: hidden.face.id });
const [guestView] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
expect(guestView.cover.photo_id).toBe(visible.photoId);
expect(guestView.cover.photo_id).not.toBe(hidden.photoId);
});
it('drops a person entirely when all their photos are hidden', async () => {
const eventId = await seedEvent('all-hidden');
const faces = [];
for (let v = 0; v < 3; v++) {
faces.push((await addPhotoWithFace(eventId, makeEmbedding(4, v), { visibility: 'hidden' })).face);
}
await clustering.assignFaces(eventId, faces);
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
expect(guestView).toHaveLength(0);
const clientView = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
expect(clientView).toHaveLength(1);
});
it('omits hidden and ignored people from the guest response', async () => {
const eventId = await seedEvent('hidden-people');
const a = (await addPhotoWithFace(eventId, makeEmbedding(5))).face;
const b = (await addPhotoWithFace(eventId, makeEmbedding(6))).face;
await clustering.assignFaces(eventId, [a, b]);
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
await db('event_people').where({ id: people[0].id }).update({ is_hidden: true });
await db('event_people').where({ id: people[1].id }).update({ is_ignored: true });
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
expect(guestView).toHaveLength(0);
const adminView = await peopleService.listPeople(eventId, { isClient: true, forAdmin: true });
expect(adminView).toHaveLength(2);
});
it('does not attach a hidden person to a photo a guest can see', async () => {
const eventId = await seedEvent('person-ids-hidden');
const { photoId, face } = await addPhotoWithFace(eventId, makeEmbedding(7));
await clustering.assignFaces(eventId, [face]);
const person = await db('event_people').where({ event_id: eventId }).first();
await db('event_people').where({ id: person.id }).update({ is_hidden: true });
const guestMap = await peopleService.getPersonIdsByPhoto(eventId, [photoId], { forAdmin: false });
expect(guestMap.get(photoId)).toBeUndefined();
const adminMap = await peopleService.getPersonIdsByPhoto(eventId, [photoId], { forAdmin: true });
expect(adminMap.get(photoId)).toEqual([person.id]);
});
it('respects the minimum cluster size so one-off bystanders stay out', async () => {
const eventId = await seedEvent('min-cluster');
const solo = (await addPhotoWithFace(eventId, makeEmbedding(8))).face;
const crowd = [];
for (let v = 0; v < 4; v++) {
crowd.push((await addPhotoWithFace(eventId, makeEmbedding(9, v))).face);
}
await clustering.assignFaces(eventId, [solo, ...crowd]);
const people = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 3 });
expect(people).toHaveLength(1);
expect(people[0].face_count).toBe(4);
});
});
describe('erasure', () => {
it('purgeEvent removes every face row and resets the photos', async () => {
const eventId = await seedEvent('purge');
const faces = [];
for (let v = 0; v < 3; v++) {
faces.push((await addPhotoWithFace(eventId, makeEmbedding(10, v))).face);
}
await clustering.assignFaces(eventId, faces);
await db('photos').where({ event_id: eventId }).update({ face_status: 'done', face_count: 1 });
expect(await db('photo_faces').where({ event_id: eventId })).not.toHaveLength(0);
expect(await db('event_people').where({ event_id: eventId })).not.toHaveLength(0);
await faceProcessor.purgeEvent(eventId);
expect(await db('photo_faces').where({ event_id: eventId })).toHaveLength(0);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
const photos = await db('photos').where({ event_id: eventId });
expect(photos.every((p) => p.face_status === null && p.face_count === null)).toBe(true);
});
it('purgePhotoFaces removes face rows WITHOUT relying on the FK cascade', async () => {
// The regression this guards: PicPeak does not enable
// `PRAGMA foreign_keys` on SQLite, so ON DELETE CASCADE never fires
// there and biometric embeddings outlived the photo. The pragma is
// explicitly OFF here so the assertion can only pass if the deletion
// path purges the rows itself.
await db.raw('PRAGMA foreign_keys = OFF');
const eventId = await seedEvent('purge-no-cascade');
const faces = [];
for (let v = 0; v < 3; v++) {
faces.push((await addPhotoWithFace(eventId, makeEmbedding(20, v))).face);
}
await clustering.assignFaces(eventId, faces);
const person = await db('event_people').where({ event_id: eventId }).first();
expect(person.face_count_total).toBe(3);
const victim = faces[0];
await faceProcessor.purgePhotoFaces(victim.photo_id);
expect(await db('photo_faces').where({ photo_id: victim.photo_id })).toHaveLength(0);
// …and the person it belonged to was rebuilt, not left with a stale count.
const after = await db('event_people').where({ id: person.id }).first();
expect(after.face_count_total).toBe(2);
});
it('purging the last face of a person removes the person too', async () => {
await db.raw('PRAGMA foreign_keys = OFF');
const eventId = await seedEvent('purge-last-face');
const { face, photoId } = await addPhotoWithFace(eventId, makeEmbedding(21));
await clustering.assignFaces(eventId, [face]);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
await faceProcessor.purgePhotoFaces(photoId);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
});
it('deleting an event removes its people and faces', async () => {
await db.raw('PRAGMA foreign_keys = ON');
const eventId = await seedEvent('event-delete');
const { face } = await addPhotoWithFace(eventId, makeEmbedding(11));
await clustering.assignFaces(eventId, [face]);
await db('photos').where({ event_id: eventId }).del();
await db('events').where({ id: eventId }).del();
expect(await db('photo_faces').where({ event_id: eventId })).toHaveLength(0);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
});
});
describe('all-in-one image block (#1042 / PR #1068)', () => {
// Blocked for performance: the AIO image runs backend, frontend, SQLite
// and every worker in one container, with no ML sidecar to talk to. The
// failure there would not be loud — just a slow install that looks
// broken — so the gate is asserted rather than assumed.
const faceSettings = require('../../src/services/faceSettings');
afterEach(() => { delete process.env.PICPEAK_SINGLE_CONTAINER; });
it('reports the feature off regardless of the flag row', async () => {
process.env.PICPEAK_SINGLE_CONTAINER = 'true';
expect(faceSettings.isSingleContainerImage()).toBe(true);
// Even with the flag ON in the database.
await db('feature_flags').insert({ key: 'faces', value: true })
.onConflict('key').merge()
.catch(async () => {
await db('feature_flags').where({ key: 'faces' }).update({ value: true });
});
expect(await faceSettings.isFeatureEnabled()).toBe(false);
});
it('refuses per-event detection too', async () => {
process.env.PICPEAK_SINGLE_CONTAINER = 'true';
const eventId = await seedEvent('aio-block');
const event = await db('events').where({ id: eventId }).first();
expect(event.face_recognition_enabled).toBeTruthy();
expect(await faceSettings.isEnabledForEvent(event)).toBe(false);
});
it('accepts only explicit truthy markers', () => {
for (const v of ['true', '1', 'yes', 'TRUE']) {
process.env.PICPEAK_SINGLE_CONTAINER = v;
expect(faceSettings.isSingleContainerImage()).toBe(true);
}
for (const v of ['false', '0', '', 'no']) {
process.env.PICPEAK_SINGLE_CONTAINER = v;
expect(faceSettings.isSingleContainerImage()).toBe(false);
}
delete process.env.PICPEAK_SINGLE_CONTAINER;
expect(faceSettings.isSingleContainerImage()).toBe(false);
});
});
describe('export and backup exclusion', () => {
it('excludes both face tables from .picpeak exports', () => {
const { EXCLUDED_TABLES } = require('../../src/services/picpeakExportService');
expect(EXCLUDED_TABLES.has('photo_faces')).toBe(true);
expect(EXCLUDED_TABLES.has('event_people')).toBe(true);
});
it('excludes both face tables from the database backup table list', async () => {
const databaseBackup = require('../../src/services/databaseBackup');
const service = databaseBackup.DatabaseBackupService
? new databaseBackup.DatabaseBackupService()
: databaseBackup;
if (typeof service.getTables !== 'function') return; // shape differs; covered by the export test
const tables = await service.getTables();
expect(tables).not.toContain('photo_faces');
expect(tables).not.toContain('event_people');
// Sanity: the filter didn't eat everything.
expect(tables).toContain('events');
});
});
});
@@ -0,0 +1,146 @@
/**
* Bounding-box coordinate space (#1074).
*
* The sidecar reports boxes in the pixel space of the image it was HANDED —
* the ≤1920px preview — while every consumer (the strip's avatar crop, the
* admin manager, the auto-category portrait rule) compares them against
* photos.width/height, the ORIGINAL dimensions. faceProcessor scales once so
* everything downstream can assume original-image coordinates.
*
* This is the defect that survived longest in review, and it is invisible on
* any photo already under 1920px — the entire demo gallery was 750px, so the
* scale factor was always exactly 1.0 and the correction never ran. Verified
* by hand afterwards on a real 4000x3000 upload (stored box moved from
* 1493,204 to 3110,426 — a factor of 2.083, exactly 4000/1920). This test
* exists so that verification does not have to be repeated by hand.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-facescale-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facescale-test-secret';
// A 1920x1440 JPEG standing in for the preview rendition. faceProcessor reads
// its dimensions with sharp to derive the scale, so it must be a real image.
const sharp = require('sharp');
let mockPreviewBuffer;
const mockSidecarBox = [1493, 204, 131, 161]; // what the sidecar sees on the preview
jest.mock('../../src/services/imageProcessor', () => ({
...jest.requireActual('../../src/services/imageProcessor'),
ensurePreviewImage: jest.fn(async () => 'previews/preview_test.jpg'),
}));
jest.mock('../../src/services/storage', () => ({
getStorage: () => ({ get: async () => mockPreviewBuffer }),
}));
jest.mock('../../src/services/faceClient', () => ({
detectFaces: jest.fn(async () => ({
model_version: 'test-v1',
faces: [{
bbox: mockSidecarBox,
score: 0.99,
landmarks: [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]],
yaw: 0, pitch: 0, blur: 500,
embedding: Array.from({ length: 64 }, (_, i) => (i === 0 ? 1 : 0)),
}],
})),
SidecarUnavailableError: class extends Error {},
}));
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let faceProcessor;
async function seedPhoto(width, height) {
const [e] = await db('events').insert({
slug: `scale-${width}-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'scale',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `scale-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const [p] = await db('photos').insert({
event_id: eventId,
filename: 'big.jpg',
path: '/tmp/big.jpg',
type: 'individual',
width,
height,
processing_status: 'complete',
face_status: 'processing',
}).returning('id');
return { eventId, photoId: typeof p === 'object' ? p.id : p };
}
describe('face bbox coordinate space (#1074)', () => {
beforeAll(async () => {
mockPreviewBuffer = await sharp({
create: { width: 1920, height: 1440, channels: 3, background: { r: 20, g: 40, b: 80 } },
}).jpeg().toBuffer();
({ db, cleanup } = await bootCrmDb());
// The faces flag gates everything; turn it on for this suite.
await db('feature_flags').insert({ key: 'faces', value: true })
.onConflict('key').merge()
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
faceProcessor = require('../../src/services/faceProcessor');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('scales preview-space boxes into ORIGINAL image coordinates', async () => {
// 4000px original, 1920px preview -> every coordinate must grow by 4000/1920.
const { photoId } = await seedPhoto(4000, 3000);
await faceProcessor.processPhotoFaces(photoId);
const face = await db('photo_faces').where({ photo_id: photoId }).first();
const scale = 4000 / 1920;
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0] * scale, 1);
expect(face.bbox_y).toBeCloseTo(mockSidecarBox[1] * scale, 1);
expect(face.bbox_w).toBeCloseTo(mockSidecarBox[2] * scale, 1);
expect(face.bbox_h).toBeCloseTo(mockSidecarBox[3] * scale, 1);
// The regression this guards: the raw preview-space value being stored.
expect(face.bbox_x).not.toBeCloseTo(mockSidecarBox[0], 1);
// And a sanity check that it lands inside the original frame.
expect(face.bbox_x + face.bbox_w).toBeLessThanOrEqual(4000);
});
it('leaves boxes untouched when the photo is already preview-sized', async () => {
// The case that hid the bug: no downscale, so scale is exactly 1 and the
// stored box equals what the sidecar reported.
const { photoId } = await seedPhoto(1920, 1440);
await faceProcessor.processPhotoFaces(photoId);
const face = await db('photo_faces').where({ photo_id: photoId }).first();
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0], 1);
expect(face.bbox_w).toBeCloseTo(mockSidecarBox[2], 1);
});
it('falls back to unscaled rather than corrupting when width is unknown', async () => {
// Pre-dimension-migration rows have no width. Storing a box scaled by
// NaN/0 would be worse than storing an unscaled one.
const { photoId } = await seedPhoto(null, null);
await faceProcessor.processPhotoFaces(photoId);
const face = await db('photo_faces').where({ photo_id: photoId }).first();
expect(Number.isFinite(face.bbox_x)).toBe(true);
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0], 1);
});
});
@@ -0,0 +1,226 @@
/**
* A deferred photo must not stall the queue.
*
* claimNextPhoto orders by id ascending, and the queue defaults to a single
* worker. So returning an unreachable photo to 'pending' — the obvious way to
* say "try again later" — makes that same row the oldest pending one forever:
* the worker reclaims it after every backoff and never reaches a higher id.
* One dead mount would stall face scanning for the entire install, including
* unrelated events and fresh uploads.
*
* The row is instead left parked in 'processing' with its face_started_at
* intact. It is not claimable, so the worker advances; the existing janitor
* returns it to 'pending' after STUCK_TIMEOUT_MS, which is the retry.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-defer-'));
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'defer-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let faceQueue; let faceProcessor;
describe('deferred photos do not block the queue', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
faceQueue = require('../../src/services/faceQueue');
faceProcessor = require('../../src/services/faceProcessor');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
});
it('exports TransientSourceError for the queue to branch on', () => {
// The queue imports this from faceProcessor; if the export is dropped the
// instanceof check silently becomes false and every deferral turns back
// into a permanent failure.
expect(typeof faceProcessor.TransientSourceError).toBe('function');
expect(new faceProcessor.TransientSourceError(1, 'x'))
.toBeInstanceOf(Error);
});
it('does NOT return a deferred row to pending', () => {
// Source inspection, deliberately. workerLoop is an unexported infinite
// loop, so the branch cannot be driven directly, and asserting on database
// state alone does not distinguish the fix from the bug — a version that
// re-queues the row passes every state assertion in this file. What
// actually matters is that this one branch does not call releaseToPending,
// so that is what is pinned. Same approach as the contract tests added for
// #596.
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'services', 'faceQueue.js'), 'utf8'
);
const marker = 'if (err instanceof TransientSourceError) {';
const start = src.indexOf(marker);
expect(start).toBeGreaterThan(-1);
// The branch body, up to its closing brace.
const body = src.slice(start, src.indexOf('\n }', start));
expect(body).not.toMatch(/releaseToPending/);
expect(body).toMatch(/continue/);
// And the sidecar branch, which SHOULD still release, so this test fails
// if the two branches are ever collapsed back together.
const sideStart = src.indexOf('if (err instanceof SidecarUnavailableError) {');
expect(sideStart).toBeGreaterThan(-1);
const sideBody = src.slice(sideStart, src.indexOf('\n }', sideStart));
expect(sideBody).toMatch(/releaseToPending/);
});
it('leaves a deferred row claimable-later, not claimable-now', async () => {
// A row parked in 'processing' is invisible to claimNextPhoto, which only
// ever selects face_status='pending' — that is what lets the worker move
// past it instead of spinning on it.
const [e] = await db('events').insert({
slug: `defer-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'defer',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `defer-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const [stuck] = await db('photos').insert({
event_id: eventId,
filename: 'stuck.jpg',
path: 'd/stuck.jpg',
type: 'individual',
processing_status: 'complete',
face_status: 'processing',
face_started_at: new Date().toISOString(),
source_origin: 'external',
}).returning('id');
const stuckId = typeof stuck === 'object' ? stuck.id : stuck;
const parked = await db('photos')
.where({ id: stuckId, face_status: 'pending' })
.first();
expect(parked).toBeUndefined(); // not claimable while parked
// The janitor's contract is what turns the park into a retry: it resets
// 'processing' rows whose face_started_at is older than the stuck timeout.
// Backdate past it and the row becomes claimable again.
const longAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
await db('photos').where({ id: stuckId }).update({ face_started_at: longAgo });
const cutoff = new Date(Date.now() - 600000).toISOString();
const reset = await db('photos')
.where('face_status', 'processing')
.where('face_started_at', '<', cutoff)
.update({ face_status: 'pending', face_started_at: null });
expect(reset).toBeGreaterThan(0);
const after = await db('photos').where({ id: stuckId }).first();
expect(after.face_status).toBe('pending');
});
it('claimNextPhoto skips events inside their backoff window', async () => {
// The per-event cooldown is what stops the janitor handing a whole dead
// gallery back every sweep. Without the exclusion the worker walks all of
// it again — one slow stat per photo against a possibly hard-mounted
// share — before reaching any healthy event.
const mk = async (name) => {
const [e] = await db('events').insert({
slug: `cd-${name}-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: name,
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `cd-${name}-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const [p2] = await db('photos').insert({
event_id: eventId,
filename: `${name}.jpg`,
path: `cd/${name}.jpg`,
type: 'individual',
processing_status: 'complete',
face_status: 'pending',
source_origin: 'external',
}).returning('id');
return { eventId, photoId: typeof p2 === 'object' ? p2.id : p2 };
};
await db('photos').del();
const dead = await mk('dead'); // lower id -> would win the FIFO
const healthy = await mk('healthy');
// Without exclusion the dead event's row is claimed first...
const first = await faceQueue.claimNextPhoto([]);
expect(first.id).toBe(dead.photoId);
await db('photos').where({ id: dead.photoId }).update({ face_status: 'pending' });
// ...and with it, the worker reaches the healthy event instead.
const second = await faceQueue.claimNextPhoto([dead.eventId]);
expect(second.id).toBe(healthy.photoId);
});
it('backoff spares managed rows in a mixed-source event', async () => {
// A reference event can hold managed uploads alongside imported external
// ones. Excluding the whole event id would leave those unscanned for as
// long as external rows keep renewing the cooldown — indefinitely, during
// a real outage — even though their local source is fine.
await db('photos').del();
const [e] = await db('events').insert({
slug: `mix-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'mix',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `mix-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
source_mode: 'reference',
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const add = async (origin, name) => {
const [p2] = await db('photos').insert({
event_id: eventId,
filename: name,
path: `mix/${name}`,
type: 'individual',
processing_status: 'complete',
face_status: 'pending',
source_origin: origin,
}).returning('id');
return typeof p2 === 'object' ? p2.id : p2;
};
await add('external', 'ext.jpg'); // lower id, would win the FIFO
const managedId = await add('managed', 'man.jpg');
// Event is in backoff: the external row is skipped, the managed one is not.
const claimed = await faceQueue.claimNextPhoto([eventId]);
expect(claimed).toBeTruthy();
expect(claimed.id).toBe(managedId);
});
it('startQueue is exported and does not throw on import', () => {
// faceQueue requires faceProcessor for TransientSourceError while
// faceProcessor is itself required by the routes — a circular require here
// would surface as an undefined export rather than a crash, so assert the
// module actually loaded something usable.
expect(faceQueue).toBeTruthy();
expect(Object.keys(faceQueue).length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,219 @@
/**
* "The scan finished" is not a thing this queue is told (#1107).
*
* It claims photos one at a time, so a backfill is just a lot of independent
* claims and the only available signal is a worker finding nothing left. That
* signal is NOT sufficient on its own — with concurrency above one the other
* workers may still be busy, and a photo released back to `pending` by a down
* sidecar is still owed — so the drain is tested against the queue directly.
*
* These are the cases that decide whether consolidation runs too early (a
* wasted pass over half-formed clusters) or never (the feature silently does
* nothing, which is the state #1107 was filed about).
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-facedrain-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facedrain-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let faceQueue; let clustering;
async function seedEvent(slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
// The drain rechecks this before consolidating, so the fixture has to be
// a gallery that actually has detection on.
face_recognition_enabled: true,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/** Both halves of the "two deliberate actions" rule have to be on. */
async function enableFacesGlobally() {
const existing = await db('feature_flags').where({ key: 'faces' }).first();
if (existing) await db('feature_flags').where({ key: 'faces' }).update({ value: true });
else await db('feature_flags').insert({ key: 'faces', value: true });
}
async function insertPhoto(eventId, faceStatus) {
const [row] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
face_status: faceStatus,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
describe('faceQueue drain consolidation (#1107)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
faceQueue = require('../../src/services/faceQueue');
clustering = require('../../src/services/faceClustering');
await enableFacesGlobally();
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(() => {
faceQueue.touchedEvents.clear();
faceQueue.consolidationRetryAt.clear();
faceQueue.inFlightByEvent.clear();
jest.restoreAllMocks();
});
it('does nothing at all when no photo has been scanned', async () => {
const spy = jest.spyOn(clustering, 'consolidate');
await faceQueue.drainConsolidation();
expect(spy).not.toHaveBeenCalled();
});
it('waits while the event still has photos queued', async () => {
const eventId = await seedEvent('drain-pending');
await insertPhoto(eventId, 'done');
await insertPhoto(eventId, 'pending');
faceQueue.touchedEvents.add(eventId);
const spy = jest.spyOn(clustering, 'consolidate');
await faceQueue.drainConsolidation();
expect(spy).not.toHaveBeenCalled();
// Still owed, so it must keep its place for the next idle tick — dropping
// it here would mean the gallery never consolidates at all.
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
});
it('waits while a photo is still being processed by another worker', async () => {
const eventId = await seedEvent('drain-processing');
await insertPhoto(eventId, 'done');
await insertPhoto(eventId, 'processing');
faceQueue.touchedEvents.add(eventId);
const spy = jest.spyOn(clustering, 'consolidate');
await faceQueue.drainConsolidation();
expect(spy).not.toHaveBeenCalled();
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
});
it('consolidates once the queue is empty, and does not repeat itself', async () => {
const eventId = await seedEvent('drain-empty');
await insertPhoto(eventId, 'done');
await insertPhoto(eventId, 'failed');
await insertPhoto(eventId, 'skipped');
faceQueue.touchedEvents.add(eventId);
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
await faceQueue.drainConsolidation();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(eventId);
// Drained and handled, so a second idle tick must not pay for it again.
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
await faceQueue.drainConsolidation();
expect(spy).toHaveBeenCalledTimes(1);
});
it('a failing consolidation never propagates into the worker loop, and is retried', async () => {
const eventId = await seedEvent('drain-throws');
await insertPhoto(eventId, 'done');
faceQueue.touchedEvents.add(eventId);
const spy = jest.spyOn(clustering, 'consolidate').mockRejectedValue(new Error('boom'));
await expect(faceQueue.drainConsolidation()).resolves.toBeUndefined();
// A transient database error must not cost the gallery its consolidation
// outright — the event keeps its place so a later tick retries.
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
// ...but not on the very next tick. The worker idles every couple of
// seconds, so an immediate retry would hot-loop a permanently broken event
// and warn every time.
expect(faceQueue.consolidationRetryAt.get(eventId)).toBeGreaterThan(Date.now());
const callsBefore = spy.mock.calls.length;
await faceQueue.drainConsolidation();
expect(spy).toHaveBeenCalledTimes(callsBefore);
// Once the backoff elapses it really does try again, and succeeds.
faceQueue.consolidationRetryAt.set(eventId, Date.now() - 1);
spy.mockResolvedValue([]);
await faceQueue.drainConsolidation();
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
expect(faceQueue.consolidationRetryAt.has(eventId)).toBe(false);
});
it('waits while another worker is still inside processPhotoFaces', async () => {
const eventId = await seedEvent('drain-inflight');
// Every row already reads as drained: the last photo is committed 'done'
// inside the transaction, and auto-categorisation runs afterwards. Only
// the in-flight count knows a worker is still there.
await insertPhoto(eventId, 'done');
faceQueue.touchedEvents.add(eventId);
faceQueue.inFlightByEvent.set(eventId, 1);
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
await faceQueue.drainConsolidation();
// Consolidating here would record its count, and the busy worker would
// then re-mark the event — the next pass merges nothing and overwrites the
// real number with zero.
expect(spy).not.toHaveBeenCalled();
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
faceQueue.inFlightByEvent.delete(eventId);
await faceQueue.drainConsolidation();
expect(spy).toHaveBeenCalledTimes(1);
});
it('does not consolidate an event whose detection was switched off mid-drain', async () => {
const eventId = await seedEvent('drain-disabled');
await insertPhoto(eventId, 'done');
await db('events').where({ id: eventId }).update({ face_recognition_enabled: false });
faceQueue.touchedEvents.add(eventId);
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
await faceQueue.drainConsolidation();
// An earlier photo legitimately marked the event before the toggle went
// off. Merging someone's clusters just after they disabled the feature is
// not a thing to do quietly.
expect(spy).not.toHaveBeenCalled();
// Dropped rather than retried — it is not coming back on its own.
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
});
it('treats events independently — a busy gallery does not hold up a finished one', async () => {
const busy = await seedEvent('drain-busy');
const done = await seedEvent('drain-done');
await insertPhoto(busy, 'pending');
await insertPhoto(done, 'done');
faceQueue.touchedEvents.add(busy);
faceQueue.touchedEvents.add(done);
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
await faceQueue.drainConsolidation();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(done);
expect(faceQueue.touchedEvents.has(busy)).toBe(true);
});
});
@@ -0,0 +1,567 @@
/**
* "Not the same person" has to outlive re-derivation (#1132).
*
* The decision used to be stored as a pair of event_people.id, and neither
* person ids nor face ids survive:
*
* - recluster() deletes every person and re-assigns, so person ids die but
* photo_faces.id survives
* - a full re-scan replaces a photo's faces outright, so FACE ids die too
*
* The embedding is the only stable handle, so that is what the separation is
* keyed on. These tests simulate both kinds of re-derivation by destroying the
* ids and rebuilding from the same vectors — which is exactly what the real
* paths do — and assert the constraint still binds.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sep-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sep-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let clustering;
const THRESHOLDS = { face_match_threshold: 0.6, face_quality_min_score: 0.7, face_quality_min_px: 40 };
const DIM = 64;
/** Two unit vectors whose dot product is exactly `target`, on basis (i, i+1). */
function pairAtSimilarity(target, basis) {
const a = new Float32Array(DIM);
const b = new Float32Array(DIM);
a[basis] = 1;
b[basis] = target;
b[basis + 1] = Math.sqrt(1 - target * target);
return [a, b];
}
async function seedEvent(slug) {
const [row] = await db('events').insert({
slug, event_type: 'wedding', event_name: slug, event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: `${slug}-share`, expires_at: new Date().toISOString(),
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function insertPerson(eventId, centroid, overrides = {}) {
const [row] = await db('event_people').insert({
event_id: eventId,
centroid: clustering.packEmbedding(centroid),
face_count_total: 1,
model_version: 'test-v1',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/** The mirror of pairAtSimilarity's second vector: same similarity, other side. */
function mirrorAtSimilarity(target, basis) {
const b = new Float32Array(DIM);
b[basis] = target;
b[basis + 1] = -Math.sqrt(1 - target * target);
return b;
}
async function insertFaceWithPhoto(eventId, personId, centroid) {
const [p] = await db('photos').insert({
event_id: eventId, filename: `${Math.random()}.jpg`, path: '/tmp/x.jpg', type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
const [f] = await db('photo_faces').insert({
photo_id: photoId, event_id: eventId, person_id: personId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
embedding: clustering.packEmbedding(centroid),
model_version: 'test-v1', created_at: new Date().toISOString(),
}).returning('id');
return { faceId: typeof f === 'object' ? f.id : f, photoId };
}
async function insertFace(eventId, personId, centroid) {
const { faceId } = await insertFaceWithPhoto(eventId, personId, centroid);
return faceId;
}
/**
* What a re-scan does to identity: the people are gone and the faces come back
* with brand-new ids. Same vectors, nothing else preserved.
*/
async function simulateRescan(eventId, vectors) {
await db('photo_faces').where({ event_id: eventId }).del();
await db('event_people').where({ event_id: eventId }).del();
const ids = [];
for (const vec of vectors) {
const personId = await insertPerson(eventId, vec);
await insertFace(eventId, personId, vec);
ids.push(personId);
}
return ids;
}
describe('separations survive re-derivation (#1132)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
clustering = require('../../src/services/faceClustering');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('the matcher', () => {
it('binds a pair that still looks like the one that was separated', () => {
const [a, b] = pairAtSimilarity(0.64, 0);
expect(clustering.separationForbids(a, b, [{ a, b }])).toBe(true);
});
it('binds regardless of which way round the candidates arrive', () => {
const [a, b] = pairAtSimilarity(0.64, 0);
// Neither the stored pair nor the candidate pair has a meaningful order.
expect(clustering.separationForbids(b, a, [{ a, b }])).toBe(true);
});
it('lapses once a side has drifted past recognition', () => {
const [a, b] = pairAtSimilarity(0.64, 0);
// A cluster reshaped far enough is no longer the cluster the
// photographer pointed at, so the constraint should stop applying rather
// than bind something they never saw.
const drifted = new Float32Array(DIM);
drifted[10] = 1;
expect(clustering.separationForbids(drifted, b, [{ a, b }])).toBe(false);
});
it('does not bind two clusters that are both the SAME side', () => {
// A split leaves two halves of one cluster, so the pair it records is
// often similar to itself — here 0.95. Two candidates that are plainly
// both side A (0.97 to each other) each clear the bar against BOTH
// stored sides, so a test that only asks "does each side match
// something" says yes and refuses to let that person cluster with
// itself. It fragments into singletons — the person the split was not
// even about.
const [a, b] = pairAtSimilarity(0.95, 0);
const x = new Float32Array(DIM); x[0] = 1;
const y = mirrorAtSimilarity(0.97, 0);
expect(clustering.separationForbids(x, y, [{ a, b }])).toBe(false);
// The pair it was actually about still binds.
expect(clustering.separationForbids(a, b, [{ a, b }])).toBe(true);
});
it('ignores a separation recorded under a different embedding model', () => {
const [a, b] = pairAtSimilarity(0.64, 0);
// Vectors from another model are meaningless here, not merely stale —
// the same rule assignment and consolidation apply to person centroids.
expect(clustering.separationForbids(a, b, [{ a, b, modelVersion: 'test-v2' }],
{ modelVersion: 'test-v1' })).toBe(false);
expect(clustering.separationForbids(a, b, [{ a, b, modelVersion: 'test-v1' }],
{ modelVersion: 'test-v1' })).toBe(true);
});
it('ignores an unrelated pair entirely', () => {
const [a, b] = pairAtSimilarity(0.64, 0);
const [x, y] = pairAtSimilarity(0.64, 20);
expect(clustering.separationForbids(x, y, [{ a, b }])).toBe(false);
});
});
describe('across a re-scan', () => {
it('still refuses to merge the pair after every id has changed', async () => {
const eventId = await seedEvent('sep-rescan');
// Well above the auto-merge threshold: only the separation keeps them apart.
const [a, b] = pairAtSimilarity(0.97, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await insertFace(eventId, idA, a);
await insertFace(eventId, idB, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
const newIds = await simulateRescan(eventId, [a, b]);
// The premise: nothing the old row named still exists.
expect(newIds).not.toContain(idA);
expect(newIds).not.toContain(idB);
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toEqual([]);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
});
it('keeps the pair out of the suggestion list too', async () => {
const eventId = await seedEvent('sep-rescan-suggest');
const [a, b] = pairAtSimilarity(0.64, 0); // inside the suggestion band
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
await simulateRescan(eventId, [a, b]);
expect(await clustering.suggestMerges(eventId, { thresholds: THRESHOLDS })).toEqual([]);
});
it('a split still binds after the ids it recorded are gone', async () => {
const eventId = await seedEvent('sep-split-rescan');
// Two faces that look alike enough to have been clustered together, but
// are not the same vector — which is what a split is FOR, and the only
// case it can survive re-derivation in. Two byte-identical embeddings
// carry no information about which side is which, so a separation
// between them has nothing to key on once the ids are gone.
const [base, other] = pairAtSimilarity(0.96, 0);
const personId = await insertPerson(eventId, base);
await insertFace(eventId, personId, base);
const extra = await insertFace(eventId, personId, other);
const newPersonId = await clustering.splitPerson(eventId, personId, [extra]);
expect(newPersonId).toBeTruthy();
// The snapshot must have been taken AFTER recomputeCentroid — before it,
// the new person has no centroid at all.
const row = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
expect(row.centroid_a).toBeTruthy();
expect(row.centroid_b).toBeTruthy();
await simulateRescan(eventId, [base, other]);
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toEqual([]);
});
});
describe('when a photo is hard-deleted', () => {
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
it('drops the separation when one side has no photos left', async () => {
const eventId = await seedEvent('sep-purge-gone');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await insertFace(eventId, idA, a);
const { photoId } = await insertFaceWithPhoto(eventId, idB, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
await purgePhotoFaces(photoId);
// Person B is gone with its only photo. The row held a COPY of its
// centroid, so leaving it standing would keep a vector derived from a
// deleted photo alive in a table nothing else touches.
expect(await db('event_people').where({ id: idB }).first()).toBeUndefined();
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
});
it('keeps the constraint when a side still has another cluster on it', async () => {
const eventId = await seedEvent('sep-purge-descendant');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await insertFace(eventId, idA, a);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
// Re-derivation can leave one stored side represented by more than one
// current person. Deleting the photo behind ONE of them must not throw
// the whole decision away — the other still stands for that side, and the
// pair would be free to merge again.
const twin = new Float32Array(DIM);
for (let i = 0; i < DIM; i++) twin[i] = 0.98 * b[i];
twin[6] = Math.sqrt(1 - 0.98 ** 2);
const survivor = await insertPerson(eventId, twin);
await insertFace(eventId, survivor, twin);
const { photoId } = await insertFaceWithPhoto(eventId, idB, b);
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
await purgePhotoFaces(photoId);
expect(await db('event_people').where({ id: idB }).first()).toBeUndefined();
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
expect(rows).toHaveLength(1);
// Re-anchored onto the survivor, so it still binds.
expect(clustering.separationForbids(a, twin, [{
a: clustering.unpackEmbedding(rows[0].centroid_a),
b: clustering.unpackEmbedding(rows[0].centroid_b),
}])).toBe(true);
});
it('re-takes the snapshot from what is left when the person survives', async () => {
const eventId = await seedEvent('sep-purge-survives');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await insertFace(eventId, idA, a);
await insertFace(eventId, idB, b);
// A second face on B, close enough that B stays recognisably B — so
// purging it moves B's centroid rather than deleting the person, and the
// side still resolves to B afterwards.
const other = new Float32Array(DIM);
for (let i = 0; i < DIM; i++) other[i] = 0.95 * b[i];
other[5] = Math.sqrt(1 - 0.95 ** 2);
const { photoId } = await insertFaceWithPhoto(eventId, idB, other);
await clustering.recomputeCentroid(idB);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
const before = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
await purgePhotoFaces(photoId);
const after = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
expect(after).toBeTruthy();
expect(Buffer.from(after.centroid_b).equals(Buffer.from(before.centroid_b))).toBe(false);
// It now equals the recomputed centroid — nothing of the deleted face left.
const person = await db('event_people').where({ id: idB }).first();
expect(Buffer.from(after.centroid_b).equals(Buffer.from(person.centroid))).toBe(true);
});
});
describe('when the photographer changes their mind', () => {
it('a manual merge clears the separation between the merged people', async () => {
const eventId = await seedEvent('sep-merge-overrules');
const [a, b] = pairAtSimilarity(0.97, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await insertFace(eventId, idA, a);
await insertFace(eventId, idB, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
// ...and then decides they ARE the same person after all.
await clustering.mergePeople(eventId, [idB], idA);
// The row is keyed on the centroids as well as the ids, so leaving it
// would survive the ids it names: the next recluster would recognise
// those two sides and pull the merge apart again.
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
await simulateRescan(eventId, [a, b]);
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toHaveLength(1);
});
});
describe('cleanup after the ids have already died', () => {
// The rows these paths must find are exactly the ones whose person ids no
// longer resolve — that is the state this whole feature creates. Matching
// on ids alone walks past them, which is worse than not cleaning up at
// all: the surviving row still enforces its vectors.
it('a merge clears a separation that had already outlived its ids', async () => {
const eventId = await seedEvent('sep-merge-stale');
const [a, b] = pairAtSimilarity(0.97, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
// A recluster: same vectors, brand-new people. The row now names nobody.
const [newA, newB] = await simulateRescan(eventId, [a, b]);
expect([newA, newB]).not.toContain(idA);
await clustering.mergePeople(eventId, [newB], newA);
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
// And it stays merged through the next re-derivation.
await simulateRescan(eventId, [a, b]);
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toHaveLength(1);
});
it('a purge clears a separation that had already outlived its ids', async () => {
const eventId = await seedEvent('sep-purge-stale');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
// Same recluster, then hard-delete the photo behind the B side.
await db('photo_faces').where({ event_id: eventId }).del();
await db('event_people').where({ event_id: eventId }).del();
const newA = await insertPerson(eventId, a);
await insertFace(eventId, newA, a);
const newB = await insertPerson(eventId, b);
const { photoId } = await insertFaceWithPhoto(eventId, newB, b);
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
await purgePhotoFaces(photoId);
expect(await db('event_people').where({ id: newB }).first()).toBeUndefined();
// The row named idA/idB, neither of which exists — but its centroid_b is
// a copy of a vector derived from the photo that was just destroyed.
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
});
});
describe('when the whole gallery is deleted', () => {
it('deleteEventCascade clears the separations too', () => {
// Source inspection, deliberately. deleteEventCascade takes an admin
// context and does filesystem cleanup, so driving it here would test the
// scaffolding rather than the contract. The contract is narrow and
// absolute: this table now holds centroid BLOBs, it has no event FK by
// design, and nothing else in the codebase would ever reach it — so the
// one delete has to be in the cascade or the embeddings outlive the
// gallery. Same approach as the contract tests added for #596.
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'routes', 'adminEvents', 'helpers.js'), 'utf8'
);
const body = src.slice(src.indexOf('async function deleteEventCascade'));
expect(body).toContain('event_people_merge_dismissals\').where(\'event_id\', eventId).del()');
// Guarded, not caught: a failed statement aborts the transaction on PG.
expect(body).toContain('hasTable(\'event_people_merge_dismissals\')');
});
it('permanent archive deletion clears the face data too', () => {
// Same contract, second door. This route deletes the event row directly
// and leans on the FK cascade, which is inert on SQLite — and no FK
// reaches the dismissals table on either engine. archiveEvent's purge is
// nonfatal, so an event really can arrive here still holding embeddings.
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'routes', 'adminArchives.js'), 'utf8'
);
expect(src).toContain('event_people_merge_dismissals');
expect(src).toContain('db(\'photo_faces\').where(\'event_id\', req.params.id).del()');
expect(src).toContain('db(\'event_people\').where(\'event_id\', req.params.id).del()');
});
});
describe('during assignment', () => {
it('will not put a new face into a cluster it was separated from', async () => {
const eventId = await seedEvent('sep-assign');
const [a, b] = pairAtSimilarity(0.97, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
// A face that looks like side B arrives. Its nearest centroid is A (0.97,
// far above the 0.6 match threshold), and before #1132 it would simply
// have joined — reforming the pair the photographer pulled apart, because
// assignment consulted no separations at all.
await db('event_people').where({ id: idB }).del();
const [p] = await db('photos').insert({
event_id: eventId, filename: 'new.jpg', path: '/tmp/n.jpg', type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
const [f] = await db('photo_faces').insert({
photo_id: photoId, event_id: eventId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
embedding: clustering.packEmbedding(b), model_version: 'test-v1',
created_at: new Date().toISOString(),
}).returning('id');
const faceId = typeof f === 'object' ? f.id : f;
const assignments = await clustering.assignFaces(
eventId, [{ id: faceId, embedding: clustering.packEmbedding(b), model_version: 'test-v1',
det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
{ thresholds: THRESHOLDS },
);
expect(assignments).toHaveLength(1);
expect(assignments[0].personId).not.toBe(idA);
// It opened its own person rather than being forced into the wrong one.
expect(assignments[0].personId).toBeTruthy();
});
it('holds back a face that is only loosely like the side it belongs to', async () => {
const eventId = await seedEvent('sep-assign-loose');
// The separated sides are CENTROIDS; an individual face sits well below
// its own centroid — that is why faces join at 0.6 and not at 0.92. A
// face 0.85-like its own side would clear no strict bar against it, and
// before this it walked straight into the other person during a
// recluster, which is the exact merge the photographer undid.
const [sideA, sideB] = pairAtSimilarity(0.7, 0);
const idA = await insertPerson(eventId, sideA);
const idB = await insertPerson(eventId, sideB);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
await db('event_people').where({ id: idB }).del();
// 0.65 to side A — above the 0.6 match threshold, so it would join A —
// and 0.85 to side B, which is where it actually belongs.
const face = new Float32Array(DIM);
face[0] = 0.65; face[1] = 0.553; face[2] = Math.sqrt(1 - 0.65 ** 2 - 0.553 ** 2);
const [p] = await db('photos').insert({
event_id: eventId, filename: 'loose.jpg', path: '/tmp/l.jpg', type: 'individual',
}).returning('id');
const [f] = await db('photo_faces').insert({
photo_id: typeof p === 'object' ? p.id : p, event_id: eventId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
embedding: clustering.packEmbedding(face), model_version: 'test-v1',
created_at: new Date().toISOString(),
}).returning('id');
const assignments = await clustering.assignFaces(
eventId, [{ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(face),
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
{ thresholds: THRESHOLDS },
);
expect(assignments[0].personId).not.toBe(idA);
expect(assignments[0].personId).toBeTruthy();
});
it('binds while the clusters are still being rebuilt one face at a time', async () => {
const eventId = await seedEvent('sep-assign-rebuild');
// recluster() empties event_people and re-assigns from scratch, so for
// the first faces of a batch the "person" on the other side of the
// comparison is a cluster of ONE. A settled centroid it is not, and
// holding it to the strict threshold meant the pair was already merged
// by the time the constraint could bind — with nothing left to split it.
const [sideA, sideB] = pairAtSimilarity(0.7, 0);
const idA = await insertPerson(eventId, sideA);
const idB = await insertPerson(eventId, sideB);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
await db('event_people').where({ event_id: eventId }).del();
// Two faces, one per side, each a little off its own side's centroid —
// 0.91, just under the strict bar — and 0.66 to each other, over the
// match threshold. Exactly the pair that must not re-form.
const off = Math.sqrt(1 - 0.91 ** 2);
const faceA = new Float32Array(DIM);
faceA[0] = 0.91; faceA[3] = off;
const faceB = new Float32Array(DIM);
faceB[0] = 0.91 * 0.7; faceB[1] = 0.91 * Math.sqrt(1 - 0.7 ** 2); faceB[3] = off;
const rows = [];
for (const vec of [faceA, faceB]) {
const [p] = await db('photos').insert({
event_id: eventId, filename: `${Math.random()}.jpg`, path: '/tmp/r.jpg', type: 'individual',
}).returning('id');
const [f] = await db('photo_faces').insert({
photo_id: typeof p === 'object' ? p.id : p, event_id: eventId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
embedding: clustering.packEmbedding(vec), model_version: 'test-v1',
created_at: new Date().toISOString(),
}).returning('id');
rows.push({ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(vec),
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 });
}
// The premise: they are close enough to each other to cluster together.
expect(clustering.dot(faceA, faceB)).toBeGreaterThan(THRESHOLDS.face_match_threshold);
const assignments = await clustering.assignFaces(eventId, rows, { thresholds: THRESHOLDS });
expect(assignments[0].personId).not.toBe(assignments[1].personId);
});
it('leaves ordinary assignment alone when no separation applies', async () => {
const eventId = await seedEvent('sep-assign-clean');
const base = new Float32Array(DIM); base[0] = 1;
const personId = await insertPerson(eventId, base);
const [p] = await db('photos').insert({
event_id: eventId, filename: 'x.jpg', path: '/tmp/x.jpg', type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
const [f] = await db('photo_faces').insert({
photo_id: photoId, event_id: eventId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
embedding: clustering.packEmbedding(base), model_version: 'test-v1',
created_at: new Date().toISOString(),
}).returning('id');
const assignments = await clustering.assignFaces(
eventId, [{ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(base),
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
{ thresholds: THRESHOLDS },
);
// The whole point of the strict threshold: a constraint that fires when
// it should not would quietly wreck ordinary clustering.
expect(assignments[0].personId).toBe(personId);
});
});
});
@@ -0,0 +1,212 @@
/**
* A dropped mount defers a scan; a dead photo fails it.
*
* ensurePreviewImage returns null for both "this JPEG is corrupt" and "the
* NFS share is gone", and #1090 made that distinction matter: external
* libraries now reach this path, and network mounts drop far more often than
* local disks. Failing on an outage strands the photo — faceQueue only ever
* claims 'pending', and nothing re-queues a failure automatically, so a mount
* that blinked mid-scan would cost an entire gallery a manual Re-scan.
*
* The probe checks the containing DIRECTORY rather than the file, because that
* is what separates the two cases: a missing file inside a healthy directory
* is a broken photo, an unreachable directory is broken storage.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-transient-'));
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'transient-test-secret';
// Created BEFORE anything requires externalMediaService: getExternalMediaRoot
// only honours the env var if the directory already exists, and caches the
// result on first call — set it later and every path silently resolves
// against a fallback root instead.
process.env.EXTERNAL_MEDIA_ROOT = path.join(tmpRoot, 'media');
fs.mkdirSync(process.env.EXTERNAL_MEDIA_ROOT, { recursive: true });
let previewKeyResult = null;
const mockEnsurePreviewImage = jest.fn(async () => previewKeyResult);
jest.mock('../../src/services/imageProcessor', () => ({
...jest.requireActual('../../src/services/imageProcessor'),
ensurePreviewImage: (...args) => mockEnsurePreviewImage(...args),
}));
jest.mock('../../src/services/faceClient', () => ({
detectFaces: jest.fn(async () => ({ model_version: 'test-v1', faces: [] })),
SidecarUnavailableError: class extends Error {},
}));
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let faceProcessor;
async function seedExternalPhoto({ externalPath, relpath = 'individual/a.jpg' }) {
const [e] = await db('events').insert({
slug: `tr-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'tr',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `tr-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
source_mode: 'reference',
external_path: externalPath,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const [p] = await db('photos').insert({
event_id: eventId,
filename: 'a.jpg',
path: 'tr/a.jpg',
type: 'individual',
width: 4000,
height: 3000,
processing_status: 'complete',
face_status: 'processing',
source_origin: 'external',
// Stored relative to EXTERNAL_MEDIA_ROOT (#1163), which is what the import
// route writes — `relpath` above is expressed relative to the event's
// folder only because that reads better at the call sites.
external_relpath: path.join(externalPath, relpath),
}).returning('id');
return { eventId, photoId: typeof p === 'object' ? p.id : p };
}
describe('transient source vs dead photo', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await db('feature_flags').insert({ key: 'faces', value: true })
.onConflict('key').merge()
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
faceProcessor = require('../../src/services/faceProcessor');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
});
beforeEach(() => {
previewKeyResult = null; // i.e. ensurePreviewImage could not build one
mockEnsurePreviewImage.mockClear();
});
it('defers, not fails, when the source directory is unreachable', async () => {
// Nothing was ever created under EXTERNAL_MEDIA_ROOT for this path, so the
// directory does not resolve — the shape a dropped mount presents.
const { photoId } = await seedExternalPhoto({ externalPath: 'vanished-share' });
await expect(faceProcessor.processPhotoFaces(photoId))
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
// Critically: still claimable. A 'failed' here is what stranded the photo.
const photo = await db('photos').where({ id: photoId }).first();
expect(photo.face_status).not.toBe('failed');
});
it('defers when the event root survives an unmount but is empty', async () => {
// The common NFS/SMB shape: unmounting leaves the mountpoint behind as an
// ordinary empty directory, so fs.access succeeds on storage that is
// entirely gone. The EVENT ROOT is the thing that goes empty — the photo's
// own subdirectory vanishes with it.
const emptyRoot = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'unmounted');
await fs.promises.mkdir(emptyRoot, { recursive: true });
const { photoId } = await seedExternalPhoto({ externalPath: 'unmounted' });
await expect(faceProcessor.processPhotoFaces(photoId))
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
const photo = await db('photos').where({ id: photoId }).first();
expect(photo.face_status).not.toBe('failed');
});
it('fails when the directory is healthy but the file is gone', async () => {
// Directory exists, file does not — a genuinely broken photo, which should
// surface as a failure the admin can see rather than retry forever.
const live = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'live-share', 'individual');
await fs.promises.mkdir(live, { recursive: true });
// Non-empty: an empty directory is now read as an unmounted share, so the
// "healthy storage, dead photo" case needs a sibling file present.
await fs.promises.writeFile(path.join(live, 'sibling.jpg'), 'x');
const { photoId } = await seedExternalPhoto({ externalPath: 'live-share' });
const result = await faceProcessor.processPhotoFaces(photoId);
expect(result.status).toBe('failed');
const photo = await db('photos').where({ id: photoId }).first();
expect(photo.face_status).toBe('failed');
expect(photo.face_error).toMatch(/preview/i);
});
it('fails a missing subdirectory rather than deferring the whole event', async () => {
// individual/ deleted while collages/ is fine. Probing only the photo's own
// directory reports ENOENT and would read as a mount-wide outage, deferring
// the event and starving every healthy sibling folder. The root is
// populated, so the mount is up and this is a broken path.
const root = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'partial');
await fs.promises.mkdir(path.join(root, 'collages'), { recursive: true });
await fs.promises.writeFile(path.join(root, 'collages', 'kept.jpg'), 'x');
const { photoId } = await seedExternalPhoto({ externalPath: 'partial' });
const result = await faceProcessor.processPhotoFaces(photoId);
expect(result.status).toBe('failed');
});
it('defers a file that exists but cannot be read', async () => {
// EACCES / EIO / ESTALE on the file itself, with the mount up: a transient
// condition wearing a per-file disguise. Only ENOENT means genuinely gone.
const root = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'locked');
const dir = path.join(root, 'individual');
await fs.promises.mkdir(dir, { recursive: true });
const file = path.join(dir, 'a.jpg');
await fs.promises.writeFile(file, 'x');
await fs.promises.chmod(file, 0o000);
const { photoId } = await seedExternalPhoto({ externalPath: 'locked' });
try {
await expect(faceProcessor.processPhotoFaces(photoId))
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
} finally {
await fs.promises.chmod(file, 0o644).catch(() => {});
}
});
it('still fails managed photos without probing the mount', async () => {
// The probe is scoped to external/reference rows: a managed photo with no
// preview is broken, and there is no mount to blame.
const [e] = await db('events').insert({
slug: `tr-m-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'trm',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `tr-m-${Math.random()}`,
expires_at: new Date().toISOString(),
face_recognition_enabled: true,
}).returning('id');
const [p] = await db('photos').insert({
event_id: typeof e === 'object' ? e.id : e,
filename: 'm.jpg',
path: 'trm/m.jpg',
type: 'individual',
width: 100,
height: 100,
processing_status: 'complete',
face_status: 'processing',
source_origin: 'managed',
}).returning('id');
const result = await faceProcessor.processPhotoFaces(typeof p === 'object' ? p.id : p);
expect(result.status).toBe('failed');
});
});
@@ -0,0 +1,221 @@
/**
* Guest filters must respect show_feedback_to_guests (#1044 follow-up).
*
* Every filter token on /photos is an OR of two halves: what THIS viewer
* marked, and what ANYONE marked. The response fields built from the second
* half — like_count, comment_count, color_label_count — are all gated on
* show_feedback_to_guests. The FILTER was not.
*
* So with the setting off, the numbers were hidden but `?filter=liked` still
* returned exactly the photos other people had liked: the same information as
* a set instead of a count, one token at a time. These tests pin the gate on
* every token, and pin that the viewer's own half is never gated — filtering
* by what you yourself marked is yours to do regardless.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'filter-visibility-secret';
const SLUG = 'filter-visibility-event';
const ME = 'guest-me-identifier';
const SOMEONE_ELSE = 'guest-other-identifier';
describe('guest filters and show_feedback_to_guests (#1044)', () => {
let db;
let cleanup;
let app;
let eventId;
let mine;
let theirs;
let myGuestRowId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const setVisibility = (visible) => db('event_feedback_settings')
.where({ event_id: eventId })
.update({ show_feedback_to_guests: visible });
// A real verified guest, which is how the viewer's own feedback is actually
// identified — NOT the `guest_id` query parameter the frontend invents.
const guestToken = () => jwt.sign(
{ type: 'guest', guestId: myGuestRowId, eventId },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const filter = async (token, { as = 'me', claimGuestId } = {}) => {
const req = request(app)
.get(`/api/gallery/${SLUG}/photos`)
.query({ filter: token, ...(claimGuestId ? { guest_id: claimGuestId } : {}) })
.set('Authorization', `Bearer ${galleryToken()}`);
if (as === 'me') req.set('x-guest-token', guestToken());
const res = await req;
expect(res.status).toBe(200);
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Filter Visibility',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'filter-visibility-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const addPhoto = async (name) => {
const p = await db('photos').insert({
event_id: eventId,
filename: name,
path: `events/filter/${name}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
return p[0]?.id ?? p[0];
};
mine = await addPhoto('mine.jpg');
theirs = await addPhoto('theirs.jpg');
await db('event_feedback_settings').insert({
event_id: eventId,
feedback_enabled: true,
allow_likes: true,
allow_comments: true,
allow_ratings: true,
allow_favorites: true,
allow_color_labels: true,
moderate_comments: false,
show_feedback_to_guests: true,
});
const guestRow = await db('gallery_guests').insert({
event_id: eventId,
name: 'Me',
identifier: ME,
created_at: new Date().toISOString(),
last_seen_at: new Date().toISOString(),
is_deleted: false,
}).returning('id');
myGuestRowId = guestRow[0]?.id ?? guestRow[0];
const feedback = (photoId, who, type, extra = {}) => db('photo_feedback').insert({
photo_id: photoId,
event_id: eventId,
guest_identifier: who,
// Submission links to the per-person guest row when one is present, and
// that is the column the viewer's own half resolves through.
guest_id: who === ME ? myGuestRowId : null,
feedback_type: type,
is_approved: true,
is_hidden: false,
created_at: new Date().toISOString(),
...extra,
});
// Everything on `theirs` belongs to somebody else; `mine` is this viewer's.
await feedback(mine, ME, 'like');
await feedback(theirs, SOMEONE_ELSE, 'like');
await feedback(theirs, SOMEONE_ELSE, 'favorite');
await feedback(theirs, SOMEONE_ELSE, 'comment', { comment_text: 'lovely' });
await feedback(theirs, SOMEONE_ELSE, 'rating', { rating: 5 });
await feedback(theirs, SOMEONE_ELSE, 'color_label', { color_label: 'green' });
// The denormalized counters the aggregate half of the filter reads.
await db('photos').where('id', theirs).update({
like_count: 1, favorite_count: 1, comment_count: 1, average_rating: 5, color_label_count: 1,
});
await db('photos').where('id', mine).update({ like_count: 1 });
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('with feedback visible to guests', () => {
beforeAll(() => setVisibility(true));
it('shows other people\'s marks through every token, as before', async () => {
expect(await filter('liked')).toEqual([mine, theirs].sort((a, b) => a - b));
expect(await filter('favorited')).toEqual([theirs]);
expect(await filter('rated')).toEqual([theirs]);
expect(await filter('commented')).toEqual([theirs]);
expect(await filter('color:green')).toEqual([theirs]);
});
});
describe('with feedback hidden from guests', () => {
beforeAll(() => setVisibility(false));
it('stops every token from selecting on other people\'s marks', async () => {
// `theirs` is the photo only other guests marked. It must not come back
// through any token — a filter that selects on hidden feedback reports
// that feedback just as surely as a count would.
expect(await filter('favorited')).toEqual([]);
expect(await filter('rated')).toEqual([]);
expect(await filter('commented')).toEqual([]);
expect(await filter('color:green')).toEqual([]);
});
it('still filters by what the viewer marked themselves', async () => {
// The viewer's own half is never gated: this is their own action, and
// hiding it would break "show me the ones I liked" for no privacy gain.
expect(await filter('liked')).toEqual([mine]);
});
it('drops the viewer\'s own feedback once an admin hides it', async () => {
// Moderation has to reach the filter too. getPhotoFeedback excludes
// hidden rows for the guest's OWN feedback, so a photo matching here
// would come back with nothing visible on it to explain why.
await db('photo_feedback')
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
.update({ is_hidden: true });
expect(await filter('liked')).toEqual([]);
await db('photo_feedback')
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
.update({ is_hidden: false });
expect(await filter('liked')).toEqual([mine]);
});
it('ignores a guest_id supplied by the caller', async () => {
// The own-half is resolved from the request identity. If it honoured the
// query string instead, anyone holding another guest's identifier could
// read that guest's hidden memberships one token at a time — straight
// back through the gate this file exists to pin.
expect(await filter('favorited', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
expect(await filter('color:green', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
// And an anonymous caller claiming to be me gets nothing of mine.
expect(await filter('liked', { as: 'anon', claimGuestId: ME })).toEqual([]);
});
});
});
@@ -0,0 +1,121 @@
/**
* Gallery password invisible-Unicode fallback (#654).
*
* Passwords relayed through chat apps (Instagram DMs especially) pick up
* invisible characters on copy-paste — zero-width space/joiners, word
* joiner, BOM, soft hyphen — which fail the byte-exact bcrypt compare and
* surface as "incorrect password" for a correct password. The verify route
* retries the compare with those characters stripped, in the SAME request,
* so the fallback costs no reCAPTCHA token and no failed-attempt quota.
*
* Pins the contract:
* - exact submitted bytes always win first, so stored passwords that
* legitimately contain these characters (e.g. ZWJ emoji sequences)
* keep working
* - paste artifacts (mid-string ZWSP, leading BOM, trailing space) are
* rescued by the sanitized fallback compare
* - the fallback never invents a match (missing ZWJ still 401s), and a
* rescued login records no failed attempt
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sanitize-test-secret';
const PLAIN_SLUG = 'sanitize-plain-event';
const ZWJ_SLUG = 'sanitize-zwj-event';
const PLAIN_PASSWORD = 'wedding2026';
// Stored password legitimately containing a ZWJ emoji sequence.
const ZWJ_PASSWORD = 'Family\u{1F468}\u200D\u{1F469}Aa1';
describe('gallery/verify invisible-Unicode fallback (#654)', () => {
let db;
let cleanup;
let app;
const makeEvent = async (slug, password) => {
const inserted = await db('events').insert({
slug,
event_type: 'wedding',
event_name: `Sanitize ${slug}`,
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: await bcrypt.hash(password, 4),
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-share`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return inserted[0]?.id ?? inserted[0];
};
let plainEventId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
plainEventId = await makeEvent(PLAIN_SLUG, PLAIN_PASSWORD);
await makeEvent(ZWJ_SLUG, ZWJ_PASSWORD);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', require('../../src/routes/auth'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const verify = (slug, password) =>
request(app).post('/api/auth/gallery/verify').send({ slug, password });
it('accepts the exact password', async () => {
const res = await verify(PLAIN_SLUG, PLAIN_PASSWORD);
expect(res.status).toBe(200);
expect(res.body.token).toBeTruthy();
});
it('rescues a mid-string zero-width space from chat-app copy-paste', async () => {
const res = await verify(PLAIN_SLUG, 'wedding\u200B2026');
expect(res.status).toBe(200);
expect(res.body.token).toBeTruthy();
});
it('rescues leading BOM + trailing space paste artifacts', async () => {
const res = await verify(PLAIN_SLUG, `\uFEFF${PLAIN_PASSWORD} `);
expect(res.status).toBe(200);
expect(res.body.token).toBeTruthy();
});
it('records no login_fail for a rescued login (single-request fallback)', async () => {
await verify(PLAIN_SLUG, 'wedding\u200B2026').expect(200);
const failed = await db('access_logs')
.where({ event_id: plainEventId, action: 'login_fail' });
expect(failed).toHaveLength(0);
});
it('still accepts a stored password that legitimately contains a ZWJ', async () => {
const res = await verify(ZWJ_SLUG, ZWJ_PASSWORD);
expect(res.status).toBe(200);
expect(res.body.token).toBeTruthy();
});
it('does not invent a match when the ZWJ is missing from the input', async () => {
const res = await verify(ZWJ_SLUG, 'Family\u{1F468}\u{1F469}Aa1');
expect(res.status).toBe(401);
});
it('rejects a plain wrong password', async () => {
const res = await verify(PLAIN_SLUG, 'not-the-password');
expect(res.status).toBe(401);
});
});
@@ -17,7 +17,7 @@ const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
jest.setTimeout(120000);
let db; let cleanup; let service; let app;
@@ -19,7 +19,7 @@
*/
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
jest.setTimeout(120000);
let db; let cleanup; let service; let adminId;
@@ -0,0 +1,229 @@
/**
* Spotting one person registered twice (#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 — so the photographer's
* "final selection" is only trustworthy after someone notices two Tinas with
* half the picks each and merges them.
*
* Merging already worked. This is the half that was missing: saying which rows
* are the same person, so the admin does not have to find them by eye.
*
* Detection only — the registration path is deliberately untouched. Reusing a
* row because someone typed a matching email would let anyone who knows that
* email inherit the identity and its selections, and answering differently for
* a known email would leak which addresses are in the gallery, which is
* exactly what /guest/recover goes out of its way to avoid.
*/
const request = require('supertest');
const express = require('express');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
describe('duplicate guest detection (#1210)', () => {
let db; let cleanup; let app; let eventId;
const listGuests = async () => {
const res = await request(app).get(`/api/admin/events/${eventId}/guests`);
expect(res.status).toBe(200);
return res.body;
};
const addGuest = async (name, email, extra = {}) => {
const [g] = await db('gallery_guests').insert({
event_id: eventId, name, email,
identifier: `id-${name}-${Math.random()}`,
created_at: new Date().toISOString(),
last_seen_at: new Date().toISOString(),
is_deleted: false,
...extra,
}).returning('id');
return typeof g === 'object' ? g.id : g;
};
const byId = (body, id) => body.guests.find((g) => g.id === id);
beforeAll(async () => {
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const [ev] = await db('events').insert({
slug: 'dupe-guests', event_type: 'wedding', event_name: 'Dupe Guests',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/dupe-guests/share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
app = express();
app.use(express.json());
app.use('/api/admin', require('../../src/routes/adminGuests'));
}, 180000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('gallery_guests').where({ event_id: eventId }).del();
});
it('groups duplicates under a shared key', async () => {
const first = await addGuest('Tina', 'tina@example.com');
const second = await addGuest('Tina', 'tina@example.com');
const other = await addGuest('Marc', 'marc@example.com');
const body = await listGuests();
// A shared group key rather than a list of sibling ids: the payload stays
// linear in the number of guests, and the case/whitespace folding lives in
// one place instead of being reimplemented on the client.
expect(byId(body, first).duplicate_group).toBe('tina@example.com');
expect(byId(body, second).duplicate_group).toBe('tina@example.com');
expect(byId(body, other).duplicate_group).toBeNull();
});
it('counts the groups and the rows in them', async () => {
await addGuest('Tina', 'tina@example.com');
await addGuest('Tina', 'tina@example.com');
await addGuest('Ben', 'ben@example.com');
await addGuest('Ben', 'ben@example.com');
await addGuest('Ben', 'ben@example.com');
await addGuest('Marc', 'marc@example.com');
expect((await listGuests()).duplicates).toEqual({ groups: 2, guests: 5 });
});
it('matches the same address typed with different capitals or a stray space', async () => {
// The same person on a different day. Both read as distinct rows in the
// admin list, which is precisely why they need catching here.
const a = await addGuest('Tina', 'tina@example.com');
const b = await addGuest('Tina', 'Tina@Example.com ');
const body = await listGuests();
expect(byId(body, a).duplicate_group).toBe('tina@example.com');
expect(byId(body, b).duplicate_group).toBe('tina@example.com');
expect(body.duplicates).toEqual({ groups: 1, guests: 2 });
});
it('does not treat two guests without an email as the same person', async () => {
// require_name_email is off by default, so a shared gallery link produces
// plenty of these. Grouping them would merge strangers.
const a = await addGuest('Anon', null);
const b = await addGuest('Anon', null);
const body = await listGuests();
expect(byId(body, a).duplicate_group).toBeNull();
expect(byId(body, b).duplicate_group).toBeNull();
expect(body.duplicates).toEqual({ groups: 0, guests: 0 });
});
it('does not treat a shared name as evidence of anything', async () => {
const a = await addGuest('Anna', 'anna.k@example.com');
const b = await addGuest('Anna', 'anna.m@example.com');
const body = await listGuests();
expect(byId(body, a).duplicate_group).toBeNull();
expect(byId(body, b).duplicate_group).toBeNull();
});
it('moves a pending invite to the survivor instead of stranding it', async () => {
// 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 — and
// this feature now points that pair out and offers the merge. Redemption
// resolves guest_invites.guest_id with `is_deleted: false`, so merging
// without moving the invite leaves the emailed link returning 404
// guest_missing while the invite dialog still shows it as Pending.
const placeholder = await addGuest('Tina', 'tina@example.com');
const selfRegistered = await addGuest('Tina', 'tina@example.com');
const [inv] = await db('guest_invites').insert({
event_id: eventId, guest_id: placeholder, token: 'invite-token-1',
created_by_admin_id: 1, created_at: new Date().toISOString(),
}).returning('id');
const inviteId = typeof inv === 'object' ? inv.id : inv;
const res = await request(app)
.post(`/api/admin/events/${eventId}/guests/${selfRegistered}/merge`)
.send({ mergeIds: [placeholder] });
expect(res.status).toBe(200);
const invite = await db('guest_invites').where({ id: inviteId }).first();
expect(invite.guest_id).toBe(selfRegistered);
// And it still resolves: the survivor is not soft-deleted.
const target = await db('gallery_guests').where({ id: invite.guest_id }).first();
expect(Boolean(target.is_deleted)).toBe(false);
});
it('leaves a spent invite pointing at what it actually redeemed', async () => {
// A redeemed invite is a record of who redeemed what. Retargeting it would
// rewrite that history to name a guest who was never on the other end.
const old = await addGuest('Tina', 'tina@example.com');
const kept = await addGuest('Tina', 'tina@example.com');
const [inv] = await db('guest_invites').insert({
event_id: eventId, guest_id: old, token: 'invite-token-2',
created_by_admin_id: 1, redeemed_at: new Date().toISOString(),
created_at: new Date().toISOString(),
}).returning('id');
const inviteId = typeof inv === 'object' ? inv.id : inv;
await request(app)
.post(`/api/admin/events/${eventId}/guests/${kept}/merge`)
.send({ mergeIds: [old] });
expect((await db('guest_invites').where({ id: inviteId }).first()).guest_id).toBe(old);
});
it('canonicalises the survivor\'s address so recovery can still find them', async () => {
// Grouping folds case and whitespace, so a merge can be proposed between a
// clean address and a legacy one that is not. /guest/recover lowercases
// what the guest types and matches on equality, so a survivor left holding
// the raw value becomes permanently unrecoverable by email.
const legacy = await addGuest('Tina', 'Tina@Example.com ');
const other = await addGuest('Tina', 'tina@example.com');
const res = await request(app)
.post(`/api/admin/events/${eventId}/guests/${legacy}/merge`)
.send({ mergeIds: [other] });
expect(res.status).toBe(200);
expect((await db('gallery_guests').where({ id: legacy }).first()).email).toBe('tina@example.com');
});
it('leaves an already-canonical survivor untouched', async () => {
const kept = await addGuest('Tina', 'tina@example.com');
const dupe = await addGuest('Tina', 'Tina@Example.com');
await request(app)
.post(`/api/admin/events/${eventId}/guests/${kept}/merge`)
.send({ mergeIds: [dupe] });
expect((await db('gallery_guests').where({ id: kept }).first()).email).toBe('tina@example.com');
});
it('ignores a removed guest', async () => {
const kept = await addGuest('Tina', 'tina@example.com');
await addGuest('Tina', 'tina@example.com', { is_deleted: true });
const body = await listGuests();
// The deleted row is not listed at all, so the survivor is not a duplicate
// of something the admin cannot see or merge.
expect(body.guests.map((g) => g.id)).toEqual([kept]);
expect(byId(body, kept).duplicate_group).toBeNull();
expect(body.duplicates).toEqual({ groups: 0, guests: 0 });
});
});
@@ -0,0 +1,167 @@
/**
* Minimal in-process OIDC provider for integration tests (#798).
*
* Serves just enough of the spec for openid-client's full validation to
* pass: discovery, JWKS (RS256), authorization endpoint (immediate redirect,
* no login UI), and token endpoint (authorization_code + PKCE). Claims for
* the next login are scripted per test via `setNextUser()`.
*
* Runs on an ephemeral localhost port over plain http — the service allows
* that in NODE_ENV=test only.
*/
const http = require('http');
const crypto = require('crypto');
const { URL } = require('url');
function b64url(input) {
return Buffer.from(input).toString('base64url');
}
class MockOidcProvider {
constructor() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
this.privateKey = privateKey;
this.publicJwk = publicKey.export({ format: 'jwk' });
this.publicJwk.kid = 'test-key-1';
this.publicJwk.alg = 'RS256';
this.publicJwk.use = 'sig';
this.clientId = 'picpeak-test';
this.clientSecret = 'test-client-secret';
this.codes = new Map(); // code -> { nonce, redirectUri, codeChallenge, user }
this.nextUser = { sub: 'user-1', email: 'sso@example.com', email_verified: true };
// Test hooks:
this.tamperNonce = false; // sign the ID token with a WRONG nonce
this.emailViaUserinfoOnly = false; // omit email from the ID token; serve it on /userinfo
this.advertiseEndSession = true; // include end_session_endpoint in discovery (#798 phase 3)
this.accessTokens = new Map(); // access_token -> user (for /userinfo)
this.server = null;
this.issuer = null;
}
setNextUser(user) {
this.nextUser = user;
}
signIdToken({ sub, nonce, extraClaims = {} }) {
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'RS256', kid: this.publicJwk.kid, typ: 'JWT' };
const payload = {
iss: this.issuer,
aud: this.clientId,
sub,
iat: now,
exp: now + 300,
nonce,
...extraClaims,
};
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), this.privateKey);
return `${signingInput}.${signature.toString('base64url')}`;
}
async start() {
this.server = http.createServer((req, res) => this.handle(req, res));
await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve));
this.issuer = `http://127.0.0.1:${this.server.address().port}`;
return this.issuer;
}
async stop() {
if (this.server) await new Promise((resolve) => this.server.close(resolve));
}
handle(req, res) {
const url = new URL(req.url, this.issuer);
const json = (status, body) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (url.pathname === '/.well-known/openid-configuration') {
return json(200, {
issuer: this.issuer,
authorization_endpoint: `${this.issuer}/authorize`,
token_endpoint: `${this.issuer}/token`,
userinfo_endpoint: `${this.issuer}/userinfo`,
jwks_uri: `${this.issuer}/jwks`,
...(this.advertiseEndSession ? { end_session_endpoint: `${this.issuer}/logout` } : {}),
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
});
}
if (url.pathname === '/jwks') {
return json(200, { keys: [this.publicJwk] });
}
if (url.pathname === '/authorize') {
// "Log in" instantly: mint a code bound to this request's params and
// bounce back to the redirect_uri like a real IdP would.
const code = crypto.randomBytes(16).toString('base64url');
this.codes.set(code, {
nonce: url.searchParams.get('nonce'),
redirectUri: url.searchParams.get('redirect_uri'),
codeChallenge: url.searchParams.get('code_challenge'),
user: this.nextUser,
});
const back = new URL(url.searchParams.get('redirect_uri'));
back.searchParams.set('code', code);
back.searchParams.set('state', url.searchParams.get('state'));
res.writeHead(302, { location: back.href });
return res.end();
}
if (url.pathname === '/token' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const params = new URLSearchParams(body);
const stored = this.codes.get(params.get('code'));
if (!stored) return json(400, { error: 'invalid_grant' });
this.codes.delete(params.get('code'));
// PKCE check — S256(code_verifier) must match the challenge.
const verifier = params.get('code_verifier') || '';
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
if (challenge !== stored.codeChallenge) {
return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
}
const { sub, ...extraClaims } = stored.user;
// Spec-compliant providers may keep profile/email claims OFF the ID
// token and serve them from /userinfo only — this hook simulates that.
const idTokenClaims = this.emailViaUserinfoOnly ? {} : extraClaims;
const idToken = this.signIdToken({
sub,
nonce: this.tamperNonce ? 'tampered-nonce' : stored.nonce,
extraClaims: idTokenClaims,
});
const accessToken = crypto.randomBytes(16).toString('base64url');
this.accessTokens.set(accessToken, stored.user);
return json(200, {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 300,
id_token: idToken,
});
});
return undefined;
}
if (url.pathname === '/userinfo') {
const auth = req.headers.authorization || '';
const user = this.accessTokens.get(auth.replace(/^Bearer\s+/i, ''));
if (!user) return json(401, { error: 'invalid_token' });
return json(200, { ...user });
}
return json(404, { error: 'not_found' });
}
}
module.exports = { MockOidcProvider };
@@ -0,0 +1,283 @@
/**
* Hidden feedback, seen from the guest who left it (#1150).
*
* Everything in the system treats a hidden row as absent: getPhotoFeedback
* drops it even for the guest's own feedback, the /photos filters drop it, and
* updatePhotoFeedbackStats does not count it. Two places disagreed — the
* per-viewer `is_liked` heart and the `my_color_label` badge — so a like the
* photographer had hidden still showed as liked on a photo whose like_count
* was zero.
*
* Making those two agree exposes the second half: the duplicate check that
* powers like/favorite toggling did NOT skip hidden rows, so the now-empty
* heart, when clicked, found the hidden row and toggled it OFF. The click
* appeared to do nothing and it took two more to get back to a filled heart.
*
* Hiding a non-comment is deliberate, not an accident of the raw route: #839
* and #1044 both ship it, with tests asserting that a hidden reaction or
* colour label stops counting. So the fix is to make hidden mean absent
* consistently — not to stop admins hiding these.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-feedback-secret';
const SLUG = 'hidden-own-feedback';
const ME = 'guest-me-identifier';
describe('a guest\'s own hidden feedback (#1150)', () => {
let db; let cleanup; let app; let feedbackService;
let eventId; let photoId; let myGuestRowId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const guestToken = () => jwt.sign(
{ type: 'guest', guestId: myGuestRowId, eventId },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const getPhoto = async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`)
.set('x-guest-token', guestToken());
expect(res.status).toBe(200);
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
return (photos || []).find((p) => p.id === photoId);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
feedbackService = require('../../src/services/feedbackService');
const [ev] = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Hidden Own Feedback',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'hidden-own-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [p] = await db('photos').insert({
event_id: eventId, filename: 'shot.jpg', path: 'events/hidden/shot.jpg',
type: 'individual', uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = typeof p === 'object' ? p.id : p;
const [g] = await db('gallery_guests').insert({
event_id: eventId, name: 'Me', identifier: ME,
created_at: new Date().toISOString(), last_seen_at: new Date().toISOString(),
is_deleted: false,
}).returning('id');
myGuestRowId = typeof g === 'object' ? g.id : g;
await db('event_feedback_settings').insert({
event_id: eventId, feedback_enabled: true, allow_likes: true,
allow_color_labels: true, moderate_comments: false,
show_feedback_to_guests: true,
});
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
}, 180000);
afterAll(async () => { if (cleanup) await cleanup(); });
const like = () => db('photo_feedback').insert({
photo_id: photoId, event_id: eventId, guest_identifier: ME,
guest_id: myGuestRowId, feedback_type: 'like',
is_approved: true, is_hidden: false, created_at: new Date().toISOString(),
});
beforeEach(async () => {
await db('photo_feedback').where({ photo_id: photoId }).del();
await db('photos').where('id', photoId).update({ like_count: 0, color_label_count: 0 });
});
describe('the read surfaces agree with each other', () => {
it('un-fills the heart once the like is hidden', async () => {
await like();
await feedbackService.updatePhotoFeedbackStats(photoId);
expect((await getPhoto()).is_liked).toBe(true);
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
await feedbackService.updatePhotoFeedbackStats(photoId);
const photo = await getPhoto();
// like_count already ignored hidden rows, so the heart was the only
// thing still claiming this photo was liked.
expect(photo.like_count).toBe(0);
expect(photo.is_liked).toBe(false);
});
it('drops a hidden colour label from the badge', async () => {
await db('photo_feedback').insert({
photo_id: photoId, event_id: eventId, guest_identifier: ME,
guest_id: myGuestRowId, feedback_type: 'color_label', color_label: 'green',
is_approved: true, is_hidden: true, created_at: new Date().toISOString(),
});
expect((await getPhoto()).my_color_label).toBeFalsy();
});
});
describe('and every other surface agrees', () => {
it('keeps a hidden like out of /my-feedback', async () => {
await like();
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
const res = await request(app)
.get(`/api/gallery/${SLUG}/my-feedback`)
.set('Authorization', `Bearer ${galleryToken()}`)
.set('x-guest-token', guestToken());
expect(res.status).toBe(200);
// In guest identity mode the Liked/Favorited/Rated chips and their
// filters are built from THIS array, not from is_liked — so a hidden
// like left an empty heart while the chip still counted it.
expect(res.body.filter((f) => f.feedback_type === 'like')).toHaveLength(0);
});
it('does not count a hidden row against the guest cap', async () => {
await db('event_feedback_settings')
.where({ event_id: eventId }).update({ max_likes_per_guest: 1 });
await like();
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
// The hidden row is room, not an occupant: the guest sees an empty
// heart, and meeting that click with limit_reached leaves the control
// dead until they un-like something they can still see.
const result = await feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
});
expect(result.limit_reached).toBeUndefined();
await db('event_feedback_settings')
.where({ event_id: eventId }).update({ max_likes_per_guest: null });
});
it('keeps the hidden record when the guest changes their replacement', async () => {
// A hidden colour label and a visible replacement now coexist. The
// toggle/switch and rating-clear paths DELETE over the guest-scoped set,
// so an unfiltered scope took the admin's record with it — leaving
// nothing to review or unhide.
const [orig] = await db('photo_feedback').insert({
photo_id: photoId, event_id: eventId, guest_identifier: ME,
guest_id: myGuestRowId, feedback_type: 'color_label', color_label: 'red',
is_approved: true, is_hidden: true, created_at: new Date().toISOString(),
}).returning('id');
const hiddenId = typeof orig === 'object' ? orig.id : orig;
// The guest, seeing no label, picks green, then switches to blue, then
// toggles blue off — every mutation the single-value path offers.
const opts = { feedback_type: 'color_label', guest_identifier: ME, guest_id: myGuestRowId };
await feedbackService.submitFeedback(photoId, eventId, { ...opts, color_label: 'green' });
await feedbackService.submitFeedback(photoId, eventId, { ...opts, color_label: 'blue' });
await feedbackService.submitFeedback(photoId, eventId, { ...opts, color_label: 'blue' });
const survivor = await db('photo_feedback').where('id', hiddenId).first();
expect(survivor).toBeTruthy();
expect(survivor.is_hidden).toBeTruthy();
expect(survivor.color_label).toBe('red');
});
it('leaves other anonymous rows alone when there is no identity to scope by', async () => {
// With neither guest_id nor guest_identifier the collapse scope degrades
// to `guest_identifier IS NULL` — every identifier-less row on the
// photo, i.e. other people's. Verified: knex renders that as `is null`.
const anon = (extra) => ({
photo_id: photoId, event_id: eventId, feedback_type: 'like',
is_approved: true, created_at: new Date().toISOString(), ...extra,
});
const [h] = await db('photo_feedback').insert(anon({ is_hidden: true })).returning('id');
const hiddenId = typeof h === 'object' ? h.id : h;
await db('photo_feedback').insert(anon({ is_hidden: false }));
await db('photo_feedback').insert(anon({ is_hidden: false }));
await feedbackService.moderateFeedback(hiddenId, 'approve', 1);
// All three survive: two unrelated visitors plus the unhidden one.
expect(await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false }))
.toHaveLength(3);
});
it('collapses the replacement when an admin unhides the original', async () => {
await like();
const original = await db('photo_feedback').where({ photo_id: photoId }).first();
await db('photo_feedback').where('id', original.id).update({ is_hidden: true });
// The guest, seeing an empty heart, likes again — a second row.
await feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
});
expect(await db('photo_feedback').where({ photo_id: photoId })).toHaveLength(2);
await feedbackService.moderateFeedback(original.id, 'approve', 1);
// Two visible rows for one guest would double-count in the tallies and
// need two toggles to clear, since each deletes a single row.
const visible = await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
expect(visible).toHaveLength(1);
expect(visible[0].id).toBe(original.id);
await feedbackService.updatePhotoFeedbackStats(photoId);
expect((await db('photos').where('id', photoId).first()).like_count).toBe(1);
});
});
describe('and clicking still works afterwards', () => {
it('re-liking creates a fresh row instead of toggling the hidden one off', async () => {
await like();
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
// What the guest sees is an empty heart, so this is an ADD.
const result = await feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like',
guest_identifier: ME,
guest_id: myGuestRowId,
});
// Before this, the duplicate check found the hidden row and deleted it —
// `removed: true` — so the click did nothing visible and the moderation
// was silently undone.
expect(result.removed).toBeUndefined();
const visible = await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
expect(visible).toHaveLength(1);
expect((await getPhoto()).is_liked).toBe(true);
});
});
});
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
// on first use; bump the budget for this file.
jest.setTimeout(60000);
jest.setTimeout(120000);
describe('incoming-invoice categorise / re-bill chain', () => {
let db;
@@ -32,7 +32,7 @@ jest.mock('../../src/services/restoreService', () => ({
},
}));
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('installFromBackupBoot', () => {
let db;
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
jest.setTimeout(120000);
let db;
let cleanup;
@@ -0,0 +1,236 @@
/**
* Shared run state for the maintenance sweeps (#1181).
*
* The behaviour that matters here cannot be observed from one process holding
* a module-level flag, which is exactly why the flag moved into the database.
* A second replica is simulated the only way that is honest in a single-process
* test: by asserting on the shared row itself, and by driving claim() twice —
* a second caller getting null is precisely what a second replica gets.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('maintenance job state (#1181)', () => {
let tmpDir; let db; let app; let jobs;
const dimStatus = () => request(app).get('/api/admin/photos/repair-dimensions/status');
const capStatus = () => request(app).get('/api/admin/photos/repair-capture-dates/status');
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mjs-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mjs-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
jobs = require('../../src/services/maintenanceJobState');
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
await db('maintenance_jobs').update({
is_running: false, started_at: null, heartbeat_at: null, finished_at: null, last_result: null, owner: null, claim_token: null,
});
});
test('the lease table is kept out of .picpeak archives', () => {
// It is live state, not data. An archive taken mid-sweep would otherwise
// carry is_running = true and a claim token owned by a process on the
// SOURCE install; restored inside the staleness window, the target reports
// the job as running and refuses new POSTs with no runner to release it.
// The importer filters on this same set, so archives written before the
// exclusion are skipped on restore too.
const { EXCLUDED_TABLES } = require('../../src/services/picpeakExportService');
expect(EXCLUDED_TABLES.has('maintenance_jobs')).toBe(true);
});
test('the migration seeds a row for each job', async () => {
const names = await db('maintenance_jobs').pluck('job_name');
// 190 seeds the orientation backfill alongside 189's two (#1198).
expect(names.sort()).toEqual([
'photo_capture_date_backfill', 'photo_dimension_repair', 'photo_orientation_backfill',
]);
});
test('a second claim is refused while the first is alive', async () => {
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
// What a second replica's POST does. Nothing about the first claim lives in
// this process, so this is the same question the other replica asks.
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
});
test('the two jobs claim independently', async () => {
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
expect(await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL)).toEqual(expect.any(String));
});
test('each claim gets a distinct token', async () => {
const first = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
await jobs.release(jobs.JOB_DIMENSION_REPAIR, first);
const second = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
// Same process, same pid — so an owner string would have collided here and
// the fencing below would be worthless.
expect(second).not.toBe(first);
});
test('a claim whose heartbeat has gone quiet can be taken over', async () => {
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
// The replica holding it was killed: no release, no further heartbeats.
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
});
test('a superseded runner cannot renew its lease', async () => {
const oldToken = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
const newToken = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
expect(newToken).toEqual(expect.any(String));
// The old runner is still alive and mid-loop. Its renewal must tell it so,
// which is what makes the route loop stop instead of running alongside the
// new owner.
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, oldToken)).toBe(false);
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, newToken)).toBe(true);
});
test('a superseded runner cannot release the new owner\'s claim', async () => {
const oldToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_CAPTURE_DATE_BACKFILL }).update({ heartbeat_at: longAgo });
const newToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
// The old runner finishes late and tries to write its result. Unfenced,
// this cleared is_running under the new owner and let a THIRD sweep start.
expect(await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, oldToken, { success: 999, noExif: 0, failed: 0 })).toBe(false);
const state = await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL);
expect(state.isRunning).toBe(true);
expect(state.lastResult).toBeNull();
// And the row is still the new owner's to release.
expect(await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, newToken, { success: 1, noExif: 0, failed: 0 })).toBe(true);
});
test('a stale run reads as not running, so the button comes back', async () => {
await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(true);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
// is_running is still true in the row — nothing released it — but a status
// poll must not leave the operator staring at a job that cannot finish.
expect((await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).first()).is_running).toBeTruthy();
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(false);
});
test('a heartbeat keeps a long run claimed', async () => {
const token = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, token)).toBe(true);
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(true);
});
test('release stores the result and read gives it back parsed', async () => {
const token = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, token, { success: 3, noExif: 2, failed: 1 });
const state = await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL);
expect(state.isRunning).toBe(false);
expect(state.lastResult).toEqual({ success: 3, noExif: 2, failed: 1 });
});
test('releasing without a result keeps the previous run visible', async () => {
const first = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, first, { success: 7, noExif: 0, failed: 0 });
// The "nothing to do" path: claimed, found no candidates, released. It must
// not blank the numbers the last real run reported.
const second = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, second);
expect((await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL)).lastResult).toEqual({ success: 7, noExif: 0, failed: 0 });
});
test('a malformed result does not take the status endpoint down', async () => {
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ last_result: 'not json' });
const state = await jobs.read(jobs.JOB_DIMENSION_REPAIR);
expect(state.lastResult).toBeNull();
expect(state.isRunning).toBe(false);
});
test('both status endpoints report the shared row, not process memory', async () => {
await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
const capToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, capToken, { success: 1, noExif: 0, failed: 0 });
// Written straight to the row, exactly as another replica would have.
const dim = await dimStatus();
expect(dim.status).toBe(200);
expect(dim.body.isRunning).toBe(true);
const cap = await capStatus();
expect(cap.status).toBe(200);
expect(cap.body.isRunning).toBe(false);
expect(cap.body.lastResult).toEqual({ success: 1, noExif: 0, failed: 0 });
});
test('a POST is refused while another replica holds the claim', async () => {
// The claim was taken by "another replica" — this process knows nothing
// about it beyond the row.
await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.status).toBe(409);
const dimRes = await request(app).post('/api/admin/photos/repair-dimensions');
// The other job is untouched by that claim, so it is free to start.
expect(dimRes.status).toBe(200);
});
test('the no-op path releases the claim it took', async () => {
// No photos at all, so both endpoints take their "nothing to do" exit.
await db('photos').del();
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
const row = await db('maintenance_jobs').where({ job_name: jobs.JOB_CAPTURE_DATE_BACKFILL }).first();
expect(row.is_running).toBeFalsy();
// ...and a second POST is therefore accepted rather than 409ing forever.
expect((await request(app).post('/api/admin/photos/repair-capture-dates')).status).toBe(200);
});
});
@@ -0,0 +1,102 @@
/**
* PostgreSQL checks for the shared maintenance-job state (#1181).
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway database, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_mjs_test" \
* npx jest __tests__/integration/maintenanceJobStatePg.test.js
*
* What SQLite cannot answer: the claim leans on comparing a `timestamp` column
* against an ISO-8601 string, and on an UPDATE ... WHERE guard being atomic
* under real concurrent connections. SQLite compares those strings
* lexicographically and serialises writes anyway, so it would pass either way —
* exactly the shape of divergence that has bitten this repo before.
*/
const knex = require('knex');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('maintenance job state on Postgres', () => {
let pgDb;
let jobs;
const JOB = 'photo_dimension_repair';
beforeAll(async () => {
pgDb = knex({ client: 'pg', connection: PG_URL, pool: { min: 0, max: 10 } });
await pgDb.raw('DROP TABLE IF EXISTS maintenance_jobs');
await require('../../migrations/core/189_maintenance_job_state').up(pgDb);
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
jobs = require('../../src/services/maintenanceJobState');
}, 60000);
afterAll(async () => {
jest.dontMock('../../src/database/db');
if (pgDb) await pgDb.destroy();
});
beforeEach(async () => {
await pgDb('maintenance_jobs').update({
is_running: false, started_at: null, heartbeat_at: null, finished_at: null, last_result: null, owner: null, claim_token: null,
});
});
test('the ISO-string cutoff really compares as a timestamp, not as text', async () => {
expect(await jobs.claim(JOB)).toEqual(expect.any(String));
expect(await jobs.claim(JOB)).toBeNull();
await pgDb('maintenance_jobs').where({ job_name: JOB })
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString() });
// If Postgres had rejected or mis-cast the ISO string this would either
// throw or never match.
expect(await jobs.claim(JOB)).toEqual(expect.any(String));
const row = await pgDb('maintenance_jobs').where({ job_name: JOB }).first();
expect(row.heartbeat_at).toBeInstanceOf(Date);
});
test('concurrent claims on real connections produce exactly one winner', async () => {
// The whole point of the conditional UPDATE. Ten connections race; nine
// must lose. SQLite cannot demonstrate this — it serialises writers.
const results = await Promise.all(Array.from({ length: 10 }, () => jobs.claim(JOB)));
expect(results.filter(Boolean)).toHaveLength(1);
// ...and the winner holds a token nobody else can forge.
expect(results.find(Boolean)).toEqual(expect.any(String));
});
test('a released job can be re-claimed exactly once again', async () => {
const token = await jobs.claim(JOB);
await jobs.release(JOB, token, { success: 2, failed: 0 });
const results = await Promise.all(Array.from({ length: 5 }, () => jobs.claim(JOB)));
expect(results.filter(Boolean)).toHaveLength(1);
expect((await jobs.read(JOB)).lastResult).toEqual({ success: 2, failed: 0 });
});
test('a superseded runner is fenced out on real Postgres', async () => {
const oldToken = await jobs.claim(JOB);
await pgDb('maintenance_jobs').where({ job_name: JOB })
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString() });
const newToken = await jobs.claim(JOB);
expect(await jobs.heartbeat(JOB, oldToken)).toBe(false);
expect(await jobs.release(JOB, oldToken, { success: 999, failed: 0 })).toBe(false);
// The new owner still holds it, with its result unwritten.
expect((await jobs.read(JOB)).isRunning).toBe(true);
expect(await jobs.release(JOB, newToken, { success: 4, failed: 0 })).toBe(true);
});
test('read() reports a live claim as running and a stale one as not', async () => {
await jobs.claim(JOB);
expect((await jobs.read(JOB)).isRunning).toBe(true);
await pgDb('maintenance_jobs').where({ job_name: JOB })
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 1000).toISOString() });
expect((await jobs.read(JOB)).isRunning).toBe(false);
});
});
@@ -0,0 +1,284 @@
/**
* OIDC logout-to-IdP integration tests (#798 phase 3).
*
* Same full-stack shape as oidcSso.test.js: real routes over a mock
* in-process IdP, genuine discovery/JWKS/PKCE via openid-client. Pins:
*
* - the SSO callback stores the raw ID token in the oidc_id_token cookie
* - /logout with that cookie + oidc_logout_from_idp=true returns the
* IdP end-session URL (id_token_hint, post_logout_redirect_uri,
* client_id) and clears the cookie
* - feature off → no ssoLogoutUrl even for an SSO session
* - no oidc_id_token cookie (local-password session) → no ssoLogoutUrl
* even with the feature on — local sessions never bounce to the IdP
* - IdP without an end_session_endpoint → no ssoLogoutUrl, logout still 200
* - settings surface: GET exposes the flag + post_logout_redirect_uri,
* PUT persists the flag
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const { bootCrmDb } = require('./helpers/crmDb');
const { MockOidcProvider } = require('./helpers/mockOidcProvider');
describe('OIDC logout-to-IdP (#798 phase 3)', () => {
let db;
let cleanup;
let app;
let idp;
let oidcService;
beforeAll(async () => {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-logout-test-secret';
process.env.FRONTEND_URL = 'http://localhost:5199';
({ db, cleanup } = await bootCrmDb());
idp = new MockOidcProvider();
const issuer = await idp.start();
oidcService = require('../../src/services/oidcService');
await oidcService.saveOidcSettings({
oidc_enabled: true,
oidc_issuer_url: issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
oidc_autoprovision: true,
oidc_default_role: 'viewer',
oidc_logout_from_idp: true,
});
const authRouter = require('../../src/routes/auth');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRouter);
}, 120000);
afterAll(async () => {
if (idp) await idp.stop();
if (cleanup) await cleanup();
});
/** Drive login → IdP → callback like a browser; returns the callback response. */
async function ssoRoundTrip() {
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
const stateCookie = (loginRes.headers['set-cookie'] || [])
.find((c) => c.startsWith('oidc_state='))
.split(';')[0];
const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' });
expect(idpRes.status).toBe(302);
const back = new URL(idpRes.headers.get('location'));
return request(app)
.get(`${back.pathname}?${back.searchParams.toString()}`)
.set('Cookie', stateCookie)
.expect(302);
}
/**
* The oidc_id_token cookie pair ("oidc_id_token=<jwt>") from a callback
* response. The callback carries TWO Set-Cookie headers for this name —
* establishAdminSession clears any stale marker, then the callback sets
* the fresh one — and browsers apply them in order, so the LAST wins.
*/
function idTokenCookie(res) {
const cookies = (res.headers['set-cookie'] || []).filter((c) => c.startsWith('oidc_id_token='));
const last = cookies[cookies.length - 1];
return last ? last.split(';')[0] : null;
}
it('stores the raw ID token in the oidc_id_token cookie on SSO login', async () => {
idp.setNextUser({ sub: 'logout-sub-1', email: 'logout@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const cookie = idTokenCookie(res);
expect(cookie).toBeTruthy();
// Raw JWT, HttpOnly, scoped to /api/auth.
const raw = decodeURIComponent(cookie.replace('oidc_id_token=', ''));
expect(raw.split('.')).toHaveLength(3);
const setCookies = (res.headers['set-cookie'] || []).filter((c) => c.startsWith('oidc_id_token='));
const full = setCookies[setCookies.length - 1];
expect(full).toMatch(/HttpOnly/i);
expect(full).toMatch(/Path=\/api\/auth/i);
});
it('returns the IdP end-session URL on logout and clears the cookie', async () => {
idp.setNextUser({ sub: 'logout-sub-2', email: 'logout2@example.com', email_verified: true });
const cbRes = await ssoRoundTrip();
const cookie = idTokenCookie(cbRes);
const rawIdToken = decodeURIComponent(cookie.replace('oidc_id_token=', ''));
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', cookie)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeTruthy();
const url = new URL(res.body.ssoLogoutUrl);
expect(url.href.startsWith(`${idp.issuer}/logout`)).toBe(true);
expect(url.searchParams.get('id_token_hint')).toBe(rawIdToken);
expect(url.searchParams.get('post_logout_redirect_uri')).toBe('http://localhost:5199/admin/login');
expect(url.searchParams.get('client_id')).toBe(idp.clientId);
// Cookie must be cleared so a later local-password logout in the same
// browser doesn't bounce to the IdP again.
const cleared = (res.headers['set-cookie'] || []).find((c) => c.startsWith('oidc_id_token='));
expect(cleared).toBeTruthy();
expect(cleared).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i);
});
it('omits ssoLogoutUrl when the feature is disabled', async () => {
idp.setNextUser({ sub: 'logout-sub-3', email: 'logout3@example.com', email_verified: true });
const cbRes = await ssoRoundTrip();
const cookie = idTokenCookie(cbRes);
await oidcService.saveOidcSettings({ oidc_logout_from_idp: false });
try {
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', cookie)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeUndefined();
} finally {
await oidcService.saveOidcSettings({ oidc_logout_from_idp: true });
}
});
it('omits ssoLogoutUrl without an oidc_id_token cookie (local-password session)', async () => {
const res = await request(app).post('/api/auth/logout').expect(200);
expect(res.body.ssoLogoutUrl).toBeUndefined();
});
it('omits ssoLogoutUrl when the IdP advertises no end_session_endpoint', async () => {
// Separate provider whose discovery document lacks end_session_endpoint;
// repointing the settings invalidates the discovery cache.
const bareIdp = new MockOidcProvider();
bareIdp.advertiseEndSession = false;
const bareIssuer = await bareIdp.start();
try {
await oidcService.saveOidcSettings({
oidc_issuer_url: bareIssuer,
oidc_client_id: bareIdp.clientId,
oidc_client_secret: bareIdp.clientSecret,
});
bareIdp.setNextUser({ sub: 'logout-sub-4', email: 'logout4@example.com', email_verified: true });
const cbRes = await ssoRoundTrip();
const cookie = idTokenCookie(cbRes);
expect(cookie).toBeTruthy();
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', cookie)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeUndefined();
} finally {
await bareIdp.stop();
await oidcService.saveOidcSettings({
oidc_issuer_url: idp.issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
});
}
});
it('stores an issuer-tagged marker for oversized ID tokens; logout still round-trips, without a hint', async () => {
idp.setNextUser({
sub: 'logout-sub-5',
email: 'logout5@example.com',
email_verified: true,
// ~9KB of group claims — far past the 4KB cookie limit.
groups: Array.from({ length: 300 }, (_, i) => `group-${String(i).padStart(4, '0')}-xxxxxxxxxxxxxxxx`),
});
const cbRes = await ssoRoundTrip();
const cookie = idTokenCookie(cbRes);
expect(cookie).toBeTruthy();
// Issuer-tagged marker, not the (oversized) token itself.
const marker = decodeURIComponent(cookie.replace('oidc_id_token=', ''));
expect(marker.startsWith('sso.')).toBe(true);
expect(Buffer.from(marker.split('.')[1], 'base64url').toString('utf8')).toBe(idp.issuer);
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', cookie)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeTruthy();
const url = new URL(res.body.ssoLogoutUrl);
expect(url.searchParams.get('id_token_hint')).toBeNull();
expect(url.searchParams.get('client_id')).toBe(idp.clientId);
});
it('skips the round-trip for an oversized-token marker from a DIFFERENT issuer', async () => {
const foreignMarker = `sso.${Buffer.from('http://other-idp.example').toString('base64url')}`;
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', `oidc_id_token=${foreignMarker}`)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeUndefined();
});
it('a fresh local-password login clears a stale SSO marker', async () => {
const role = await db('roles').where({ name: 'admin' }).first();
await db('admin_users').insert({
username: 'stale-marker-admin',
email: 'stale-marker@example.com',
password_hash: await bcrypt.hash('StaleMarker123!', 4),
role_id: role.id,
is_active: 1,
must_change_password: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
// Stale marker from a dead SSO session rides along on the login request.
const res = await request(app)
.post('/api/auth/admin/login')
.set('Cookie', 'oidc_id_token=stale.jwt.value')
.send({ username: 'stale-marker-admin', password: 'StaleMarker123!' })
.expect(200);
const cleared = (res.headers['set-cookie'] || []).find((c) => c.startsWith('oidc_id_token='));
expect(cleared).toBeTruthy();
expect(cleared).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i);
});
it('skips the round-trip when the stored hint was issued by a DIFFERENT issuer (config changed)', async () => {
// Fake-but-well-formed JWT from another IdP — payload is all that matters,
// buildEndSessionUrl decodes without verification for routing only.
const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
const foreignToken = `${b64({ alg: 'none' })}.${b64({ iss: 'http://other-idp.example', aud: idp.clientId })}.sig`;
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', `oidc_id_token=${foreignToken}`)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeUndefined();
});
it('drops only the hint when the issuer matches but the client changed', async () => {
const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
const oldClientToken = `${b64({ alg: 'none' })}.${b64({ iss: idp.issuer, aud: 'previous-client-id' })}.sig`;
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', `oidc_id_token=${oldClientToken}`)
.expect(200);
expect(res.body.ssoLogoutUrl).toBeTruthy();
const url = new URL(res.body.ssoLogoutUrl);
expect(url.searchParams.get('id_token_hint')).toBeNull();
expect(url.searchParams.get('client_id')).toBe(idp.clientId);
});
it('exposes the flag and post_logout_redirect_uri via getOidcConfig/getPostLogoutRedirectUri', async () => {
// Settings-route auth chains are covered in oidcSso.test.js; here the
// service surface the routes read from is pinned directly.
const cfg = await oidcService.getOidcConfig();
expect(cfg.logoutFromIdp).toBe(true);
expect(await oidcService.getPostLogoutRedirectUri()).toBe('http://localhost:5199/admin/login');
});
});
@@ -0,0 +1,416 @@
/**
* OIDC role mapping + login policy integration tests (#798, phase 2).
*
* Same harness as oidcSso.test.js: supertest over the real routes, mock
* in-process IdP with genuine RS256/PKCE validation, fresh-SQLite DB. Pins:
*
* - JIT provisioning takes the MAPPED role from a nested dot-path claim
* (Keycloak's realm_access.roles), not the static default
* - roles are re-evaluated on every SSO login (upgrade AND downgrade)
* - several mapped roles → the highest-priority one wins
* - non-strict: unmapped login keeps the current role / default at JIT
* - strict (require_mapped_role): unmapped login → sso_error=no_role
* - the last active super_admin is never demoted by mapping
* - space-separated string claim values work (flat `roles` claim)
* - disable_local_login: password login → 403; OIDC_BREAK_GLASS=true
* re-opens it; flag is inert while SSO is disabled
* - PUT /sso validation: unknown mapping target and
* disable-local-login-without-SSO are rejected
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb } = require('./helpers/crmDb');
const { MockOidcProvider } = require('./helpers/mockOidcProvider');
describe('OIDC role mapping + login policy (#798 phase 2)', () => {
let db;
let cleanup;
let app;
let idp;
let oidcService;
let superAdminToken;
beforeAll(async () => {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-test-secret';
process.env.FRONTEND_URL = 'http://localhost:5199';
delete process.env.OIDC_BREAK_GLASS;
({ db, cleanup } = await bootCrmDb());
idp = new MockOidcProvider();
const issuer = await idp.start();
oidcService = require('../../src/services/oidcService');
await oidcService.saveOidcSettings({
oidc_enabled: true,
oidc_issuer_url: issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
oidc_autoprovision: true,
oidc_default_role: 'viewer',
oidc_role_mapping_enabled: true,
oidc_roles_claim: 'realm_access.roles',
oidc_role_mappings: {
'pp-super': 'super_admin',
'pp-admins': 'admin',
'pp-view': 'viewer',
},
});
const authRouter = require('../../src/routes/auth');
const adminSettingsRouter = require('../../src/routes/adminSettings');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRouter);
app.use('/api/admin/settings', adminSettingsRouter);
// A real super_admin row + token for the settings-validation tests.
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'root-admin',
email: 'root@example.com',
password_hash: await bcrypt.hash('RootPass123', 4),
role_id: superRole.id,
is_active: 1,
auth_provider: 'local',
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
superAdminToken = jwt.sign(
{ id: rootId, username: 'root-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
}, 120000);
afterAll(async () => {
delete process.env.OIDC_BREAK_GLASS;
if (idp) await idp.stop();
if (cleanup) await cleanup();
});
/** Drive login → IdP → callback like a browser; returns the callback response. */
async function ssoRoundTrip() {
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
const stateCookie = (loginRes.headers['set-cookie'] || [])
.find((c) => c.startsWith('oidc_state=')).split(';')[0];
const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' });
expect(idpRes.status).toBe(302);
const back = new URL(idpRes.headers.get('location'));
return request(app)
.get(`${back.pathname}?${back.searchParams.toString()}`)
.set('Cookie', stateCookie)
.expect(302);
}
async function roleOf(email) {
const row = await db('admin_users').where({ email }).first();
const role = await db('roles').where({ id: row.role_id }).first();
return role.name;
}
it('JIT-provisions with the role mapped from the nested dot-path claim', async () => {
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['irrelevant', 'pp-admins'] },
});
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(await roleOf('mapped@example.com')).toBe('admin');
});
it('re-evaluates the role on every login — downgrade lands', async () => {
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['pp-view'] },
});
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(await roleOf('mapped@example.com')).toBe('viewer');
});
it('re-evaluates the role on every login — upgrade lands and the session JWT carries it', async () => {
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['pp-admins'] },
});
const res = await ssoRoundTrip();
expect(await roleOf('mapped@example.com')).toBe('admin');
// The freshly-minted session token must already carry the NEW role —
// the sync happens before session establishment.
const adminCookie = (res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='));
const token = decodeURIComponent(adminCookie.split(';')[0].replace('admin_token=', ''));
const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
expect(decoded.role).toBe('admin');
});
it('picks the highest-priority role when several IdP values map', async () => {
idp.setNextUser({
sub: 'sub-multi',
email: 'multi@example.com',
email_verified: true,
realm_access: { roles: ['pp-view', 'pp-admins'] },
});
await ssoRoundTrip();
expect(await roleOf('multi@example.com')).toBe('admin');
});
it('non-strict: an unmapped login keeps the current role / gets the default at JIT', async () => {
// Existing admin keeps its role.
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['nothing-mapped'] },
});
let res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(await roleOf('mapped@example.com')).toBe('admin');
// JIT falls back to the configured default role.
idp.setNextUser({
sub: 'sub-unmapped-jit',
email: 'unmapped@example.com',
email_verified: true,
realm_access: { roles: ['nothing-mapped'] },
});
res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(await roleOf('unmapped@example.com')).toBe('viewer');
});
it('strict mode refuses unmapped logins with sso_error=no_role and no session', async () => {
await oidcService.saveOidcSettings({ oidc_require_mapped_role: true });
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['nothing-mapped'] },
});
const res = await ssoRoundTrip();
await oidcService.saveOidcSettings({ oidc_require_mapped_role: false });
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=no_role');
expect((res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='))).toBeFalsy();
// Role untouched by the refused attempt.
expect(await roleOf('mapped@example.com')).toBe('admin');
});
it('never demotes the last active super_admin', async () => {
// Make the SSO admin the ONLY active super_admin.
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const ssoAdmin = await db('admin_users').where({ email: 'mapped@example.com' }).first();
await db('admin_users').where({ role_id: superRole.id }).update({ is_active: 0 });
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: superRole.id, is_active: 1 });
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['pp-view'] },
});
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
// Still super_admin — the demotion was refused, the login was not.
expect(await roleOf('mapped@example.com')).toBe('super_admin');
// Restore: root admin back to active super_admin, SSO admin back to admin.
const adminRole = await db('roles').where({ name: 'admin' }).first();
await db('admin_users').where({ email: 'root@example.com' }).update({ is_active: 1 });
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: adminRole.id });
// With ANOTHER active super_admin present the same downgrade goes through.
idp.setNextUser({
sub: 'sub-map-1',
email: 'mapped@example.com',
email_verified: true,
realm_access: { roles: ['pp-view'] },
});
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: superRole.id });
await ssoRoundTrip();
expect(await roleOf('mapped@example.com')).toBe('viewer');
});
it('never demotes the last LOCAL-password super_admin even when an OIDC-owned super exists', async () => {
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const viewerRole = await db('roles').where({ name: 'viewer' }).first();
// A local-password super admin, SSO-linked via verified email so role
// sync applies to it.
const [localId] = await db('admin_users').insert({
username: 'local-super',
email: 'local-super@example.com',
password_hash: await bcrypt.hash('LocalSuper123', 4),
role_id: superRole.id,
is_active: 1,
auth_provider: 'local',
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
// The only OTHER active super is OIDC-owned (root goes inactive) — the
// plain last-super guard would allow the demotion, the break-glass
// guard must not.
const ssoAdmin = await db('admin_users').where({ email: 'mapped@example.com' }).first();
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: superRole.id });
await db('admin_users').where({ email: 'root@example.com' }).update({ is_active: 0 });
idp.setNextUser({
sub: 'sub-local-super',
email: 'local-super@example.com',
email_verified: true,
realm_access: { roles: ['pp-view'] },
});
const res = await ssoRoundTrip();
const row = await db('admin_users').where({ id: localId }).first();
// Restore the fixture state before asserting.
await db('admin_users').where({ email: 'root@example.com' }).update({ is_active: 1 });
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: viewerRole.id });
await db('admin_users').where({ id: localId }).update({ is_active: 0 });
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(row.role_id).toBe(superRole.id); // kept — it is the break-glass account
});
it('treats prototype-property IdP values (constructor/toString) as unmapped, not as an error', async () => {
idp.setNextUser({
sub: 'sub-proto',
email: 'proto@example.com',
email_verified: true,
realm_access: { roles: ['constructor', 'toString', '__proto__'] },
});
const res = await ssoRoundTrip();
// Non-strict: unmapped → JIT with the default role, login succeeds.
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(await roleOf('proto@example.com')).toBe('viewer');
});
it('accepts a space-separated string value on a flat claim', async () => {
await oidcService.saveOidcSettings({ oidc_roles_claim: 'roles' });
idp.setNextUser({
sub: 'sub-flat',
email: 'flat@example.com',
email_verified: true,
roles: 'other pp-admins',
});
const res = await ssoRoundTrip();
await oidcService.saveOidcSettings({ oidc_roles_claim: 'realm_access.roles' });
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
expect(await roleOf('flat@example.com')).toBe('admin');
});
it('refuses local password login while disable_local_login is effective', async () => {
await oidcService.saveOidcSettings({ oidc_disable_local_login: true });
const res = await request(app)
.post('/api/auth/admin/login')
.send({ username: 'root@example.com', password: 'RootPass123' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('LOCAL_LOGIN_DISABLED');
});
it('OIDC_BREAK_GLASS=true re-opens local login despite the policy', async () => {
process.env.OIDC_BREAK_GLASS = 'true';
const res = await request(app)
.post('/api/auth/admin/login')
.send({ username: 'root@example.com', password: 'RootPass123' });
delete process.env.OIDC_BREAK_GLASS;
expect(res.status).toBe(200);
expect(res.body.user).toBeTruthy();
});
it('the stored flag is inert while SSO is disabled', async () => {
// Simulate a torn-down SSO config with the stale flag still set — the
// runtime check must ignore it (no lockout).
await db('app_settings').where({ setting_key: 'oidc_enabled' })
.update({ setting_value: JSON.stringify(false) });
expect(await oidcService.isLocalLoginDisabled()).toBe(false);
await db('app_settings').where({ setting_key: 'oidc_enabled' })
.update({ setting_value: JSON.stringify(true) });
expect(await oidcService.isLocalLoginDisabled()).toBe(true);
await oidcService.saveOidcSettings({ oidc_disable_local_login: false });
});
it('the policy disarms itself when no active local-password super admin remains', async () => {
await oidcService.saveOidcSettings({ oidc_disable_local_login: true });
expect(await oidcService.isLocalLoginDisabled()).toBe(true);
// The break-glass account disappears (e.g. manual demotion/deactivation
// while the policy is on) → local login must re-open by itself.
await db('admin_users').where({ email: 'root@example.com' }).update({ auth_provider: 'oidc' });
expect(await oidcService.isLocalLoginDisabled()).toBe(false);
await db('admin_users').where({ email: 'root@example.com' }).update({ auth_provider: 'local' });
await oidcService.saveOidcSettings({ oidc_disable_local_login: false });
});
it('PUT /sso rejects a mapping onto an unknown role', async () => {
const res = await request(app)
.put('/api/admin/settings/sso')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({ oidc_role_mappings: { 'pp-admins': 'does_not_exist' } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/does_not_exist/);
// Stored mapping unchanged.
const cfg = await oidcService.getOidcConfig();
expect(cfg.roleMappings['pp-admins']).toBe('admin');
});
it('PUT /sso rejects disabling local login while SSO is (being turned) off', async () => {
const res = await request(app)
.put('/api/admin/settings/sso')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({ oidc_enabled: false, oidc_disable_local_login: true });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/while SSO is enabled/);
});
it('PUT /sso refuses SSO-only mode without an active local-password super admin', async () => {
// Make every active super_admin OIDC-owned — break-glass would then
// re-open a password route that no account can use.
const superRole = await db('roles').where({ name: 'super_admin' }).first();
await db('admin_users').where({ role_id: superRole.id }).update({ auth_provider: 'oidc' });
const denied = await request(app)
.put('/api/admin/settings/sso')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({ oidc_disable_local_login: true });
// Restore the local break-glass account, then the same request passes.
await db('admin_users').where({ email: 'root@example.com' }).update({ auth_provider: 'local' });
expect(denied.status).toBe(400);
expect(denied.body.error).toMatch(/break-glass/);
const allowed = await request(app)
.put('/api/admin/settings/sso')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({ oidc_disable_local_login: true });
expect(allowed.status).toBe(200);
await oidcService.saveOidcSettings({ oidc_disable_local_login: false });
});
it('GET /sso returns the phase-2 fields', async () => {
const res = await request(app)
.get('/api/admin/settings/sso')
.set('Authorization', `Bearer ${superAdminToken}`);
expect(res.status).toBe(200);
expect(res.body.oidc_role_mapping_enabled).toBe(true);
expect(res.body.oidc_roles_claim).toBe('realm_access.roles');
expect(res.body.oidc_role_mappings).toEqual({
'pp-super': 'super_admin',
'pp-admins': 'admin',
'pp-view': 'viewer',
});
expect(res.body.oidc_require_mapped_role).toBe(false);
expect(res.body.oidc_disable_local_login).toBe(false);
});
});
@@ -0,0 +1,302 @@
/**
* OIDC SSO integration tests (#798, phase 1).
*
* Full-stack over a mock in-process IdP (mockOidcProvider): supertest drives
* the real /admin/sso/login and /admin/sso/callback routes on a fresh-SQLite
* database, openid-client does genuine discovery/JWKS/PKCE/ID-token
* validation against the mock issuer. Pins:
*
* - happy path: JIT provisioning creates an admin and sets the session cookie
* - JIT off → not_provisioned redirect, no row created
* - repeat login matches by sub, not email (email change ≠ new account)
* - verified-email one-time link onto an existing local admin
* - unverified email must NOT link (falls through to JIT/or error)
* - deactivated admin → inactive redirect
* - missing/forged state cookie → state redirect
* - nonce tamper from the IdP → idp redirect
* - settings endpoints: secret write-only, generic /general upsert cannot
* clobber oidc_client_secret
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb } = require('./helpers/crmDb');
const { MockOidcProvider } = require('./helpers/mockOidcProvider');
describe('OIDC SSO (#798)', () => {
let db;
let cleanup;
let app;
let idp;
let oidcService;
const agentCookies = {};
beforeAll(async () => {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-test-secret';
// The redirect_uri derives from the public base URL — pin it explicitly:
// CI has no backend/.env, and getFrontendBaseUrl() returning '' makes
// buildAuthorizationRequest fail (by design) with OIDC_BAD_CONFIG.
process.env.FRONTEND_URL = 'http://localhost:5199';
({ db, cleanup } = await bootCrmDb());
idp = new MockOidcProvider();
const issuer = await idp.start();
// Require AFTER bootCrmDb so services share this db instance.
oidcService = require('../../src/services/oidcService');
await oidcService.saveOidcSettings({
oidc_enabled: true,
oidc_issuer_url: issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
oidc_autoprovision: true,
oidc_default_role: 'viewer',
});
const authRouter = require('../../src/routes/auth');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRouter);
}, 120000);
afterAll(async () => {
if (idp) await idp.stop();
if (cleanup) await cleanup();
});
/** Drive login → IdP → callback like a browser; returns the callback response. */
async function ssoRoundTrip({ mutateState } = {}) {
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
const idpUrl = loginRes.headers.location;
expect(idpUrl.startsWith(idp.issuer)).toBe(true);
let stateCookie = (loginRes.headers['set-cookie'] || [])
.find((c) => c.startsWith('oidc_state='));
expect(stateCookie).toBeTruthy();
stateCookie = stateCookie.split(';')[0];
if (mutateState === 'drop') stateCookie = null;
if (mutateState === 'forge') {
stateCookie = `oidc_state=${jwt.sign({ type: 'oidc_state', s: 'x', n: 'y', cv: 'z' }, 'wrong-secret', { issuer: 'picpeak-auth' })}`;
}
// "Browser" follows the redirect to the IdP, which instantly bounces back.
const idpRes = await fetch(idpUrl, { redirect: 'manual' });
expect(idpRes.status).toBe(302);
const back = new URL(idpRes.headers.get('location'));
let cb = request(app).get(`${back.pathname}?${back.searchParams.toString()}`);
if (stateCookie) cb = cb.set('Cookie', stateCookie);
return cb.expect(302);
}
it('JIT-provisions an unknown user and establishes an admin session', async () => {
idp.setNextUser({ sub: 'sub-jit-1', email: 'jit@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const adminCookie = (res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='));
expect(adminCookie).toBeTruthy();
const row = await db('admin_users').where({ email: 'jit@example.com' }).first();
expect(row).toBeTruthy();
expect(row.auth_provider).toBe('oidc');
expect(row.external_subject).toBe('sub-jit-1');
const role = await db('roles').where('id', row.role_id).first();
expect(role.name).toBe('viewer');
// The session JWT must be a normal admin token.
const token = adminCookie.split(';')[0].replace('admin_token=', '');
const decoded = jwt.verify(decodeURIComponent(token), process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
expect(decoded.type).toBe('admin');
expect(decoded.id).toBe(row.id);
agentCookies.jitAdminId = row.id;
});
it('matches repeat logins by sub even when the email changed at the IdP', async () => {
idp.setNextUser({ sub: 'sub-jit-1', email: 'renamed@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
// No second row — resolved via external_subject.
expect(await db('admin_users').where({ email: 'renamed@example.com' }).first()).toBeFalsy();
const byId = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
expect(byId.external_subject).toBe('sub-jit-1');
});
it('links an existing local admin one-time via VERIFIED email and stamps the sub', async () => {
const role = await db('roles').where({ name: 'admin' }).first();
const [localId] = await db('admin_users').insert({
username: 'local-admin',
email: 'local@example.com',
password_hash: await bcrypt.hash('LocalPass123', 4),
role_id: role.id,
is_active: 1,
auth_provider: 'local',
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
idp.setNextUser({ sub: 'sub-local-1', email: 'local@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const row = await db('admin_users').where({ id: localId }).first();
expect(row.external_subject).toBe('sub-local-1');
expect(row.auth_provider).toBe('local'); // password keeps working
});
it('does NOT link by unverified email — provisions a separate account instead', async () => {
const role = await db('roles').where({ name: 'admin' }).first();
await db('admin_users').insert({
username: 'victim-admin',
email: 'victim@example.com',
password_hash: await bcrypt.hash('VictimPass123', 4),
role_id: role.id,
is_active: 1,
auth_provider: 'local',
created_at: new Date(),
updated_at: new Date(),
});
idp.setNextUser({ sub: 'sub-attacker', email: 'victim@example.com', email_verified: false });
// JIT would need this email but the victim row owns it (unique) — the
// insert fails and the flow must land on an error, never on the
// victim's session.
const res = await ssoRoundTrip();
expect(res.headers.location).toMatch(/sso_error=/);
const victim = await db('admin_users').where({ email: 'victim@example.com' }).first();
expect(victim.external_subject).toBeNull();
});
it('refuses a deactivated admin with sso_error=inactive', async () => {
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 0 });
idp.setNextUser({ sub: 'sub-jit-1', email: 'renamed@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=inactive');
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 1 });
});
it('rejects a callback without the state cookie', async () => {
const res = await ssoRoundTrip({ mutateState: 'drop' });
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=state');
});
it('rejects a forged state cookie (wrong signing key)', async () => {
const res = await ssoRoundTrip({ mutateState: 'forge' });
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=state');
});
it('rejects an ID token whose nonce does not match', async () => {
idp.tamperNonce = true;
idp.setNextUser({ sub: 'sub-nonce', email: 'nonce@example.com', email_verified: true });
const res = await ssoRoundTrip();
idp.tamperNonce = false;
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=idp');
expect(await db('admin_users').where({ email: 'nonce@example.com' }).first()).toBeFalsy();
});
it('blocks JIT with sso_error=not_provisioned when autoprovision is off', async () => {
await oidcService.saveOidcSettings({ oidc_autoprovision: false });
idp.setNextUser({ sub: 'sub-new-user', email: 'new@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=not_provisioned');
expect(await db('admin_users').where({ email: 'new@example.com' }).first()).toBeFalsy();
await oidcService.saveOidcSettings({ oidc_autoprovision: true });
});
it('stores the client secret encrypted and survives a config round-trip', async () => {
const row = await db('app_settings').where({ setting_key: 'oidc_client_secret' }).first();
const stored = JSON.parse(row.setting_value);
expect(stored).not.toContain(idp.clientSecret);
expect(oidcService.decryptSecret(stored)).toBe(idp.clientSecret);
const cfg = await oidcService.getOidcConfig();
expect(cfg.clientSecret).toBe(idp.clientSecret);
});
it('refuses local password login for OIDC-owned accounts', async () => {
// Give the JIT admin a KNOWN password hash directly in the DB — the
// auth_provider check must reject the login even with valid credentials
// (otherwise a password reset would mint an IdP-bypassing local login).
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({
password_hash: await bcrypt.hash('KnownPass123', 4),
});
const row = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
const res = await request(app)
.post('/api/auth/admin/login')
.send({ username: row.email, password: 'KnownPass123' });
expect(res.status).toBe(401);
});
it('returns 404 from /sso/login when SSO is disabled', async () => {
await oidcService.saveOidcSettings({ oidc_enabled: false });
await request(app).get('/api/auth/admin/sso/login').expect(404);
await oidcService.saveOidcSettings({ oidc_enabled: true });
});
it('merges email from the UserInfo endpoint when the ID token omits it', async () => {
idp.emailViaUserinfoOnly = true;
idp.setNextUser({ sub: 'sub-userinfo', email: 'userinfo@example.com', email_verified: true });
const res = await ssoRoundTrip();
idp.emailViaUserinfoOnly = false;
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const row = await db('admin_users').where({ email: 'userinfo@example.com' }).first();
expect(row).toBeTruthy();
expect(row.external_subject).toBe('sub-userinfo');
});
it('binds identities per ISSUER — a sub collision on a new IdP must not inherit the old account', async () => {
// The JIT admin from the first test is bound to (issuer A, 'sub-jit-1').
const boundAdmin = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
expect(boundAdmin.external_issuer).toBe(idp.issuer);
// Same sub, DIFFERENT issuer: a second IdP the instance switches to.
const idp2 = new MockOidcProvider();
await idp2.start();
try {
await oidcService.saveOidcSettings({
oidc_issuer_url: idp2.issuer,
oidc_client_id: idp2.clientId,
oidc_client_secret: idp2.clientSecret,
});
idp2.setNextUser({ sub: 'sub-jit-1', email: 'colliding@example.com', email_verified: true });
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
const stateCookie = (loginRes.headers['set-cookie'] || [])
.find((c) => c.startsWith('oidc_state=')).split(';')[0];
const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' });
const back = new URL(idpRes.headers.get('location'));
const res = await request(app)
.get(`${back.pathname}?${back.searchParams.toString()}`)
.set('Cookie', stateCookie)
.expect(302);
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
// A NEW row bound to issuer B — the issuer-A admin is untouched and
// its role was not inherited.
const collider = await db('admin_users').where({ email: 'colliding@example.com' }).first();
expect(collider).toBeTruthy();
expect(collider.id).not.toBe(agentCookies.jitAdminId);
expect(collider.external_issuer).toBe(idp2.issuer);
const original = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
expect(original.external_issuer).toBe(idp.issuer);
} finally {
await idp2.stop();
await oidcService.saveOidcSettings({
oidc_issuer_url: idp.issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
});
}
});
});
@@ -0,0 +1,361 @@
/**
* Backfilling orientation for a library that predates #1185 (#1198).
*
* The orientation fix corrected the generators and every ingest path, but left
* existing rows describing the raw sensor order. Those rows end up worse than
* untouched ones: before the fix a rotated photo was consistently wrong — a
* sideways image in a matching tile — and afterwards the thumbnail is right
* while the stored aspect ratio is not.
*
* A first attempt at this was reverted from #1194 after review. These tests
* pin the five things that went wrong with it:
*
* 1. requeueing faces without clearing the cached preview, so the rescan
* re-read unrotated pixels;
* 2. reading originals in a way that cannot see S3 or RAW;
* 3. deciding "did this change" from a dimension delta, which never fires
* for orientations 2, 3 and 4;
* 4. walking archived events whose originals no longer exist;
* 5. writing dimensions and invalidation non-atomically.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
const sharp = require('sharp');
describe('orientation backfill (#1198)', () => {
let tmpDir; let db; let app; let storageRoot;
const status = () => request(app).get('/api/admin/photos/repair-orientation/status');
const run = () => request(app).post('/api/admin/photos/repair-orientation');
const settle = async () => {
for (let i = 0; i < 100; i++) {
await new Promise((r) => setTimeout(r, 50));
const s = await status();
if (!s.body.isRunning) return s;
}
throw new Error('backfill did not settle');
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-orientbf-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'orientbf-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
// bootCrmDb owns STORAGE_PATH; fixtures must live where the app resolves.
storageRoot = process.env.STORAGE_PATH;
await fs.promises.mkdir(path.join(storageRoot, 'events/active/orientbf'), { recursive: true });
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seed({
orientation, storedWidth, storedHeight, faceStatus = null,
previewPath = 'previews/prev_orientbf.jpg', archived = false, filename = 'p.jpg',
thumbnailPath = 'thumbnails/thumb_orientbf.jpg', heroPath = 'heroes/hero_orientbf.jpg',
watermarkPath = 'watermarks/wm_orientbf.jpg', checkedAt = null,
}) {
await db('photos').del();
await db('events').del();
const [e] = await db('events').insert({
slug: 'orientbf', event_type: 'wedding', event_name: 'orientbf', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: `orientbf-${Math.random()}`, expires_at: new Date().toISOString(),
is_archived: archived,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const img = sharp({ create: { width: 400, height: 200, channels: 3, background: { r: 7, g: 7, b: 7 } } });
await (orientation ? img.withMetadata({ orientation }) : img)
.jpeg().toFile(path.join(storageRoot, 'events/active/orientbf', filename));
const [p] = await db('photos').insert({
event_id: eventId, filename, path: `orientbf/${filename}`, type: 'individual',
width: storedWidth, height: storedHeight, face_status: faceStatus,
preview_path: previewPath, thumbnail_path: thumbnailPath, hero_path: heroPath,
watermark_path: watermarkPath, orientation_checked_at: checkedAt,
uploaded_at: new Date().toISOString(),
}).returning('id');
return { eventId, photoId: typeof p === 'object' ? p.id : p };
}
it('corrects a row whose dimensions are transposed', async () => {
// The case the dimension repair can never reach: both values present,
// just in the raw sensor order.
const { photoId } = await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
expect((await run()).body.count).toBe(1);
const done = await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.width).toBe(200);
expect(row.height).toBe(400);
expect(done.body.lastResult.corrected).toBe(1);
});
it('clears the cached preview before requeueing, not after', async () => {
// The reverted attempt's own-goal: ensurePreviewImage hands back a cached
// preview whenever it is still a valid image, so a rescan against the
// pre-fix preview produced boxes in the old coordinate system and scaled
// them by the corrected dimensions.
const { photoId } = await seed({
orientation: 6, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
});
await run();
const done = await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.preview_path).toBeNull();
expect(row.face_status).toBe('pending');
expect(done.body.lastResult.requeuedFaces).toBe(1);
});
it('clears the thumbnail and hero too, not just the preview', async () => {
// The miss that mattered most: ensureThumbnail and ensureHeroImage return
// their cached file whenever it is merely VALID, and a pre-fix sideways
// thumbnail is perfectly valid. Clearing only the preview fixed the face
// data and left the gallery rendering the old sideways image inside a
// newly-corrected portrait tile.
const { photoId } = await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
await run();
await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.preview_path).toBeNull();
expect(row.thumbnail_path).toBeNull();
expect(row.hero_path).toBeNull();
});
it('leaves the renditions of an untransformed photo alone', async () => {
// Nothing moved, so nothing cached is stale — clearing them would make a
// routine run regenerate the whole library for no reason.
const { photoId } = await seed({ orientation: null, storedWidth: 400, storedHeight: 200 });
await run();
await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.thumbnail_path).toBe('thumbnails/thumb_orientbf.jpg');
expect(row.hero_path).toBe('heroes/hero_orientbf.jpg');
expect(row.preview_path).toBe('previews/prev_orientbf.jpg');
});
it('does not touch a row whose file was replaced while it was reading', async () => {
// replacePhoto swaps a new file under an existing row and rewrites
// path/filename (reachable from replace_by_name). The writes are fenced on
// the identity that was measured, so a replacement that lands mid-run is
// left entirely alone rather than being given the previous file's
// dimensions and having its fresh renditions cleared.
const { photoId } = await seed({
orientation: 6, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
});
const res = await run();
expect(res.body.count).toBe(1);
// Simulate the replacement landing before the loop writes.
await db('photos').where({ id: photoId })
.update({ path: 'orientbf/replaced.jpg', filename: 'replaced.jpg' });
await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.width).toBe(400); // untouched
expect(row.face_status).toBe('done'); // not requeued
expect(row.thumbnail_path).toBe('thumbnails/thumb_orientbf.jpg');
});
it('is idempotent — a second run finds nothing left to do', async () => {
// The trigger is the EXIF tag on the ORIGINAL, which correcting a photo
// never changes. Without a marker every re-run would throw away the
// renditions it had just regenerated and requeue every completed face
// scan — on a face-enabled install, re-detecting the whole library.
await seed({ orientation: 6, storedWidth: 400, storedHeight: 200, faceStatus: 'done' });
expect((await run()).body.count).toBe(1);
const first = await settle();
expect(first.body.lastResult.corrected).toBe(1);
expect((await run()).body.count).toBe(0);
});
it('force revisits rows it has already checked', async () => {
await seed({
orientation: 6, storedWidth: 200, storedHeight: 400,
checkedAt: new Date().toISOString(),
});
expect((await run()).body.count).toBe(0);
const forced = await request(app).post('/api/admin/photos/repair-orientation').send({ force: true });
expect(forced.body.count).toBe(1);
await settle();
});
it('clears the watermarked rendition, which is what a guest actually sees', async () => {
// gallery.js serves watermark_path ahead of the original when branding
// watermarking is on.
const { photoId } = await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
await run();
await settle();
expect((await db('photos').where({ id: photoId }).first()).watermark_path).toBeNull();
});
it('does not report stale tiers after a clean run', async () => {
// storage.stat() RESOLVES with null for a missing key rather than
// rejecting, so counting "the promise settled" marked every deleted and
// never-created tier as a survivor and told the operator to re-run.
await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
await run();
const done = await settle();
expect(done.body.lastResult.staleTiers).toBe(0);
});
it('requeues faces when only the dimensions were wrong', async () => {
// No rotation involved: boxes are scaled by photo.width at read time, so
// any change to the stored dimensions invalidates them.
const { photoId } = await seed({
orientation: null, storedWidth: 999, storedHeight: 111, faceStatus: 'done',
});
await run();
const done = await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.width).toBe(400);
expect(row.face_status).toBe('pending');
expect(done.body.lastResult.requeuedFaces).toBe(1);
});
it('requeues an orientation that moves pixels without moving dimensions', async () => {
// Orientation 3 is a 180° turn: every pixel moves, width and height do
// not. A dimension-delta check sees nothing and skips exactly this row.
const { photoId } = await seed({
orientation: 3, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
});
await run();
const done = await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.width).toBe(400); // unchanged, correctly
expect(row.face_status).toBe('pending');
expect(row.preview_path).toBeNull();
expect(done.body.lastResult.requeuedFaces).toBe(1);
expect(done.body.lastResult.corrected).toBe(0);
});
it('leaves a post-fix import alone, renditions and all', async () => {
// A 5-8 rotation changes the dimensions, so a tagged photo whose stored
// dimensions are already oriented must have been ingested after #1185.
// Re-clearing its renditions would delete valid files and rescan a
// completed face detection for nothing.
const { photoId } = await seed({
orientation: 6, storedWidth: 200, storedHeight: 400, faceStatus: 'done',
});
await run();
const done = await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.thumbnail_path).toBe('thumbnails/thumb_orientbf.jpg');
expect(row.face_status).toBe('done');
expect(done.body.lastResult).toMatchObject({ corrected: 0, requeuedFaces: 0 });
// ...and it is marked, so it is not re-read next time either.
expect(row.orientation_checked_at).toBeTruthy();
});
it('still invalidates a 180-degree rotation, which carries no such evidence', async () => {
// Orientation 3 leaves the dimensions identical whether or not it has been
// processed, so there is nothing to infer from and it must be done once.
const { photoId } = await seed({
orientation: 3, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
});
await run();
await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.thumbnail_path).toBeNull();
expect(row.face_status).toBe('pending');
});
it('leaves an untagged photo completely alone', async () => {
const { photoId } = await seed({
orientation: null, storedWidth: 400, storedHeight: 200, faceStatus: 'done',
});
await run();
const done = await settle();
const row = await db('photos').where({ id: photoId }).first();
expect(row.width).toBe(400);
expect(row.face_status).toBe('done');
expect(row.preview_path).toBe('previews/prev_orientbf.jpg');
expect(done.body.lastResult).toMatchObject({ corrected: 0, requeuedFaces: 0, failed: 0 });
});
it('does not start face scanning on an install that never enabled it', async () => {
const { photoId } = await seed({
orientation: 6, storedWidth: 400, storedHeight: 200, faceStatus: null,
});
await run();
const done = await settle();
expect((await db('photos').where({ id: photoId }).first()).face_status).toBeNull();
expect(done.body.lastResult.requeuedFaces).toBe(0);
// ...but the dimensions are still corrected.
expect(done.body.lastResult.corrected).toBe(1);
});
it('skips archived events, whose originals were deleted on archive', async () => {
await seed({ orientation: 6, storedWidth: 400, storedHeight: 200, archived: true });
const res = await run();
expect(res.body.count).toBe(0);
});
it('refuses a second run while one is in flight', async () => {
// Shares the maintenance-lease plumbing, on its own job row so it neither
// blocks nor is blocked by the dimension repair.
const jobs = require('../../src/services/maintenanceJobState');
await seed({ orientation: 6, storedWidth: 400, storedHeight: 200 });
const claim = await jobs.claim(jobs.JOB_ORIENTATION_BACKFILL);
expect(claim).toEqual(expect.any(String));
expect((await run()).status).toBe(409);
// The dimension repair is a different job and is unaffected.
expect((await request(app).post('/api/admin/photos/repair-dimensions')).status).toBe(200);
await jobs.release(jobs.JOB_ORIENTATION_BACKFILL, claim);
});
});
@@ -0,0 +1,197 @@
'use strict';
/**
* Cross-engine .picpeak restore policy (#1041): a SQLite archive restored onto
* a PostgreSQL instance — the official small-install → full-stack upgrade
* path — now allowed by validateManifest's direction rule instead of the
* former CLI-only allowEngineSwitch flag. The coercion engine itself
* (typedColumnsFor / epochToIso / coerceForTargetEngine) landed with #1039;
* these tests pin the direction policy and the coercion's cross-engine
* value-correctness.
*
* Ungated: validateManifest direction rules and the pure coercion units.
* The reverse direction (pg backup onto a sqlite instance) staying blocked is
* pinned by picpeakRoundtrip.test.js, which runs on the real sqlite harness.
*
* Gated on PICPEAK_PG_TEST_URL (same contract as picpeakRestorePg.test.js):
* sqlite-shaped NDJSON rows land in real Postgres with correct stored VALUES,
* not just row counts, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:pw@127.0.0.1:7102/picpeak_xengine_test" \
* npx jest __tests__/integration/picpeakCrossEngine.test.js
*/
const knexLib = require('knex');
describe('validateManifest cross-engine direction (pg target)', () => {
let validateManifest;
beforeAll(() => {
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
// validateManifest wraps its knex_migrations lookup in try/catch — a
// throwing stub simply skips the forward-only check, which is not under
// test here.
jest.doMock('../../src/database/db', () => ({ db: () => { throw new Error('stub'); } }));
({ validateManifest } = require('../../src/services/picpeakImportService'));
});
afterAll(() => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
jest.resetModules();
});
it('allows a sqlite backup onto a pg instance (upgrade direction)', async () => {
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'sqlite' }, tables: {},
});
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
});
it('still allows same-engine pg → pg', async () => {
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
});
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
});
});
describe('epochToIso (landed with #1039)', () => {
let epochToIso;
beforeAll(() => {
jest.resetModules();
({ epochToIso } = require('../../src/services/picpeakImportService'));
});
it('converts epoch milliseconds', () => {
expect(epochToIso(1723400000000)).toBe('2024-08-11T18:13:20.000Z');
});
it('converts epoch SECONDS to the same instant, not January 1970', () => {
expect(epochToIso(1723400000)).toBe('2024-08-11T18:13:20.000Z');
});
it('converts numeric strings', () => {
expect(epochToIso('1723400000000')).toBe('2024-08-11T18:13:20.000Z');
});
it('passes non-numeric values through untouched', () => {
expect(epochToIso('2026-08-12 10:00:00')).toBe('2026-08-12 10:00:00');
});
});
describe('coerceForTargetEngine on sqlite-shaped rows', () => {
let coerceForTargetEngine;
beforeAll(() => {
jest.resetModules();
({ coerceForTargetEngine } = require('../../src/services/picpeakImportService'));
});
const types = { timestamps: ['created_at', 'expires_at'], booleans: ['is_active'] };
it('coerces 0/1 booleans and epoch timestamps, leaves date strings alone', () => {
const [row] = coerceForTargetEngine(
[{ id: 1, is_active: 1, created_at: 1723400000000, expires_at: '2026-09-01 12:00:00' }],
types
);
expect(row.is_active).toBe(true);
expect(row.created_at).toBe('2024-08-11T18:13:20.000Z');
expect(row.expires_at).toBe('2026-09-01 12:00:00'); // pg parses this natively
});
it('coerces falsy variants and passes null/empty through', () => {
const [row] = coerceForTargetEngine(
[{ is_active: 0, created_at: null, expires_at: '' }],
types
);
expect(row.is_active).toBe(false);
expect(row.created_at).toBeNull();
expect(row.expires_at).toBe('');
});
});
// ── Real-Postgres integration (gated) ────────────────────────────────────────
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('sqlite-shaped rows land correctly in real Postgres', () => {
let pgDb;
let svc;
beforeAll(async () => {
pgDb = knexLib({ client: 'pg', connection: PG_URL });
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
await pgDb.schema.createTable('xengine_events', (t) => {
t.increments('id');
t.string('slug');
t.boolean('is_active').defaultTo(true);
t.boolean('allow_downloads').defaultTo(true);
t.timestamp('created_at');
t.timestamp('expires_at');
});
await pgDb.schema.createTable('xengine_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.jsonb('setting_value');
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
svc = require('../../src/services/picpeakImportService');
});
afterAll(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
if (pgDb) {
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
await pgDb.destroy();
}
});
it('typedColumnsFor classifies boolean and timestamp columns via columnInfo()', async () => {
const types = await svc.typedColumnsFor(pgDb, 'xengine_events');
expect(types.booleans.sort()).toEqual(['allow_downloads', 'is_active']);
expect(types.timestamps.sort()).toEqual(['created_at', 'expires_at']);
});
it('inserts a sqlite archive row (0/1 booleans, epoch dates, json text) with correct stored values', async () => {
// Exactly what a sqlite-created .picpeak carries: integers for booleans,
// epoch numbers for #485-shape timestamps (ms here, seconds covered by the
// epochToIso unit), a "YYYY-MM-DD HH:MM:SS" string for clean ones, and
// json columns as TEXT (the crossEngine path skips serialiseJsonColumns —
// the text is already what pg wants).
const epoch = 1723400000000;
const eventRows = [
{ id: 1, slug: 'wedding', is_active: 1, allow_downloads: 0, created_at: epoch, expires_at: '2026-09-01 12:00:00' },
];
const settingRows = [{ id: 1, setting_key: 'brand', setting_value: '{"name":"PicPeak","dark":true}' }];
await pgDb.transaction(async (trx) => {
const evTypes = await svc.typedColumnsFor(trx, 'xengine_events');
await trx.batchInsert('xengine_events', svc.coerceForTargetEngine(eventRows, evTypes), 100);
const stTypes = await svc.typedColumnsFor(trx, 'xengine_settings');
await trx.batchInsert('xengine_settings', svc.coerceForTargetEngine(settingRows, stTypes), 100);
});
const ev = await pgDb('xengine_events').where({ id: 1 }).first();
expect(ev.is_active).toBe(true); // 1 → true, not backwards (#1028 class)
expect(ev.allow_downloads).toBe(false); // 0 → false
expect(new Date(ev.created_at).getTime()).toBe(epoch);
expect(new Date(ev.expires_at).toISOString().slice(0, 10)).toBe('2026-09-01');
const st = await pgDb('xengine_settings').where({ id: 1 }).first();
// jsonb parsed back by the driver — value intact, no double encoding.
expect(st.setting_value).toEqual({ name: 'PicPeak', dark: true });
});
it('id sequence works after explicit-id insert + resync (next natural insert)', async () => {
await svc.resyncSequences(['xengine_events']);
const [next] = await pgDb('xengine_events')
.insert({ slug: 'fresh', is_active: true })
.returning('id');
expect(Number(next.id || next)).toBe(2);
});
});
@@ -0,0 +1,94 @@
'use strict';
// Validates the engine-neutral .picpeak export: it must produce a real zip with
// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo
// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const StreamZip = require('node-stream-zip');
const { bootCrmDb } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let createPicpeak;
// bootCrmDb MUST run before requiring the service (which transitively requires
// db.js) so the export reads this test's DB, not the default path.
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
({ createPicpeak } = require('../../src/services/picpeakExportService'));
}, 120000);
afterAll(async () => {
await cleanup();
});
async function readZip(filePath) {
const zip = new StreamZip.async({ file: filePath });
const entries = Object.keys(await zip.entries());
const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
await zip.close();
return { entries, manifest };
}
describe('picpeak export (.picpeak logical export)', () => {
it('produces a .picpeak with a manifest and per-table NDJSON', async () => {
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
expect(filePath.endsWith('.picpeak')).toBe(true);
expect(fs.existsSync(filePath)).toBe(true);
expect(manifest.format).toBe(1);
expect(manifest.kind).toBe('picpeak-backup');
expect(manifest.database.engine).toBe('sqlite');
expect(manifest.options.includePhotos).toBe(false);
expect(manifest.contains_secrets).toBe(true);
// Migrations seed real tables (e.g. app_settings) — expect several.
expect(Object.keys(manifest.tables).length).toBeGreaterThan(0);
expect(Object.keys(manifest.tables)).toContain('app_settings');
const { entries, manifest: zipped } = await readZip(filePath);
expect(entries).toContain('manifest.json');
expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true);
expect(entries).toContain('data/app_settings.ndjson');
// Manifest inside the zip matches the returned one.
expect(zipped.tables).toEqual(manifest.tables);
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('never exports knex bookkeeping tables', async () => {
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
const names = Object.keys(manifest.tables);
expect(names).not.toContain('knex_migrations');
expect(names).not.toContain('knex_migrations_lock');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('row counts in the manifest match the NDJSON line counts', async () => {
// Insert a couple of settings so at least one table is non-empty.
await db('app_settings')
.insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' })
.onConflict('setting_key').merge();
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
const zip = new StreamZip.async({ file: filePath });
const buf = await zip.entryData('data/app_settings.ndjson');
await zip.close();
const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0);
expect(lines.length).toBe(manifest.tables.app_settings.rowCount);
expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0);
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
});
@@ -0,0 +1,190 @@
/**
* PostgreSQL integration tests for the .picpeak restore robustness fixes.
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway Postgres DB,
* e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_restore_test" \
* npx jest __tests__/integration/picpeakRestorePg.test.js
*
* Validates the Postgres-specific paths that SQLite can't exercise: identity
* sequences left stale by explicit-id inserts, pg_get_serial_sequence raising on
* id-less tables, reinject/role-recreate explicit-id inserts, and FK integrity.
*/
const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('picpeak restore on Postgres', () => {
let pgDb;
let svc;
beforeAll(async () => {
pgDb = knex({ client: 'pg', connection: PG_URL });
await pgDb.raw('DROP TABLE IF EXISTS role_permissions, events, admin_users, roles, permissions, app_settings CASCADE');
await pgDb.schema.createTable('roles', (t) => {
t.increments('id');
t.string('name', 50).notNullable().unique();
t.string('display_name', 100);
t.integer('priority').defaultTo(0);
t.boolean('is_system').defaultTo(false);
});
await pgDb.schema.createTable('permissions', (t) => {
t.increments('id');
t.string('name', 100).notNullable().unique();
t.string('display_name', 150);
t.string('category', 50);
});
await pgDb.schema.createTable('role_permissions', (t) => {
t.integer('role_id').notNullable().references('id').inTable('roles').onDelete('CASCADE');
t.integer('permission_id').notNullable().references('id').inTable('permissions').onDelete('CASCADE');
t.primary(['role_id', 'permission_id']);
});
await pgDb.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('username').notNullable().unique();
t.string('email').notNullable().unique();
t.string('password_hash');
t.boolean('is_active').defaultTo(true);
t.boolean('must_change_password').defaultTo(false);
t.integer('role_id').references('id').inTable('roles').onDelete('SET NULL');
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
t.boolean('two_factor_enabled').defaultTo(false);
t.string('two_factor_secret');
t.text('two_factor_recovery_codes');
});
await pgDb.schema.createTable('events', (t) => {
t.increments('id');
t.string('slug');
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
});
await pgDb.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.json('setting_value');
t.string('setting_type');
t.timestamp('updated_at').defaultTo(pgDb.fn.now());
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
svc = require('../../src/services/picpeakImportService');
});
afterAll(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
if (pgDb) await pgDb.destroy();
});
beforeEach(async () => {
await pgDb('role_permissions').del();
await pgDb('events').del();
await pgDb('admin_users').del();
await pgDb('roles').del();
await pgDb('permissions').del();
});
test('resyncSequences fast-forwards stale sequences and skips id-less tables', async () => {
// Simulate a restore: explicit-id inserts leave the sequence at 1.
await pgDb('roles').insert([{ id: 5, name: 'super_admin', display_name: 'SA' }]);
await pgDb('admin_users').insert([{ id: 9, username: 'a', email: 'a@x.io', password_hash: 'h' }]);
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
await pgDb('role_permissions').insert([{ role_id: 5, permission_id: 3 }]); // id-less table
// Must not throw on role_permissions (no `id` column → pg_get_serial_sequence raises unguarded).
await expect(svc.resyncSequences(['roles', 'admin_users', 'permissions', 'role_permissions'])).resolves.toBeUndefined();
// Natural inserts (no explicit id) now avoid the restored ids.
const [adminId] = await pgDb('admin_users').insert({ username: 'b', email: 'b@x.io', password_hash: 'h' }).returning('id');
expect(Number(adminId.id || adminId)).toBe(10); // max(9)+1, no duplicate-key error
const [roleId] = await pgDb('roles').insert({ name: 'editor', display_name: 'Ed' }).returning('id');
expect(Number(roleId.id || roleId)).toBe(6);
});
test('reinjectCurrentAdmin insert branch works with a stale sequence (explicit max+1)', async () => {
await pgDb('admin_users').insert({ id: 9, username: 'backup', email: 'backup@x.io', password_hash: 'h' });
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, created_by: 42 };
await pgDb.transaction((trx) => svc.reinjectCurrentAdmin(trx, operator));
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
expect(op.id).toBe(10); // max(9)+1
expect(op.password_hash).toBe('OP');
expect(op.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
});
test('preserveOperatorRole re-creates a missing role on Postgres and keeps FK integrity', async () => {
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
await pgDb('roles').insert([{ id: 2, name: 'viewer', display_name: 'V' }]);
await pgDb('admin_users').insert({ id: 1, username: 'admin', email: 'op@x.io', password_hash: 'h', role_id: null });
const snapshot = { role: { name: 'super_admin', display_name: 'SA', priority: 100, is_system: true }, permissions: ['events.create', 'missing.perm'] };
await pgDb.transaction((trx) => svc.preserveOperatorRole(trx, 1, snapshot));
await svc.resyncSequences(['roles']); // post-commit, mirrors importFromPicpeak
const role = await pgDb('roles').where({ name: 'super_admin' }).first();
expect(role).toBeTruthy();
const op = await pgDb('admin_users').where({ id: 1 }).first();
expect(op.role_id).toBe(role.id); // FK valid, operator not downgraded
const grants = await pgDb('role_permissions').where({ role_id: role.id }).pluck('permission_id');
expect(grants).toEqual([3]); // existing perm granted, missing.perm skipped
});
test('full replaceAllTables: cross-instance backup preserves the operator, role, FKs, and sequences', async () => {
// A backup from ANOTHER instance: omits the operator's email AND their
// super_admin role; uses explicit ids that leave sequences stale.
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pgtest-'));
const dataDir = path.join(staging, 'data');
fs.mkdirSync(dataDir);
const write = (t, rows) => fs.writeFileSync(path.join(dataDir, `${t}.ndjson`), rows.map((r) => JSON.stringify(r)).join('\n'));
write('roles', [{ id: 5, name: 'admin', display_name: 'Admin', priority: 50, is_system: true }]);
write('permissions', [{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
write('role_permissions', [{ role_id: 5, permission_id: 3 }]);
write('admin_users', [{ id: 9, username: 'backupadmin', email: 'backup@x.io', password_hash: 'h', role_id: 5, is_active: true }]);
write('events', [{ id: 2, slug: 'restored-ev', created_by: 9 }]);
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, role_id: 999, created_by: null };
const roleSnapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
const tables = ['roles', 'permissions', 'role_permissions', 'admin_users', 'events'];
// replaceAllTables isn't exported, so drive its exact transaction sequence
// (suspend FKs, wipe, batchInsert, reinject, preserve role) through the
// exported units against real Postgres.
const importSvc = svc;
await pgDb.transaction(async (trx) => {
await trx.raw('SET session_replication_role = \'replica\'');
for (const t of tables) await trx(t).del();
for (const t of tables) {
const rows = fs.readFileSync(path.join(dataDir, `${t}.ndjson`), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
if (rows.length) await trx.batchInsert(t, rows, 100);
}
const opId = await importSvc.reinjectCurrentAdmin(trx, operator);
await importSvc.preserveOperatorRole(trx, opId, roleSnapshot);
await trx.raw('SET session_replication_role = \'origin\'');
});
await importSvc.resyncSequences(tables);
// Operator preserved (inserted, since email absent from backup).
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
expect(op).toBeTruthy();
expect(op.password_hash).toBe('OP');
// super_admin role re-created and the operator bound to it.
const sa = await pgDb('roles').where({ name: 'super_admin' }).first();
expect(sa).toBeTruthy();
expect(op.role_id).toBe(sa.id);
expect(await pgDb('role_permissions').where({ role_id: sa.id }).pluck('permission_id')).toEqual([3]);
// Restored event's created_by FK to the backup admin still valid.
const ev = await pgDb('events').where({ slug: 'restored-ev' }).first();
expect(ev.created_by).toBe(9);
// Sequences resynced → natural inserts don't collide.
const [newAdmin] = await pgDb('admin_users').insert({ username: 'fresh', email: 'fresh@x.io', password_hash: 'h' }).returning('id');
expect(Number(newAdmin.id || newAdmin)).toBeGreaterThan(op.id);
fs.rmSync(staging, { recursive: true, force: true });
});
});
@@ -0,0 +1,180 @@
'use strict';
// Full .picpeak roundtrip on a temp SQLite DB:
// 1. seed a "backup" instance (admin A + a marker setting)
// 2. export → .picpeak
// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data
// 4. import the backup with currentAdminId = B
// 5. assert the backup data is restored AND the current account (B) survives,
// while the backup's admin (A) is also present (different email → added).
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let createPicpeak;
let importFromPicpeak;
let validateManifest;
let superAdminRoleId;
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.STORAGE_PATH = tmpDir;
({ createPicpeak } = require('../../src/services/picpeakExportService'));
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
const role = await db('roles').where({ name: 'super_admin' }).first();
superAdminRoleId = role.id;
}, 120000);
afterAll(async () => {
await cleanup();
});
const adminRow = (email, hash) => ({
username: email,
email,
password_hash: hash,
role_id: superAdminRoleId,
is_active: true,
must_change_password: false,
created_at: new Date(),
updated_at: new Date(),
});
async function setMarker(value) {
await db('app_settings')
.insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' })
.onConflict('setting_key').merge();
}
async function getMarker() {
const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first();
return row ? JSON.parse(row.setting_value) : null;
}
describe('.picpeak roundtrip (export → import)', () => {
it('restores backup data and preserves the current account', async () => {
// 1. Seed the "source" instance.
await db('admin_users').del();
await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A'));
await setMarker('from_backup');
// 2. Export.
const { filePath } = await createPicpeak({ includePhotos: false });
try {
// 3. Simulate a reinstall: fresh current admin B, mutated data.
await db('admin_users').del();
const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id');
const currentAdminId = typeof bId === 'object' ? bId.id : bId;
await setMarker('mutated_after_backup');
// 4. Import, preserving the current admin.
const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId });
expect(result.restored).toBe(true);
expect(result.tables).toBeGreaterThan(0);
// 5a. Backup data restored (marker reverted to the backup value).
expect(await getMarker()).toBe('from_backup');
// 5b. The backup's admin is present (different email → added).
const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first();
expect(a).toBeTruthy();
expect(a.password_hash).toBe('HASH_A');
// 5c. The current account SURVIVES the override, with its own credentials.
const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first();
expect(b).toBeTruthy();
expect(b.password_hash).toBe('HASH_B');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('overwrites a backup admin that collides with the current account email', async () => {
// Source has an admin at the SAME email the current operator will use.
await db('admin_users').del();
await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH'));
await setMarker('collision_case');
const { filePath } = await createPicpeak({ includePhotos: false });
try {
// Reinstall: current admin uses the same email but a NEW password.
await db('admin_users').del();
const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id');
const currentAdminId = typeof id === 'object' ? id.id : id;
await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
// Exactly one admin at that email, and it keeps the CURRENT password.
const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']);
expect(rows).toHaveLength(1);
expect(rows[0].password_hash).toBe('NEW_HASH');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('restores files/ and reports filesRestored', async () => {
// A business-doc that lives in storage → travels in the backup.
const docDir = path.join(tmpDir, 'business-docs');
const marker = path.join(docDir, 'roundtrip-doc.txt');
fs.mkdirSync(docDir, { recursive: true });
fs.writeFileSync(marker, 'hello');
await db('admin_users').del();
const [id] = await db('admin_users').insert(adminRow('files@example.com', 'H')).returning('id');
const currentAdminId = typeof id === 'object' ? id.id : id;
const { filePath } = await createPicpeak({ includePhotos: false });
try {
fs.rmSync(marker); // delete on disk so the restore must bring it back
const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
expect(result.filesRestored).toBeGreaterThanOrEqual(1);
expect(fs.existsSync(marker)).toBe(true);
expect(fs.readFileSync(marker, 'utf8')).toBe('hello');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
fs.rmSync(docDir, { recursive: true, force: true });
}
});
});
describe('.picpeak manifest validation', () => {
it('rejects a database-engine mismatch', async () => {
// Harness runs on SQLite, so a pg manifest must be refused.
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
});
expect(blockers.some((b) => /engine/i.test(b))).toBe(true);
});
it('rejects a backup from a newer schema (forward-only)', async () => {
// validateManifest reads knex_migrations for the target's latest migration;
// the harness has none, so create it with an older migration than the backup.
await db.schema.createTable('knex_migrations', (t) => {
t.increments('id');
t.string('name');
t.integer('batch');
t.timestamp('migration_time');
});
try {
await db('knex_migrations').insert({ name: '100_baseline', batch: 1 });
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1,
database: { engine: 'sqlite', latest_migration: '999_from_the_future' },
tables: {},
});
expect(blockers.some((b) => /newer/i.test(b))).toBe(true);
} finally {
await db.schema.dropTableIfExists('knex_migrations');
}
});
it('rejects a file that is not a PicPeak backup', async () => {
const blockers = await validateManifest({ some: 'random-json' });
expect(blockers.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,458 @@
/**
* Responsive preview tiers (#1095).
*
* A phone can display ~1170px at most, so the single 1920px preview ships
* roughly twice the bytes it can use on every lightbox swipe — and the
* lightbox prefetches neighbours, so a guest flicking through a wedding
* gallery on cellular pays that repeatedly.
*
* The width is whitelisted rather than free-form: every distinct value is a
* permanent cache entry on disk, so an open ?w= is an invitation to fill the
* volume with renditions nobody asked for.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-tiers-'));
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'tiers-test-secret';
process.env.STORAGE_PATH = path.join(tmpRoot, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
const sharp = require('sharp');
const imageProcessor = require('../../src/services/imageProcessor');
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup;
describe('preview tiers (#1095)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
});
describe('normalizeTierWidth', () => {
const { normalizeTierWidth, PREVIEW_WIDTHS, THUMBNAIL_WIDTHS } = imageProcessor;
it('accepts every advertised width', () => {
for (const w of PREVIEW_WIDTHS) {
expect(normalizeTierWidth(String(w), PREVIEW_WIDTHS)).toBe(w);
}
for (const w of THUMBNAIL_WIDTHS) {
expect(normalizeTierWidth(String(w), THUMBNAIL_WIDTHS)).toBe(w);
}
});
it('rejects anything not on the list', () => {
// The disk-filling cases: arbitrary sizes, and a caller walking a range.
for (const bad of ['999', '1921', '0', '-100', '99999']) {
expect(normalizeTierWidth(bad, PREVIEW_WIDTHS)).toBeNull();
}
});
it('rejects junk without throwing', () => {
// Straight off a query string, so it is whatever the client sent.
for (const bad of [undefined, null, '', 'abc', '12abc', {}, [], '1e3', 'NaN']) {
expect(normalizeTierWidth(bad, PREVIEW_WIDTHS)).toBeNull();
}
});
it('does not let a thumbnail width through the preview list', () => {
// The two lists are separate on purpose; 600 is a thumb tier, not a
// preview tier, and vice versa for 1280.
expect(normalizeTierWidth('600', PREVIEW_WIDTHS)).toBeNull();
expect(normalizeTierWidth('1280', THUMBNAIL_WIDTHS)).toBeNull();
});
});
describe('ensurePreviewImageAtWidth', () => {
async function seedPhoto() {
const [e] = await db('events').insert({
slug: `tier-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'tier',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `tier-${Math.random()}`,
expires_at: new Date().toISOString(),
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
// A real image on disk under STORAGE_PATH, since the managed branch
// resolves through storage rather than a mount.
const rel = `events/active/tier/${Math.random().toString(36).slice(2, 8)}.jpg`;
const abs = path.join(process.env.STORAGE_PATH, rel);
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
await sharp({ create: { width: 3000, height: 2000, channels: 3, background: { r: 10, g: 90, b: 160 } } })
.jpeg().toFile(abs);
const [p] = await db('photos').insert({
event_id: eventId,
filename: path.basename(rel),
path: rel.replace(/^events\/active\//, ''),
type: 'individual',
width: 3000,
height: 2000,
processing_status: 'complete',
source_origin: 'managed',
}).returning('id');
return db('photos').where({ id: typeof p === 'object' ? p.id : p }).first();
}
it('scopes keys by photo id so two galleries cannot collide', async () => {
// The leak: managed auto-imports keep camera basenames, so two events can
// each hold an IMG_0001.jpg. A tier is served straight from a cache hit
// without re-reading the source, so a shared key hands one gallery's
// photo to another.
const a = await seedPhoto();
const b = await seedPhoto();
await db('photos').where({ id: a.id }).update({ path: 'wedding-a/IMG_0001.jpg' });
await db('photos').where({ id: b.id }).update({ path: 'wedding-b/IMG_0001.jpg' });
const keyA = imageProcessor.previewTierKeys(await db('photos').where({ id: a.id }).first())[0];
const keyB = imageProcessor.previewTierKeys(await db('photos').where({ id: b.id }).first())[0];
expect(keyA).not.toBe(keyB);
expect(keyA).toContain(`p${a.id}_`);
expect(keyB).toContain(`p${b.id}_`);
});
it('derives every non-default tier key for cleanup', () => {
// Tiers live outside preview_path, so delete/archive/regenerate have no
// other way to find them. 1920 is excluded because that IS preview_path.
const keys = imageProcessor.previewTierKeys({ id: 5, path: 'e/a.jpg', source_origin: 'managed' });
expect(keys).toHaveLength(imageProcessor.PREVIEW_WIDTHS.length - 1);
expect(keys.some((k) => k.includes('w1920'))).toBe(false);
expect(keys.every((k) => k.includes('p5_'))).toBe(true);
});
it('deletePreviewTiers removes generated tiers from storage', async () => {
const photo = await seedPhoto();
const key = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
const abs = path.join(process.env.STORAGE_PATH, key);
expect(fs.existsSync(abs)).toBe(true);
await imageProcessor.deletePreviewTiers(await db('photos').where({ id: photo.id }).first());
expect(fs.existsSync(abs)).toBe(false);
});
it('produces a distinct key per width and never touches preview_path', async () => {
const photo = await seedPhoto();
const small = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
expect(small).toContain('preview_w640_');
// The extra tiers are cache, not state. Writing them to the row would
// mean the last size requested silently becomes "the" preview.
const row = await db('photos').where({ id: photo.id }).first();
expect(row.preview_path == null || !String(row.preview_path).includes('w640')).toBe(true);
});
it('resolves the default width to the canonical preview, not a w1920 copy', async () => {
// Otherwise every existing install grows a duplicate of every preview it
// already has, for no benefit.
const photo = await seedPhoto();
const def = await imageProcessor.ensurePreviewImageAtWidth(photo, 1920);
expect(def).not.toContain('preview_w1920_');
});
it('reuses the cached tier instead of regenerating', async () => {
const photo = await seedPhoto();
const first = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280);
expect(first).toBeTruthy();
const abs = path.join(process.env.STORAGE_PATH, first);
const before = (await fs.promises.stat(abs)).mtimeMs;
await new Promise((r) => setTimeout(r, 20));
const second = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280);
expect(second).toBe(first);
expect((await fs.promises.stat(abs)).mtimeMs).toBe(before);
});
it('actually resizes to the requested tier', async () => {
const photo = await seedPhoto();
const key = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
// 3000x2000 constrained to a 640 long edge.
expect(Math.max(meta.width, meta.height)).toBe(640);
expect(meta.height).toBe(Math.round(640 * (2000 / 3000)));
});
});
describe('thumbnail tiers', () => {
async function seedThumbPhoto(w = 3000, h = 2000) {
const [e] = await db('events').insert({
slug: `tt-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding', event_name: 'tt', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: `tt-${Math.random()}`,
expires_at: new Date().toISOString(),
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
const rel = `events/active/tt/${Math.random().toString(36).slice(2, 8)}.jpg`;
const abs = path.join(process.env.STORAGE_PATH, rel);
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
await sharp({ create: { width: w, height: h, channels: 3, background: { r: 5, g: 5, b: 5 } } })
.jpeg().toFile(abs);
const [p2] = await db('photos').insert({
event_id: eventId, filename: path.basename(rel),
path: rel.replace(/^events\/active\//, ''), type: 'individual',
width: w, height: h, processing_status: 'complete', source_origin: 'managed',
}).returning('id');
return db('photos').where({ id: typeof p2 === 'object' ? p2.id : p2 }).first();
}
it('scopes thumbnail tier keys by photo id', async () => {
// Same cross-gallery hazard the preview tiers had: a cache hit serves
// without re-reading the source, so a shared basename leaks across events.
const keys = imageProcessor.thumbnailTierKeys({ id: 42, path: 'a/IMG_0001.jpg', source_origin: 'managed' });
expect(keys.every((k) => k.includes('p42_'))).toBe(true);
// Every width, canonical included: which one is canonical depends on the
// thumbnail_width setting, so on a 600-configured install w300 is the
// tier file. Deleting a key that was never written is a no-op; missing
// one strands it forever.
expect(keys).toHaveLength(3);
});
it('tags the tier against the configured width, not the 300 default', async () => {
// Regression: with thumbnail_width=600 a w=300 request wrote
// `thumb_<name>` while the caller probed `thumb_w300_<name>`. The cache
// never hit, so every request re-downloaded the original and ran Sharp,
// and the file it left behind was in no cleanup list.
await db('app_settings').where('setting_key', 'thumbnail_width')
.update({ setting_value: 600 });
try {
const photo = await seedThumbPhoto();
const first = await imageProcessor.ensureThumbnailAtWidth(photo, 300);
expect(first).toContain('thumb_w300_');
// The second call must be a cache hit on the key the first one wrote.
const before = fs.statSync(path.join(process.env.STORAGE_PATH, first)).mtimeMs;
const second = await imageProcessor.ensureThumbnailAtWidth(photo, 300);
expect(second).toBe(first);
expect(fs.statSync(path.join(process.env.STORAGE_PATH, second)).mtimeMs).toBe(before);
// ...and 600 is now the canonical, so it resolves to the plain thumbnail.
const canonical = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
expect(canonical).not.toContain('thumb_w600_');
// Cleanup still reaches the w300 tier this install actually generated.
expect(imageProcessor.thumbnailTierKeys(photo)).toContain(first);
} finally {
await db('app_settings').where('setting_key', 'thumbnail_width')
.update({ setting_value: 300 });
}
});
it('generates a tier at the requested size', async () => {
const photo = await seedThumbPhoto();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
expect(key).toContain('thumb_w600_');
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
expect(Math.max(meta.width, meta.height)).toBe(600);
});
it('does not upscale past the source, which is why the tier is clamped', async () => {
// The reason tileThumbnailWidth checks the short edge: ask a 400px
// source for 900 and withoutEnlargement caps it, so the request buys a
// Sharp run and a second cache entry for a file identical to the 300.
const small = await seedThumbPhoto(500, 400);
const key = await imageProcessor.ensureThumbnailAtWidth(small, 900);
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
expect(Math.max(meta.width, meta.height)).toBeLessThan(900);
});
it('resolves the canonical width to the normal thumbnail', async () => {
const photo = await seedThumbPhoto();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 300);
expect(key).not.toContain('thumb_w300_');
});
it('keeps the configured aspect ratio instead of forcing a square', async () => {
// Thumbnails are square by default, but the settings API takes any
// width/height in 50..1000. With fit:'cover' a 300x200 canonical and a
// 600x600 tier are two different crops, so the photo would visibly
// reframe as the tile size changed.
await db('app_settings').where('setting_key', 'thumbnail_height')
.update({ setting_value: 200 });
try {
const photo = await seedThumbPhoto();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata();
expect(meta.width).toBe(600);
expect(meta.height).toBe(400); // 600 * (200/300), not 600
} finally {
await db('app_settings').where('setting_key', 'thumbnail_height')
.update({ setting_value: 300 });
}
});
it('never hands a video to Sharp', async () => {
// A video's thumbnail is a poster frame from videoProcessor, not a
// resize of the stored file. Without the short-circuit the tier path
// would download the whole video (withLocalCopy, in full on S3) and
// then fail to decode it — every request, since nothing caches a miss.
const photo = await seedThumbPhoto();
await db('photos').where({ id: photo.id })
.update({ media_type: 'video', mime_type: 'video/mp4' });
const video = await db('photos').where({ id: photo.id }).first();
const key = await imageProcessor.ensureThumbnailAtWidth(video, 900);
expect(key).not.toContain('thumb_w900_');
});
it('drops tiers when a rename moves the basename they are keyed on', async () => {
// The key embeds the basename, so the DB update in renamePhotoFiles is
// the point past which the old keys cannot be derived at all — a later
// delete or archive computes the new ones and leaves these behind.
const renameService = require('../../src/services/eventRenameService');
const photo = await seedThumbPhoto();
const event = await db('events').where({ id: photo.event_id }).first();
// Give it a filename the rename will actually rewrite.
const dir = path.join(process.env.STORAGE_PATH, 'events/active', event.slug, 'individual');
await fs.promises.mkdir(dir, { recursive: true });
await sharp({ create: { width: 1200, height: 900, channels: 3, background: { r: 7, g: 7, b: 7 } } })
.jpeg().toFile(path.join(dir, 'Old_Name_001.jpg'));
await db('photos').where({ id: photo.id }).update({
filename: 'Old_Name_001.jpg',
path: `${event.slug}/individual/Old_Name_001.jpg`,
});
const renamable = await db('photos').where({ id: photo.id }).first();
const key = await imageProcessor.ensureThumbnailAtWidth(renamable, 600);
const abs = path.join(process.env.STORAGE_PATH, key);
expect(fs.existsSync(abs)).toBe(true);
await renameService.renamePhotoFiles(
event.id, 'Old Name', 'New Name', event.slug, event.slug
);
expect(await db('photos').where({ id: photo.id }).first())
.toMatchObject({ filename: 'New_Name_001.jpg' });
expect(fs.existsSync(abs)).toBe(false);
});
it('leaves tiers alone when a rename does not move the basename', async () => {
// Four storage deletes per photo is 20k calls against S3 for a
// 5,000-photo event whose slug merely changed, so the sweep is gated on
// the filename actually moving.
const renameService = require('../../src/services/eventRenameService');
const photo = await seedThumbPhoto();
const event = await db('events').where({ id: photo.event_id }).first();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
const abs = path.join(process.env.STORAGE_PATH, key);
// The photo's filename carries no event-name prefix, so nothing moves.
await renameService.renamePhotoFiles(
event.id, 'Old Name', 'New Name', event.slug, event.slug
);
expect(fs.existsSync(abs)).toBe(true);
});
it('deleteThumbnailTiers removes them', async () => {
const photo = await seedThumbPhoto();
const key = await imageProcessor.ensureThumbnailAtWidth(photo, 600);
const abs = path.join(process.env.STORAGE_PATH, key);
expect(fs.existsSync(abs)).toBe(true);
await imageProcessor.deleteThumbnailTiers(await db('photos').where({ id: photo.id }).first());
expect(fs.existsSync(abs)).toBe(false);
});
/**
* The crash in #1128 needed two things: a tier that disappears, and a
* reader that dies on it. The reader is fixed in streamResponse; this is
* the half that stops the file disappearing in the first place.
*/
describe('concurrent generation (#1128)', () => {
it('never leaves the tier absent once it has been published', async () => {
const photo = await seedThumbPhoto();
const key = imageProcessor.thumbnailTierKeys(photo).find((k) => k.includes('_w600_'));
const abs = path.join(process.env.STORAGE_PATH, key);
// A grid fires one request per tile at once, and on a cold gallery
// every one of them misses the cache. Previously each carried
// `regenerate: true`, whose first act is to DELETE the target — so a
// later arrival unlinked the file an earlier one had already published
// and handed to a reader.
const watcher = [];
const poll = setInterval(() => watcher.push(fs.existsSync(abs)), 1);
const results = await Promise.all(
Array.from({ length: 12 }, () => imageProcessor.ensureThumbnailAtWidth(photo, 600))
);
clearInterval(poll);
expect(results.every((r) => r === key)).toBe(true);
expect(fs.existsSync(abs)).toBe(true);
// Once true, never false again: no window where a validated file is gone.
const firstSeen = watcher.indexOf(true);
if (firstSeen !== -1) {
expect(watcher.slice(firstSeen).every(Boolean)).toBe(true);
}
});
it('runs one generation for a burst of requests, not one per request', async () => {
const photo = await seedThumbPhoto();
const thumbDir = path.join(process.env.STORAGE_PATH, 'thumbnails');
await fs.promises.mkdir(thumbDir, { recursive: true });
// Counted through the staging files LocalFsStorage writes:
// `<key>.tmp.<pid>.<hex>`, one per put, each a distinct random suffix.
// So distinct temp names == distinct generations, which is the thing
// the dedupe is supposed to collapse. (Spying on generateThumbnail
// would not work — ensureThumbnailAtWidth calls it through the
// module-local binding, so an export spy never sees it.)
const seen = new Set();
const poll = setInterval(() => {
for (const f of fs.readdirSync(thumbDir)) {
if (f.includes('_w900_') && f.includes('.tmp.')) seen.add(f);
}
}, 1);
const results = await Promise.all(
Array.from({ length: 8 }, () => imageProcessor.ensureThumbnailAtWidth(photo, 900))
);
clearInterval(poll);
const abs = path.join(
process.env.STORAGE_PATH,
imageProcessor.thumbnailTierKeys(photo).find((k) => k.includes('_w900_'))
);
expect(fs.existsSync(abs)).toBe(true);
expect(new Set(results).size).toBe(1);
// 8 requests, at most one Sharp pass. Before the dedupe this was 8 —
// and on an external photo, 8 full reads of the original.
expect(seen.size).toBeLessThanOrEqual(1);
});
it('does not cache a failure — a later request retries', async () => {
const photo = await seedThumbPhoto();
// Source removed underneath: generation fails and must not poison the
// key for the lifetime of the process.
const src = path.join(process.env.STORAGE_PATH, 'events/active', photo.path);
const saved = await fs.promises.readFile(src);
await fs.promises.unlink(src);
expect(await imageProcessor.ensureThumbnailAtWidth(photo, 600)).toBeNull();
await fs.promises.writeFile(src, saved);
expect(await imageProcessor.ensureThumbnailAtWidth(photo, 600)).toContain('_w600_');
});
});
});
});
@@ -0,0 +1,256 @@
/**
* Issue #866 — the createInvoice-free halves of the re-bill proof + CRM panel
* feature, against a real SQLite schema:
*
* • listCustomerRebills — status DERIVED from the linked invoice lifecycle
* (open / sent / paid; a cancelled/Storno'd cover drops back to open) plus
* cost-vs-rebilled math and mode.
* • collectRebillProofAttachments — the Send-dialog per-file selection, the
* all-or-none default resolution (per-customer override else global), the
* Beleg-<inv#> filename (suffix only when >1), and the missing-file marker.
*
* The invoice-MINTING paths (billCombinedForCustomer / billPendingRebills) call
* createInvoice inside a db.transaction, which deadlocks on the SQLite harness
* (global-db sequence write vs. held write lock) — same limitation the sibling
* incomingInvoiceRebill.test.js documents. They're covered by the existing
* billPendingRebills / billUnbilledEntries suites; here we hand-craft billed
* state instead.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
jest.setTimeout(120000);
describe('#866 re-bill proof attachment + CRM panel', () => {
let db;
let cleanup;
let adminId;
let expenseService;
let rebillProofs;
let flagCache;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const dbModule = require('../../src/database/db');
dbModule.logActivity = async () => {};
({ adminId } = await seedMinimal(db));
expenseService = require('../../src/services/expenseService');
rebillProofs = require('../../src/services/invoice/rebillProofs');
flagCache = require('../../src/middleware/requireFeatureFlag');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
let seq = 0;
async function makeCustomer(overrides = {}) {
seq += 1;
const ins = await db('customer_accounts').insert({
email: `c866-${seq}@example.com`,
display_name: `C866 ${seq}`,
password_hash: 'x',
preferred_language: 'de',
is_active: 1,
billing_cadence: 'per_event',
created_at: new Date(),
...overrides,
}).returning('id');
return unwrapId(ins);
}
async function makeDoc(customerId, overrides = {}) {
const ins = await db('inbound_documents').insert({
source: 'upload', status: 'categorized', parse_status: 'parsed', parse_method: 'none',
supplier_name: 'ACME AG', currency: 'CHF', total_amount_minor: 10000,
invoice_date: '2026-06-01', disposition: 'rebill', customer_account_id: customerId,
created_at: new Date(), updated_at: new Date(),
...overrides,
}).returning('id');
return unwrapId(ins);
}
async function makeInvoice(customerId, status, number) {
const ins = await db('invoices').insert({
invoice_number: number,
customer_account_id: customerId,
status,
currency: 'CHF',
issue_date: '2026-06-01', due_date: '2026-07-01',
vat_rate: 0, net_amount_minor: 10000, vat_amount_minor: 0, total_amount_minor: 10000,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return unwrapId(ins);
}
describe('listCustomerRebills', () => {
it('derives open / sent / paid and open→cost==rebilled for passthrough, +markup for rebill', async () => {
const customerId = await makeCustomer();
// Open re-bill (10% markup): rebilled = 11000.
await makeDoc(customerId, { total_amount_minor: 10000, markup_type: 'percent', markup_percent: 10 });
// Open passthrough: no markup, rebilled == cost.
await makeDoc(customerId, { disposition: 'durchlaufend', total_amount_minor: 5000, markup_type: 'none' });
// Sent (on a 'sent' invoice).
const sentInv = await makeInvoice(customerId, 'sent', 'R-2026-0001');
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: sentInv });
// Paid.
const paidInv = await makeInvoice(customerId, 'paid', 'R-2026-0002');
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: paidInv });
// Cancelled cover → drops back to 'open', no invoice link surfaced.
const cancInv = await makeInvoice(customerId, 'cancelled', 'R-2026-0003');
await makeDoc(customerId, { total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: cancInv });
const items = await expenseService.listCustomerRebills(customerId);
const byStatus = (s) => items.filter((r) => r.status === s);
expect(items).toHaveLength(5);
expect(byStatus('open')).toHaveLength(3); // 2 genuinely-open + 1 cancelled-cover
expect(byStatus('sent')).toHaveLength(1);
expect(byStatus('paid')).toHaveLength(1);
const rebill = items.find((r) => r.mode === 'rebill' && r.costMinor === 10000);
expect(rebill.rebilledMinor).toBe(11000);
const passthrough = items.find((r) => r.mode === 'passthrough');
expect(passthrough.rebilledMinor).toBe(passthrough.costMinor);
const sent = byStatus('sent')[0];
expect(sent.invoiceNumber).toBe('R-2026-0001');
expect(sent.invoiceId).toBe(sentInv);
const cancelledCover = items.find((r) => r.status === 'open' && r.invoiceNumber === null && r.costMinor === 8000);
expect(cancelledCover).toBeDefined(); // cancelled cover isn't shown as a live invoice link
});
});
describe('storno releases the re-bill linkage (#866 review)', () => {
it("clears billed_invoice_id so a Storno'd cover returns to the billable pool", async () => {
const invoiceService = require('../../src/services/invoiceService');
const customerId = await makeCustomer();
const invId = await makeInvoice(customerId, 'sent', 'R-2026-9000');
const lineIns = await db('invoice_line_items').insert({
invoice_id: invId, position: 1, quantity: 1, description: 'Rebill',
unit_price_minor: 8000, discount_percent: 0, line_total_minor: 8000,
}).returning('id');
const lineId = unwrapId(lineIns);
const docId = await makeDoc(customerId, {
total_amount_minor: 8000, markup_type: 'none', billed_invoice_id: invId, billed_invoice_line_item_id: lineId,
});
// Storno claims a fresh number from document_sequences; the other tests
// seed explicit R-2026-000x numbers without advancing it, so push the
// counter past them to avoid a number collision (a test artifact — real
// invoices always claim through the sequence).
await db('document_sequences').insert({ kind: 'invoice', year: 2026, current_value: 9000, created_at: new Date(), updated_at: new Date() })
.onConflict(['kind', 'year']).ignore();
await db('document_sequences').where({ kind: 'invoice', year: 2026 }).update({ current_value: 9000 });
// Storno the covering invoice (the issued-cancel path).
await db.transaction(async (trx) => invoiceService.createStorno(invId, adminId, trx));
const doc = await db('inbound_documents').where({ id: docId }).first();
expect(doc.billed_invoice_id).toBeNull();
expect(doc.billed_invoice_line_item_id).toBeNull();
// It now surfaces as a genuinely-open item AND the pending pool picks it up.
const items = await expenseService.listCustomerRebills(customerId);
const row = items.find((r) => r.id === docId);
expect(row.status).toBe('open');
expect(row.invoiceId).toBeNull();
const pending = await db('inbound_documents')
.where({ customer_account_id: customerId }).whereNull('billed_invoice_id')
.whereIn('disposition', ['rebill', 'durchlaufend']).where('status', 'categorized');
expect(pending.map((p) => p.id)).toContain(docId);
});
});
describe('collectRebillProofAttachments', () => {
const businessDocs = () => path.join(process.env.STORAGE_PATH, 'business-docs', 'inbound', '2026');
async function enableIncoming() {
const existing = await db('feature_flags').where({ key: 'incomingInvoices' }).first();
if (existing) await db('feature_flags').where({ key: 'incomingInvoices' }).update({ value: 1 });
else await db('feature_flags').insert({ key: 'incomingInvoices', value: 1 });
flagCache.invalidateFeatureFlagCache();
}
function writeProof(name) {
fs.mkdirSync(businessDocs(), { recursive: true });
const p = path.join(businessDocs(), name);
fs.writeFileSync(p, '%PDF-1.4\n% test proof\n');
return p;
}
it('honours explicit selection, names Beleg-<inv#>, and marks a missing file', async () => {
await enableIncoming();
const customerId = await makeCustomer();
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-1000');
const invoice = await db('invoices').where({ id: invId }).first();
const good1 = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('p1.pdf') });
const good2 = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('p2.pdf') });
const missing = await makeDoc(customerId, { billed_invoice_id: invId, file_path: path.join(businessDocs(), 'nope.pdf') });
// Select the two good proofs → two attachments, suffixed because >1.
const both = await rebillProofs.collectRebillProofAttachments(invoice, null, [good1, good2]);
expect(both.map((a) => a.filename).sort()).toEqual(['Beleg-R-2026-1000-1.pdf', 'Beleg-R-2026-1000-2.pdf']);
// Select exactly one → single, unsuffixed.
const one = await rebillProofs.collectRebillProofAttachments(invoice, null, [good1]);
expect(one).toHaveLength(1);
expect(one[0].filename).toBe('Beleg-R-2026-1000.pdf');
// Select the missing-file doc → no attachment, but a marker is persisted.
const none = await rebillProofs.collectRebillProofAttachments(invoice, null, [missing]);
expect(none).toHaveLength(0);
const markerRow = await db('inbound_documents').where({ id: missing }).first('proof_attach_error');
expect(markerRow.proof_attach_error).toBeTruthy();
// A successful attach clears any prior marker.
await rebillProofs.collectRebillProofAttachments(invoice, null, [good1]);
const cleared = await db('inbound_documents').where({ id: good1 }).first('proof_attach_error');
expect(cleared.proof_attach_error).toBeNull();
});
it('resolves the all-or-none default from the per-customer override then global', async () => {
await enableIncoming();
const customerId = await makeCustomer();
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-2000');
const invoice = await db('invoices').where({ id: invId }).first();
await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('d1.pdf') });
// Global default off, no override → none.
const off = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: null }, undefined);
expect(off).toHaveLength(0);
// Per-customer override ON → all, regardless of the (off) global.
const on = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: true }, undefined);
expect(on).toHaveLength(1);
// Global ON (no override) → all.
await db('app_settings').insert({ setting_key: 'accounting_rebill_attach_proof', setting_value: JSON.stringify(true), setting_type: 'accounting' });
const globalOn = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: null }, undefined);
expect(globalOn).toHaveLength(1);
// Override OFF beats global ON.
const overrideOff = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: false }, undefined);
expect(overrideOff).toHaveLength(0);
});
it('attaches nothing when the incoming-invoices flag is off', async () => {
const existing = await db('feature_flags').where({ key: 'incomingInvoices' }).first();
if (existing) await db('feature_flags').where({ key: 'incomingInvoices' }).update({ value: 0 });
else await db('feature_flags').insert({ key: 'incomingInvoices', value: 0 });
flagCache.invalidateFeatureFlagCache();
const customerId = await makeCustomer();
const invId = await makeInvoice(customerId, 'scheduled', 'R-2026-3000');
const invoice = await db('invoices').where({ id: invId }).first();
const doc = await makeDoc(customerId, { billed_invoice_id: invId, file_path: writeProof('f1.pdf') });
const res = await rebillProofs.collectRebillProofAttachments(invoice, { rebill_attach_proof: true }, [doc]);
expect(res).toHaveLength(0);
});
});
});
@@ -0,0 +1,298 @@
/**
* scripts/regenerate-thumbnails.js against external photos (#1148).
*
* The same defect #1129 fixed in the admin route, still standing in the CLI
* fallback: the script resolved every source as
* `storage/events/active/<photo.path>` and fs.access'd it. External and
* reference rows do not live there — their originals sit under
* `events.external_path` — so every one failed the check and was counted as an
* error. On an install where all photos are external the script did nothing at
* all, while reporting one error per photo.
*
* Driven against a REAL file on a REAL external mount with the real
* imageProcessor, not a mock: the whole point is that the source resolves off
* the mount, and a mocked ensureThumbnail would assert nothing about that.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const sharp = require('sharp');
const { execFile } = require('child_process');
describe('regenerate-thumbnails script (#1148)', () => {
let tmpDir; let db; let cleanup; let regenerateThumbnails;
let eventId; let externalPhotoId; let videoPhotoId; let watcherVideoId; let repairPhotoId;
let vanishingPhotoId;
let externalRoot;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-script-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
// External sources are sandboxed under EXTERNAL_MEDIA_ROOT. Rows carry a
// path relative to that root (#1163), so the 'wedding/' prefix on each
// external_relpath below is the event folder, not decoration.
process.env.EXTERNAL_MEDIA_ROOT = path.join(tmpDir, 'media');
externalRoot = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'wedding');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
await fs.promises.mkdir(externalRoot, { recursive: true });
jest.resetModules();
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
// A real image on the external mount — never under events/active.
await sharp({
create: { width: 1200, height: 800, channels: 3, background: { r: 10, g: 90, b: 160 } },
}).jpeg().toFile(path.join(externalRoot, 'shot.jpg'));
const [ev] = await db('events').insert({
slug: 'regen-script-event',
event_type: 'wedding',
event_name: 'Regen Script',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: '/gallery/regen-script-event/share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
source_mode: 'reference',
external_path: 'wedding',
created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [p] = await db('photos').insert({
event_id: eventId,
filename: 'shot.jpg',
// `path` is what the old script joined onto events/active. Left
// populated on purpose: the fix must ignore it for an external row.
path: 'regen-script-event/shot.jpg',
type: 'individual',
source_origin: 'external',
external_relpath: 'wedding/shot.jpg',
uploaded_at: new Date().toISOString(),
}).returning('id');
externalPhotoId = typeof p === 'object' ? p.id : p;
const [v] = await db('photos').insert({
event_id: eventId,
filename: 'clip.mp4',
path: 'regen-script-event/clip.mp4',
type: 'individual',
media_type: 'video',
mime_type: 'video/mp4',
source_origin: 'external',
external_relpath: 'wedding/clip.mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
videoPhotoId = typeof v === 'object' ? v.id : v;
// How fileWatcher.processNewPhoto actually writes a video: `type` and
// `mime_type` set, media_type left to its 'image' default. A media_type-only
// filter lets this through and hands the container to Sharp.
//
// The file has to EXIST, otherwise the row fails resolution and looks
// skipped for the wrong reason — the bug is Sharp being handed a video, not
// a missing source. Real MP4 header bytes, no image in sight.
await fs.promises.writeFile(
path.join(externalRoot, 'watched.mp4'),
Buffer.from('00000018667479706d70343200000000', 'hex')
);
const [wv] = await db('photos').insert({
event_id: eventId,
filename: 'watched.mp4',
path: 'regen-script-event/watched.mp4',
type: 'video',
mime_type: 'video/mp4',
source_origin: 'external',
external_relpath: 'wedding/watched.mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
watcherVideoId = typeof wv === 'object' ? wv.id : wv;
expect((await db('photos').where('id', watcherVideoId).first()).media_type).not.toBe('video');
// A photo whose thumbnail_path points at something that is no longer there.
await sharp({
create: { width: 900, height: 600, channels: 3, background: { r: 200, g: 40, b: 40 } },
}).jpeg().toFile(path.join(externalRoot, 'repair.jpg'));
const [rp] = await db('photos').insert({
event_id: eventId,
filename: 'repair.jpg',
path: 'regen-script-event/repair.jpg',
type: 'individual',
thumbnail_path: 'thumbnails/thumb_ext_missing_repair.jpg',
source_origin: 'external',
external_relpath: 'wedding/repair.jpg',
uploaded_at: new Date().toISOString(),
}).returning('id');
repairPhotoId = typeof rp === 'object' ? rp.id : rp;
// A photo whose source will be removed after its canonical thumbnail is
// cached — the "mount went away" case, where the canonical rendition is
// served from cache but a tier still needs to read the original.
await sharp({
create: { width: 1000, height: 700, channels: 3, background: { r: 30, g: 140, b: 60 } },
}).jpeg().toFile(path.join(externalRoot, 'vanishing.jpg'));
const [vp] = await db('photos').insert({
event_id: eventId,
filename: 'vanishing.jpg',
path: 'regen-script-event/vanishing.jpg',
type: 'individual',
source_origin: 'external',
external_relpath: 'wedding/vanishing.jpg',
uploaded_at: new Date().toISOString(),
}).returning('id');
vanishingPhotoId = typeof vp === 'object' ? vp.id : vp;
({ regenerateThumbnails } = require('../../scripts/regenerate-thumbnails'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
it('builds a thumbnail for an external photo instead of erroring on events/active', async () => {
// The location the old script computed and fs.access'd. Nothing is there,
// which is the whole defect — it is not where an external original lives.
// (The old script cannot be driven from a test directly: it had no export
// and ran on require, calling process.exit. Making it importable is part
// of this fix.)
const legacyPath = path.join(process.env.STORAGE_PATH, 'events/active', 'regen-script-event/shot.jpg');
expect(fs.existsSync(legacyPath)).toBe(false);
const result = await regenerateThumbnails(eventId, { tiers: false });
// The old script reported an error for this photo and wrote nothing.
expect(result.errorCount).toBe(0);
// The external photo, the repair row and the vanishing one; no video.
expect(result.successCount).toBe(3);
const row = await db('photos').where('id', externalPhotoId).first();
expect(row.thumbnail_path).toBeTruthy();
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
expect(fs.existsSync(onDisk)).toBe(true);
// Named per-photo so two events referencing one NAS basename cannot
// clobber each other — the property ensureThumbnail owns and the reason
// the script must not build this name itself.
expect(path.basename(row.thumbnail_path)).toContain(`ext${externalPhotoId}_`);
});
it('leaves videos alone', async () => {
// A video thumbnail is a poster frame from videoProcessor; handing the
// container to Sharp produced one error per video row.
const row = await db('photos').where('id', videoPhotoId).first();
expect(row.thumbnail_path).toBeFalsy();
});
it('leaves a watcher-imported video alone, which carries no media_type', async () => {
// fileWatcher writes type + mime_type and lets media_type default to
// 'image', so filtering on media_type alone still fed these to Sharp. The
// signal is errorCount: the images are already done by now, so the only
// thing that can fail this run is a video reaching Sharp.
const result = await regenerateThumbnails(eventId, { tiers: false });
expect(result.errorCount).toBe(0);
const row = await db('photos').where('id', watcherVideoId).first();
expect(row.thumbnail_path).toBeFalsy();
});
it('is idempotent — a second run skips instead of rebuilding', async () => {
const before = await db('photos').where('id', externalPhotoId).first();
const result = await regenerateThumbnails(eventId, { tiers: false });
expect(result.errorCount).toBe(0);
expect(result.successCount).toBe(0);
expect(result.skipCount).toBe(3);
const after = await db('photos').where('id', externalPhotoId).first();
expect(after.thumbnail_path).toBe(before.thumbnail_path);
});
it('counts a repaired thumbnail as generated, not skipped', async () => {
// Both images are valid at this point. Destroy ONE thumbnail object while
// leaving thumbnail_path pointing at it — the corrupt/missing case.
const row = await db('photos').where('id', repairPhotoId).first();
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
await fs.promises.rm(onDisk);
const result = await regenerateThumbnails(eventId, { tiers: false });
// On local and external storage the rebuilt key is identical, so inferring
// "skipped" from an unchanged path reports this repair as already valid —
// the one number an operator running this is actually reading.
expect(result.successCount).toBe(1);
expect(result.skipCount).toBe(2);
expect(result.errorCount).toBe(0);
expect(fs.existsSync(onDisk)).toBe(true);
});
it('backfills the responsive tiers, which is what a backfill is for', async () => {
// The tiers (#1095/#1109) are cached separately from thumbnail_path, so a
// gallery can hold every canonical rendition and still serve phones the
// full-size image. The old script only ever produced `thumb_<filename>` at
// a hard-coded 300px and could not backfill them at all.
const { THUMBNAIL_WIDTHS } = require('../../src/services/imageProcessor');
const imageRows = 3; // external, repaired and vanishing; videos excluded
const result = await regenerateThumbnails(eventId, { tiers: true });
expect(result.errorCount).toBe(0);
expect(result.tierCount).toBe(THUMBNAIL_WIDTHS.length * imageRows);
expect(result.tierFailures).toBe(0);
});
it('reports tiers it could not build instead of claiming success', async () => {
// ensureThumbnailAtWidth handles the expected failures itself and returns
// NULL rather than throwing — an unreachable mount, a storage write that
// did not land. A try/catch alone never sees those, so the run counted
// zero errors and printed a clean summary after backfilling nothing.
//
// Reproduced the honest way: cache the canonical rendition, then take the
// source away. The canonical is served from cache; the tiers still need
// the original.
const row = await db('photos').where('id', vanishingPhotoId).first();
expect(row.thumbnail_path).toBeTruthy();
const { deleteThumbnailTiers } = require('../../src/services/imageProcessor');
await deleteThumbnailTiers(row).catch(() => {});
await fs.promises.rm(path.join(externalRoot, 'vanishing.jpg'));
const result = await regenerateThumbnails(eventId, { tiers: true });
expect(result.tierFailures).toBeGreaterThan(0);
// Still not an error against the photo: the canonical rendition is intact
// and the gallery falls back to it.
expect(result.errorCount).toBe(0);
});
/** Run the CLI the way cron does, and hand back its exit status. */
const runCli = (args = []) => new Promise((resolve) => {
execFile(
process.execPath,
[path.join(__dirname, '..', '..', 'scripts', 'regenerate-thumbnails.js'), ...args],
{ env: { ...process.env }, cwd: path.join(__dirname, '..', '..') },
(error, stdout, stderr) => resolve({ code: error?.code ?? 0, stdout, stderr })
);
});
it('exits nonzero when work was left unfinished', async () => {
// Exit status is the only thing a cron job reads. `vanishing.jpg` still
// has no source, so its tiers cannot be built.
const failed = await runCli([String(eventId)]);
expect(failed.code).toBe(1);
expect(failed.stderr).toContain('completed with failures');
}, 120000);
it('exits zero when there is nothing left to do', async () => {
// Same event with tiers switched off: every canonical rendition is already
// valid, so a clean run must not cry wolf at automation.
const ok = await runCli([String(eventId), '--no-tiers']);
expect(ok.code).toBe(0);
expect(ok.stdout).toContain('Script completed successfully');
}, 120000);
});
@@ -0,0 +1,82 @@
/**
* CLI test for scripts/reset-admin-mfa.js — break-glass MFA reset (#738).
*
* Boots a temp-SQLite DB, seeds an admin with MFA fully enabled, then runs
* the script in a child process (--email <addr> --yes) pointed at the same
* DB file, and asserts the four MFA columns are zeroed. The script runs in
* its own process with its own knex connection; the parent connection is
* idle during the spawn so the SQLite write lock isn't contended.
*/
const path = require('path');
const { execFileSync } = require('child_process');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(120000);
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'reset-admin-mfa.js');
async function seedEnrolledAdmin(email) {
const inserted = await db('admin_users').insert({
username: email.split('@')[0],
email,
password_hash: 'x',
is_active: true,
two_factor_enabled: true,
two_factor_secret: 'iv.tag.ct',
two_factor_recovery_codes: JSON.stringify(['$2b$10$fakehashfakehashfakehashfa']),
two_factor_enrolled_at: new Date(),
created_at: new Date(),
}).returning('id');
return inserted[0]?.id ?? inserted[0];
}
it('zeroes the four MFA columns for the targeted admin', async () => {
const email = 'reset-me@example.com';
const id = await seedEnrolledAdmin(email);
execFileSync('node', [SCRIPT, '--email', email, '--yes'], {
env: {
...process.env,
NODE_ENV: 'test',
TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH,
},
stdio: 'pipe',
});
const row = await db('admin_users').where({ id }).first();
expect(Number(row.two_factor_enabled)).toBe(0);
expect(row.two_factor_secret).toBeNull();
expect(row.two_factor_recovery_codes).toBeNull();
expect(row.two_factor_enrolled_at).toBeNull();
});
it('leaves a different admin untouched', async () => {
const targetEmail = 'target@example.com';
const bystanderEmail = 'bystander@example.com';
const targetId = await seedEnrolledAdmin(targetEmail);
const bystanderId = await seedEnrolledAdmin(bystanderEmail);
execFileSync('node', [SCRIPT, '--email', targetEmail, '--yes'], {
env: { ...process.env, NODE_ENV: 'test', TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH },
stdio: 'pipe',
});
const target = await db('admin_users').where({ id: targetId }).first();
const bystander = await db('admin_users').where({ id: bystanderId }).first();
expect(Number(target.two_factor_enabled)).toBe(0);
expect(Number(bystander.two_factor_enabled)).toBe(1);
expect(bystander.two_factor_secret).toBe('iv.tag.ct');
});
@@ -183,22 +183,24 @@ describe('restoreService — PG branch scope contract (PR #596 review)', () => {
expect(window).toMatch(/was_successful:\s*true/);
});
it('npm run migrate:safe is invoked after the replay in restore()', () => {
it('the safe migration runner is invoked after the replay in restore()', () => {
// Contract from PR #596 round 4: backups taken on older picpeak
// versions must restore COMPLETELY on a newer image — even if new
// migrations have been added since the backup was taken. The
// restore() flow shells out to `npm run migrate:safe` AFTER the
// restore() flow shells out to the safe migration runner AFTER the
// operator-meta replay so the schema catches up to the running
// code WITHIN the restore boundary (not on the next container
// restart).
// restart). Invoked as `node migrations/run-migrations-safe.js` —
// the runtime image ships no npm, so the former `npm run
// migrate:safe` would ENOENT into the non-fatal catch.
//
// Contract:
// 1. A `migrate:safe` shell-out exists somewhere in restoreService
// 1. A run-migrations-safe shell-out exists somewhere in restoreService
// 2. It sits AFTER the replay drain — verification → replay →
// migrations is the documented order
// 3. It does NOT sit inside performDatabaseRestore (must run
// against the reinit'd pool from the parent restore())
const migrateLine = findFirst(/['"]migrate:safe['"]/);
const migrateLine = findFirst(/run-migrations-safe\.js/);
expect(migrateLine).toBeGreaterThan(0);
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
@@ -0,0 +1,406 @@
/**
* Reveal mode integration tests (#838).
*
* Pins the contract:
* - effective visibility is computed at request time (isGalleryHidden):
* reveal_at in the past opens the gate even before the scheduler stamps
* - /photos returns the event shell with photos: [] + hidden_until_reveal
* for plain guests; slideshow / client / admin-preview see everything
* - image + download endpoints 403 with GALLERY_HIDDEN for plain guests
* - the guest upload route is NOT gated (uploading while hidden is the point)
* - the scheduler stamps revealed_at for due events, exactly once
* - POST /events/:id/reveal stamps revealed_at (idempotent, 400 when the
* mode is off); re-enabling reveal_mode clears revealed_at (re-hide)
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'reveal-test-secret';
const SLUG = 'reveal-test-event';
describe('Reveal mode (#838)', () => {
let db;
let cleanup;
let app;
let eventId;
let photoIds;
let adminToken;
const { isGalleryHidden } = require('../../src/utils/revealMode');
const galleryToken = (extra = {}) => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', ...extra },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Reveal Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'reveal-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_user_uploads: 1,
reveal_mode: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
photoIds = [];
for (let i = 0; i < 2; i++) {
const p = await db('photos').insert({
event_id: eventId,
filename: `photo-${i}.jpg`,
path: `events/reveal/${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(p[0]?.id ?? p[0]);
}
// Super admin for the admin routes.
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'reveal-admin',
email: 'reveal-admin@example.com',
password_hash: await bcrypt.hash('RevealAdmin123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'reveal-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/secure-images', require('../../src/routes/secureImages'));
app.use('/api/images', require('../../src/routes/protectedImages'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('effective visibility math (isGalleryHidden)', () => {
const base = { reveal_mode: true, revealed_at: null, reveal_at: null };
it('is hidden while armed and unrevealed, visible otherwise', () => {
expect(isGalleryHidden({ ...base })).toBe(true);
expect(isGalleryHidden({ ...base, reveal_mode: false })).toBe(false);
expect(isGalleryHidden({ ...base, revealed_at: new Date() })).toBe(false);
// reveal_at in the past opens the gate WITHOUT any stamp — time-exact.
expect(isGalleryHidden({ ...base, reveal_at: new Date(Date.now() - 60_000) })).toBe(false);
expect(isGalleryHidden({ ...base, reveal_at: new Date(Date.now() + 60_000) })).toBe(true);
// SQLite 0/1 booleans
expect(isGalleryHidden({ reveal_mode: 1, revealed_at: null, reveal_at: null })).toBe(true);
expect(isGalleryHidden({ reveal_mode: 0, revealed_at: null, reveal_at: null })).toBe(false);
});
});
describe('gallery routes while hidden', () => {
it('/photos gives plain guests the shell with no photos and the flag', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(true);
expect(res.body.photos).toEqual([]);
expect(res.body.categories).toEqual([]);
expect(res.body.event.event_name).toBe('Reveal Test');
});
it('/photos serves the slideshow token everything (surprise beamer)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'slideshow' })}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('/photos serves client access everything (host review)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'client' })}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('/photos serves the admin preview everything (new transport: ?admin_preview=1 + admin cookie, even with a coexisting gallery session)', async () => {
// #868/#981: reveal-mode hiding is bypassed for an admin preview via the
// new transport (explicit flag + httpOnly admin_token cookie), NOT the
// retired ?preview=<jwt>. The coexisting gallery Bearer must not shadow it.
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos?admin_preview=1`)
.set('Cookie', [`admin_token=${adminToken}`])
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('image and download endpoints 403 with GALLERY_HIDDEN for plain guests', async () => {
for (const url of [
`/api/gallery/${SLUG}/thumbnail/${photoIds[0]}`,
`/api/gallery/${SLUG}/photo/${photoIds[0]}`,
`/api/gallery/${SLUG}/download/${photoIds[0]}`,
`/api/gallery/${SLUG}/download-all`,
`/api/gallery/${SLUG}/stats`,
`/api/gallery/${SLUG}/hero/${photoIds[0]}`,
]) {
const res = await request(app).get(url).set('Authorization', `Bearer ${galleryToken()}`);
expect(`${url}:${res.status}`).toBe(`${url}:403`);
expect(res.body.code).toBe('GALLERY_HIDDEN');
}
});
it('image endpoints are NOT reveal-blocked for the slideshow token', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/thumbnail/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'slideshow' })}`);
// The seeded file doesn't exist on disk, so anything but the reveal
// gate's 403 is fine here.
expect(res.body.code).not.toBe('GALLERY_HIDDEN');
});
it('/info exposes the effective hidden state without auth', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/info`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(true);
});
it('the guest upload route is not gated', async () => {
const res = await request(app)
.post(`/api/gallery/${eventId}/upload`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({});
// Fails later for other reasons (no multipart body) — but never on the
// reveal gate.
expect(res.body.code).not.toBe('GALLERY_HIDDEN');
});
it('legacy protected-image routes are reveal-gated for plain guests', async () => {
for (const [method, url] of [
['get', `/api/images/${SLUG}/photo/${photoIds[0]}/view`],
['post', `/api/images/${SLUG}/photo/${photoIds[0]}/generate-secure-token`],
['post', `/api/images/${SLUG}/photo/${photoIds[0]}/generate-url`],
]) {
const res = await request(app)[method](url).set('Authorization', `Bearer ${galleryToken()}`);
expect(`${url}:${res.status}`).toBe(`${url}:403`);
expect(res.body.code).toBe('GALLERY_HIDDEN');
}
});
it('feedback endpoints are reveal-gated; my-feedback degrades to empty', async () => {
// Feedback must be enabled for the routes to get past their own gate.
await db('event_feedback_settings').insert({
event_id: eventId, feedback_enabled: 1, allow_likes: 1,
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
});
const getRes = await request(app)
.get(`/api/gallery/${SLUG}/photos/${photoIds[0]}/feedback`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(getRes.status).toBe(403);
expect(getRes.body.code).toBe('GALLERY_HIDDEN');
const postRes = await request(app)
.post(`/api/gallery/${SLUG}/photos/${photoIds[0]}/feedback`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ feedback_type: 'like' });
expect(postRes.status).toBe(403);
expect(postRes.body.code).toBe('GALLERY_HIDDEN');
const mine = await request(app)
.get(`/api/gallery/${SLUG}/my-feedback`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(mine.status).toBe(200);
expect(mine.body).toEqual([]);
});
it('secure-image token minting is reveal-gated for plain guests', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ photoId: photoIds[0] });
expect(res.status).toBe(403);
expect(res.body.code).toBe('GALLERY_HIDDEN');
});
it('customer-portal tokens (via:customer, no accessLevel) bypass reveal mode', async () => {
const acct = await db('customer_accounts').insert({
email: 'portal-customer@example.com',
password_hash: 'x',
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id');
const customerId = acct[0]?.id ?? acct[0];
await db('event_customer_assignments').insert({
event_id: eventId,
customer_account_id: customerId,
});
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken({ via: 'customer', customerId })}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('a reveal_at in the past opens the gate without any stamp', async () => {
await db('events').where('id', eventId).update({ reveal_at: new Date(Date.now() - 60_000).toISOString() });
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
await db('events').where('id', eventId).update({ reveal_at: null });
});
});
describe('scheduler and admin reveal', () => {
it('the scheduler stamps revealed_at for due events exactly once', async () => {
const revealAt = new Date(Date.now() - 5 * 60_000);
await db('events').where('id', eventId).update({ reveal_at: revealAt.toISOString(), revealed_at: null });
const { checkScheduledReveals } = require('../../src/services/revealScheduler');
await checkScheduledReveals();
const asMs = (v) => new Date(v).getTime();
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).not.toBeNull();
expect(asMs(row.revealed_at)).toBe(revealAt.getTime());
expect(row.reveal_at).toBeNull(); // schedule consumed, like "Reveal now"
// Second pass no-ops (revealed_at already set).
await checkScheduledReveals();
const again = await db('events').where('id', eventId).first();
expect(asMs(again.revealed_at)).toBe(revealAt.getTime());
await db('events').where('id', eventId).update({ reveal_at: null, revealed_at: null });
});
it('POST /:id/reveal stamps revealed_at, clears the schedule, and is idempotent', async () => {
await db('events').where('id', eventId).update({ reveal_at: new Date(Date.now() + 3600_000).toISOString() });
const res = await request(app)
.post(`/api/admin/events/${eventId}/reveal`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body.revealed_at).toBeTruthy();
// "Reveal now" consumes the pending schedule.
const cleared = await db('events').where('id', eventId).first();
expect(cleared.reveal_at).toBeNull();
const first = res.body.revealed_at;
const res2 = await request(app)
.post(`/api/admin/events/${eventId}/reveal`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res2.status).toBe(200);
expect(res2.body.revealed_at).toBe(first);
// Guests see photos now.
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(false);
expect(gallery.body.photos).toHaveLength(2);
});
it('re-enabling reveal_mode clears revealed_at (re-hide)', async () => {
await db('events').where('id', eventId).update({ reveal_mode: 0 });
const res = await request(app)
.put(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ reveal_mode: true });
expect(res.status).toBe(200);
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).toBeNull();
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(true);
});
it('scheduling a FUTURE reveal on a revealed gallery re-arms hiding', async () => {
// State: revealed (previous tests). Saving a future schedule re-hides.
await db('events').where('id', eventId).update({ revealed_at: new Date().toISOString() });
const res = await request(app)
.put(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ reveal_mode: true, reveal_at: new Date(Date.now() + 3600_000).toISOString() });
expect(res.status).toBe(200);
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).toBeNull();
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(true);
await db('events').where('id', eventId).update({ reveal_at: null });
});
it('re-arming without a schedule clears a stale PAST reveal_at', async () => {
// Legacy/partial-API state: revealed with the old past schedule still
// stored. {reveal_mode:false} then {reveal_mode:true} without
// reveal_at must re-hide, not instantly re-open via the stale date.
await db('events').where('id', eventId).update({
reveal_mode: 0,
revealed_at: new Date().toISOString(),
reveal_at: new Date(Date.now() - 3600_000).toISOString(),
});
const res = await request(app)
.put(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ reveal_mode: true });
expect(res.status).toBe(200);
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).toBeNull();
expect(row.reveal_at).toBeNull();
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(true);
expect(gallery.body.photos).toEqual([]);
});
it('POST /:id/reveal 400s while reveal mode is off', async () => {
await db('events').where('id', eventId).update({ reveal_mode: 0, revealed_at: null });
const res = await request(app)
.post(`/api/admin/events/${eventId}/reveal`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(400);
await db('events').where('id', eventId).update({ reveal_mode: 1 });
});
});
});
@@ -27,7 +27,7 @@ beforeAll(async () => {
setupService = require('../../src/services/setupService');
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
}, 60000);
}, 120000);
afterAll(async () => {
await cleanup();
@@ -105,6 +105,133 @@ describe('setupService (first-run bootstrap)', () => {
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
});
it('restores 0600 on a token file that already existed with looser permissions (#1218)', async () => {
// fs.writeFileSync's `mode` applies only when the file is created, so
// writing over a 0644 file left the first-admin credential group- and
// world-readable while the code claimed otherwise. On a NAS the volume is
// often a shared mount, which is exactly where that matters.
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
fs.writeFileSync(canonical, 'stale\n', { mode: 0o644 });
fs.chmodSync(canonical, 0o644);
expect(fs.statSync(canonical).mode & 0o777).toBe(0o644);
const token = await setupService.ensureSetupToken();
expect(fs.readFileSync(canonical, 'utf8').trim()).toBe(token);
expect(fs.statSync(canonical).mode & 0o777).toBe(0o600);
});
it('never publishes a token it cannot make private (#1218)', async () => {
// The CIFS/SMB case this targets: the mount carries no Unix modes, so
// chmod is a silent no-op. The check runs on the temporary file, before
// the rename, so a credential that cannot be protected never reaches the
// published path at all.
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
try { fs.unlinkSync(canonical); } catch (_) { /* start clean */ }
const chmodSpy = jest.spyOn(fs, 'chmodSync').mockImplementation(() => {});
const realLstat = fs.lstatSync;
const lstatSpy = jest.spyOn(fs, 'lstatSync').mockImplementation((target, ...rest) => {
const st = realLstat(target, ...rest);
return String(target).includes('SETUP_TOKEN')
? { ...st, mode: (st.mode & ~0o777) | 0o644 }
: st;
});
try {
const token = await setupService.ensureSetupToken();
// Setup stays completable — server.js prints the token on stdout when no
// file was written — but nothing readable was left on the volume.
expect(token).toBeTruthy();
expect(fs.existsSync(canonical)).toBe(false);
expect(fs.readdirSync(tmpDir).filter((f) => f.includes('.tmp'))).toEqual([]);
} finally {
chmodSpy.mockRestore();
lstatSpy.mockRestore();
}
});
it('keeps the token out of the log files when no private copy is possible (#1218)', async () => {
// LOG_DIR is on the same mount as the token in the all-in-one image, so
// logging the credential would put it in combined.log — as readable as the
// file we just refused to leave, and it outlives setup. stdout is the
// fallback instead, which server.js prints.
const logger = require('../../src/utils/logger');
try { fs.unlinkSync(path.join(tmpDir, 'SETUP_TOKEN')); } catch (_) { /* start clean */ }
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
const chmodSpy = jest.spyOn(fs, 'chmodSync').mockImplementation(() => {});
const realStat = fs.lstatSync;
const statSpy = jest.spyOn(fs, 'lstatSync').mockImplementation((target, ...rest) => {
const st = realStat(target, ...rest);
// The mode check runs on the temporary file, so match the prefix.
return String(target).includes('SETUP_TOKEN')
? { ...st, mode: (st.mode & ~0o777) | 0o644 }
: st;
});
try {
const token = await setupService.ensureSetupToken();
const logged = warnSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(logged).toMatch(/could not write a private setup token file/i);
expect(logged).not.toContain(token);
} finally {
warnSpy.mockRestore();
chmodSpy.mockRestore();
statSpy.mockRestore();
}
});
it('tells the startup banner where the token went, so it is never printed (#1218)', async () => {
// server.js prints the token itself only when no file was written. If this
// reports nothing after a successful write, the banner takes that failure
// branch and puts the live credential into stdout and `docker logs` beside
// a perfectly good 0600 file.
const token = await setupService.ensureSetupToken();
expect(token).toBeTruthy();
expect(setupService.writtenSetupTokenFile()).toBe(path.join(tmpDir, 'SETUP_TOKEN'));
});
it('lets a second worker publish without disturbing the first (#1218)', async () => {
// The shipped PM2 cluster config runs several workers against one DATA_DIR.
// Publishing through rename means they simply overwrite the same value in
// turn — no shared inode to race, and neither worker can end up reporting
// nothing written and printing the live token to its own log.
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
const first = await setupService.ensureSetupToken();
expect(setupService.writtenSetupTokenFile()).toBe(canonical);
const second = await setupService.ensureSetupToken();
expect(second).toBe(first);
expect(setupService.writtenSetupTokenFile()).toBe(canonical);
expect(fs.readFileSync(canonical, 'utf8').trim()).toBe(first);
expect(fs.statSync(canonical).mode & 0o777).toBe(0o600);
// No temporary files left lying about.
expect(fs.readdirSync(tmpDir).filter((f) => f.includes('.tmp'))).toEqual([]);
});
it('revokes the token when it cannot replace an exposed file (#1218)', async () => {
// A restart reuses the token from the database, so a file left at the
// token path may hold the live value. If it cannot be replaced — an
// ACL-backed or read-only directory — that credential is out of our
// control, and /setup/admin would go on accepting it.
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
await setupService.ensureSetupToken();
const renameSpy = jest.spyOn(fs, 'renameSync').mockImplementation(() => {
const err = new Error('EACCES'); err.code = 'EACCES'; throw err;
});
try {
expect(await setupService.ensureSetupToken()).toBeNull();
expect(await getAppSetting('setup_token')).toBeFalsy();
// And the temporary file did not survive the failure.
expect(fs.readdirSync(tmpDir).filter((f) => f.includes('.tmp'))).toEqual([]);
} finally {
renameSpy.mockRestore();
try { fs.unlinkSync(canonical); } catch (_) { /* may be gone */ }
}
});
it('refuses to create a second admin (setup already complete)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
@@ -0,0 +1,424 @@
/**
* The shared colour tag (#1197).
*
* A third identity model, requested in #1178: not "everyone shares a device's
* state" but "everyone, on any device, shares the PHOTO's state". One
* identity-less colour tag per photo, and whoever writes last wins.
*
* Stored as an ordinary photo_feedback row under a reserved identifier rather
* than as a column on photos, which is what keeps the per-colour tallies, the
* filters, the moderation queue and the XMP/CSV export working unchanged: the
* tally simply has exactly one entry.
*
* The mode is scoped to the colour tag. Likes, ratings and the rest stay
* per-guest, so the last test here is as important as the first.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
const { SHARED_COLOR_LABEL_IDENTITY } = require('../../src/constants/colorLabels');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'shared-tag-secret';
const SLUG = 'shared-color-tag';
describe('shared colour tag (#1197)', () => {
let db; let cleanup; let app; let feedbackService;
let eventId; let photoId; let otherPhotoId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
// Two different devices: distinct UA strings give distinct
// generateGuestIdentifier hashes, which is exactly how `simple` mode tells
// two anonymous guests apart.
const asGuest = (ua) => ({ 'Authorization': `Bearer ${galleryToken()}`, 'User-Agent': ua });
const tag = (ua, color, id = photoId) => request(app)
.post(`/api/gallery/${SLUG}/photos/${id}/feedback`)
.set(asGuest(ua))
.send({ feedback_type: 'color_label', color_label: color });
const photosFor = async (ua, query = '') => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos${query}`)
.set(asGuest(ua));
expect(res.status).toBe(200);
return Array.isArray(res.body) ? res.body : res.body.photos;
};
const photoFor = async (ua) => (await photosFor(ua)).find((p) => p.id === photoId);
const feedbackFor = async (ua) => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
.set(asGuest(ua));
expect(res.status).toBe(200);
return res.body;
};
const sharedRows = () => db('photo_feedback').where({
photo_id: photoId, feedback_type: 'color_label',
guest_identifier: SHARED_COLOR_LABEL_IDENTITY,
});
const setMode = (mode) => db('event_feedback_settings')
.where({ event_id: eventId }).update({ identity_mode: mode });
const setSharing = (on) => db('event_feedback_settings')
.where({ event_id: eventId }).update({ show_feedback_to_guests: on });
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
feedbackService = require('../../src/services/feedbackService');
const [ev] = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Shared Colour Tag',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'shared-tag-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [p] = await db('photos').insert({
event_id: eventId, filename: 'a.jpg', path: 'events/shared/a.jpg',
type: 'individual', uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = typeof p === 'object' ? p.id : p;
const [p2] = await db('photos').insert({
event_id: eventId, filename: 'b.jpg', path: 'events/shared/b.jpg',
type: 'individual', uploaded_at: new Date().toISOString(),
}).returning('id');
otherPhotoId = typeof p2 === 'object' ? p2.id : p2;
await db('event_feedback_settings').insert({
event_id: eventId, feedback_enabled: true, allow_likes: true,
allow_color_labels: true, moderate_comments: false,
show_feedback_to_guests: true, identity_mode: 'shared',
});
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
}, 180000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('photo_feedback').where({ event_id: eventId }).del();
await db('photos').where('event_id', eventId).update({ color_label_count: 0, like_count: 0 });
await setMode('shared');
await setSharing(true);
});
describe('one tag per photo, last write wins', () => {
it('lets a second guest overwrite the first guest\'s colour', async () => {
// The request in the reporter's words: "if guest A marks a photo green
// and guest B later marks the same photo orange, the shared tag simply
// becomes orange".
expect((await tag('device-A', 'green')).status).toBe(200);
expect((await tag('device-B', 'blue')).status).toBe(200);
const rows = await sharedRows().select('color_label');
expect(rows).toHaveLength(1);
expect(rows[0].color_label).toBe('blue');
});
it('shows the same tag to a guest who never set one', async () => {
await tag('device-A', 'green');
// Different device, different identifier — in simple mode this would
// read back null, which is the whole reason the mode exists.
expect((await photoFor('device-B')).my_color_label).toBe('green');
});
it('stores no attribution against the tag', async () => {
await tag('device-A', 'green');
const row = await sharedRows().first();
expect(row.guest_id).toBeFalsy();
expect(row.guest_name).toBeFalsy();
expect(row.guest_email).toBeFalsy();
});
it('clears the tag when any guest re-sends the colour already on it', async () => {
await tag('device-A', 'green');
// B, not A: clearing is not owned by whoever set it.
expect((await tag('device-B', 'green')).status).toBe(200);
expect(await sharedRows().first()).toBeFalsy();
expect((await photoFor('device-A')).my_color_label).toBeFalsy();
});
it('leaves exactly one tag when two guests write at the same instant', async () => {
// The race the transaction exists for: both writers read "no tag", both
// insert, and the photo ends up carrying two shared tags — a per-guest
// tally in the one mode that is supposed to have none.
await Promise.all([
tag('device-A', 'green'),
tag('device-B', 'red'),
tag('device-C', 'blue'),
]);
const rows = await sharedRows().select('color_label');
expect(rows).toHaveLength(1);
expect(['green', 'red', 'blue']).toContain(rows[0].color_label);
});
it('keeps the tag on the photo it was set on', async () => {
await tag('device-A', 'green');
await tag('device-B', 'red', otherPhotoId);
const photos = await photosFor('device-C');
expect(photos.find((p) => p.id === photoId).my_color_label).toBe('green');
expect(photos.find((p) => p.id === otherPhotoId).my_color_label).toBe('red');
});
});
describe('the aggregates keep their existing shape', () => {
it('reports the tally as a single colour with a count of one', async () => {
await tag('device-A', 'green');
await tag('device-B', 'red');
// Unchanged consumers — grid badge, XMP export, admin filter — read
// this map and dominantColorLabel() over it. One entry, so the dominant
// colour is simply the tag.
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({ red: 1 });
const photo = await db('photos').where('id', photoId).first();
expect(photo.color_label_count).toBe(1);
});
it('does not render the tag a second time as another viewer\'s dot', async () => {
await tag('device-A', 'green');
// The shared row is not filed under device-B, so without the mode check
// it would come back as "someone else's label" and the tile would show
// the same green twice — once as the badge, once as a dot beside it.
const photo = await photoFor('device-B');
expect(photo.my_color_label).toBe('green');
expect(photo.other_color_labels || []).toHaveLength(0);
});
});
describe('with show_feedback_to_guests off', () => {
it('still shows the tag — it is the photo\'s state, not someone else\'s opinion', async () => {
await tag('device-A', 'green');
await setSharing(false);
expect((await photoFor('device-B')).my_color_label).toBe('green');
expect((await feedbackFor('device-B')).my_feedback.color_label).toBe('green');
});
it('still hides the per-colour tallies', async () => {
await tag('device-A', 'green');
await setSharing(false);
expect((await feedbackFor('device-B')).color_labels).toEqual({});
});
it('answers a colour filter from the shared tag', async () => {
await tag('device-A', 'green');
await setSharing(false);
// The aggregate half of this filter is gated on sharing; in shared mode
// the tag counts as the viewer's own, so the filter still works.
const filtered = await photosFor('device-B', '?filter=color:green');
expect(filtered.map((p) => p.id)).toEqual([photoId]);
});
});
describe('switching modes is not destructive', () => {
it('ignores per-guest labels while shared, and gives them back afterwards', async () => {
await setMode('simple');
await tag('device-A', 'green');
await tag('device-B', 'red');
const perGuestRows = await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'color_label' }).count('* as c').first();
expect(Number(perGuestRows.c)).toBe(2);
await setMode('shared');
// Nothing collapsed, nothing guessed: the shared tag starts empty.
expect((await photoFor('device-A')).my_color_label).toBeFalsy();
expect(await sharedRows().first()).toBeFalsy();
await setMode('simple');
// ...and every original mark is exactly where its owner left it.
expect((await photoFor('device-A')).my_color_label).toBe('green');
expect((await photoFor('device-B')).my_color_label).toBe('red');
});
it('keeps the shared tag intact across a round trip through simple mode', async () => {
await tag('device-A', 'green');
await setMode('simple');
await setMode('shared');
expect((await photoFor('device-B')).my_color_label).toBe('green');
});
// "Kept but not shown" has to mean every surface, not just the badge. The
// dormant set is still sitting in photo_feedback, so a read that does not
// say which set it means will happily count, tally, filter and export it.
describe('the dormant set stays out of every reading of the live one', () => {
const seedDormantPerGuestLabels = async () => {
await setMode('simple');
await tag('device-A', 'green');
await tag('device-B', 'red', otherPhotoId);
await setMode('shared');
};
it('keeps dormant labels out of the per-colour tallies', async () => {
await seedDormantPerGuestLabels();
// The lightbox renders this map. Green belongs to a per-guest row the
// mode does not use, so the photo reads as untagged.
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({});
await tag('device-C', 'blue');
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({ blue: 1 });
});
it('keeps dormant labels out of color_label_count', async () => {
await seedDormantPerGuestLabels();
await feedbackService.updatePhotoFeedbackStats(photoId);
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(0);
await tag('device-C', 'blue');
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(1);
});
it('keeps dormant labels out of the admin grid and the XMP/CSV export', async () => {
await seedDormantPerGuestLabels();
// getEventColorLabelCounts feeds dominant_color_label, which is what
// the admin badge shows and what xmp:Label round-trips into Lightroom.
const map = await feedbackService.getEventColorLabelCounts(eventId, [photoId, otherPhotoId]);
expect(map[photoId]).toBeUndefined();
expect(map[otherPhotoId]).toBeUndefined();
});
it('keeps dormant labels out of the raw feedback list too', async () => {
// The per-colour tallies were scoped, but the endpoint also returns the
// rows themselves. Those carried both sets, so a dormant per-guest
// label was still visible to anyone reading the list — and with
// sharing off it came back flagged as the caller's own.
await seedDormantPerGuestLabels();
const body = await feedbackFor('device-A');
expect(body.feedback.filter((f) => f.feedback_type === 'color_label')).toHaveLength(0);
expect(body.my_feedback.color_label).toBeFalsy();
await tag('device-A', 'blue');
const after = await feedbackFor('device-B');
const labels = after.feedback.filter((f) => f.feedback_type === 'color_label');
expect(labels.map((f) => f.color_label)).toEqual(['blue']);
});
it('does not answer a guest colour filter from a dormant label', async () => {
await seedDormantPerGuestLabels();
expect(await photosFor('device-A', '?filter=color:green')).toHaveLength(0);
});
it('does not show the shared tag as another guest\'s dot after switching back', async () => {
await tag('device-A', 'green');
await setMode('simple');
// The shared row belongs to nobody, so in a per-guest mode it is not
// "another viewer's label" either — it is simply not in play.
const photo = await photoFor('device-B');
expect(photo.my_color_label).toBeFalsy();
expect(photo.other_color_labels || []).toHaveLength(0);
});
it('recounts the stored per-photo counter when the mode changes', async () => {
// color_label_count is denormalized and recomputed on feedback writes.
// A mode switch changes which rows are live without any write, so
// without an explicit recount the tiles and the admin summary keep
// reporting the old mode's totals until each photo is touched again —
// on a finished gallery, never.
await setMode('simple');
await tag('device-A', 'green');
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(1);
await feedbackService.updateEventFeedbackSettings(eventId, { identity_mode: 'shared' });
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(0);
await feedbackService.updateEventFeedbackSettings(eventId, { identity_mode: 'simple' });
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(1);
});
it('does not count the shared tag as a participant', async () => {
// feedback_count is COUNT(DISTINCT guest identity) and is exported as
// rating_count. The shared tag has a reserved identifier rather than a
// person's, so counting it made tagging a photo look like a second
// guest had left feedback — and inflated the exported rating count.
await request(app)
.post(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
.set(asGuest('device-A'))
.send({ feedback_type: 'rating', rating: 5 });
expect((await db('photos').where('id', photoId).first()).feedback_count).toBe(1);
await tag('device-B', 'green');
expect((await db('photos').where('id', photoId).first()).feedback_count).toBe(1);
});
it('keeps dormant labels out of the event feedback summary', async () => {
await seedDormantPerGuestLabels();
// Feeds the admin analytics total_feedback and the guest
// /feedback-summary response.
const summary = await feedbackService.getEventFeedbackSummary(eventId);
expect(Number(summary.stats.total_color_labels)).toBe(0);
await tag('device-C', 'blue');
expect(Number((await feedbackService.getEventFeedbackSummary(eventId)).stats.total_color_labels)).toBe(1);
});
it('does not count the shared tag once the event is back on per-guest labels', async () => {
await tag('device-A', 'green');
await setMode('simple');
await feedbackService.updatePhotoFeedbackStats(photoId);
expect((await db('photos').where('id', photoId).first()).color_label_count).toBe(0);
expect(await feedbackService.getPhotoColorLabelCounts(photoId)).toEqual({});
});
});
});
describe('the mode is scoped to the colour tag', () => {
it('keeps likes per-guest in shared mode', async () => {
const like = (ua) => request(app)
.post(`/api/gallery/${SLUG}/photos/${photoId}/feedback`)
.set(asGuest(ua))
.send({ feedback_type: 'like' });
expect((await like('device-A')).status).toBe(200);
expect((await photoFor('device-A')).is_liked).toBe(true);
// B has not liked it. If 'shared' leaked past colour labels, this would
// come back true and B's click would un-like A's like.
expect((await photoFor('device-B')).is_liked).toBe(false);
await like('device-B');
const photo = await db('photos').where('id', photoId).first();
expect(photo.like_count).toBe(2);
});
});
describe('the reserved identity cannot be claimed', () => {
it('refuses a per-guest write that arrives under it', async () => {
// Not reachable through the routes — a guest identifier is either a
// sha256 hex or a server-minted UUID — but a future caller must not be
// able to write the photo's shared tag as if it were their own.
await expect(feedbackService.submitFeedback(
photoId, eventId,
{ feedback_type: 'color_label', color_label: 'green' },
SHARED_COLOR_LABEL_IDENTITY,
)).rejects.toThrow('Reserved guest identifier');
});
});
});
@@ -0,0 +1,150 @@
/**
* Slideshow photo source (#1015).
*
* The bug: with `lightbox_preview_enabled` off (the default), /photos emitted
* `preview_url: null`, so the slideshow's `preview_url || hero_url || url`
* chain fell through to `hero_url` — a 1920x1080 `fit: 'cover'` centre crop
* meant for gallery header banners. With the "Black Bars (No crop)" fit the
* show then letterboxed an already-cropped frame: portrait photos lost their
* top and bottom and the setting looked broken.
*
* The contract pinned here: `slideshow_url` points at the aspect-preserved
* preview tier and is emitted for image photos REGARDLESS of the lightbox
* toggle, so the slideshow never has a reason to reach for `hero_url`.
* `preview_url` itself must stay gated — the lightbox opt-in is unchanged.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-src-test-secret';
const SLUG = 'slideshow-source-event';
describe('Slideshow photo source (#1015)', () => {
let db;
let cleanup;
let app;
let eventId;
let imagePhotoId;
let videoPhotoId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const setLightboxPreview = async (on) => {
await db('app_settings').where({ setting_key: 'lightbox_preview_enabled' }).del();
await db('app_settings').insert({
setting_key: 'lightbox_preview_enabled',
setting_value: JSON.stringify(on),
setting_type: 'general',
updated_at: new Date().toISOString(),
});
};
const fetchPhotos = async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`)
.expect(200);
return res.body.photos;
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Slideshow Source Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'slideshow-source-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const img = await db('photos').insert({
event_id: eventId,
filename: 'portrait.jpg',
path: 'events/slideshow-source/portrait.jpg',
type: 'individual',
mime_type: 'image/jpeg',
uploaded_at: new Date().toISOString(),
}).returning('id');
imagePhotoId = img[0]?.id ?? img[0];
const vid = await db('photos').insert({
event_id: eventId,
filename: 'clip.mp4',
path: 'events/slideshow-source/clip.mp4',
type: 'individual',
media_type: 'video',
mime_type: 'video/mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
videoPhotoId = vid[0]?.id ?? vid[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('emits slideshow_url for image photos even when lightbox previews are OFF', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
expect(image.slideshow_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
// The regression: this is what used to be null, pushing the show to hero.
expect(image.preview_url).toBeNull();
});
it('leaves preview_url gated so the lightbox opt-in is unchanged', async () => {
await setLightboxPreview(true);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
expect(image.preview_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
expect(image.slideshow_url).toBe(image.preview_url);
});
it('never points the slideshow at the cover-cropped hero tier', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
// hero_url still ships (the gallery header uses it) — it just must not be
// what the slideshow resolves to.
expect(image.hero_url).toBe(`/api/gallery/${SLUG}/hero/${imagePhotoId}`);
expect(image.slideshow_url).not.toBe(image.hero_url);
});
it('emits slideshow_url: null for videos, which have no preview tier', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const video = photos.find((p) => p.id === videoPhotoId);
expect(video.slideshow_url).toBeNull();
});
});
@@ -0,0 +1,136 @@
/**
* SQLite epoch-timestamp normalization (#485 follow-up).
*
* On SQLite, timestamp columns written with a raw `new Date()` through knex
* hold epoch-millisecond numbers. Postgres returns ISO strings, so frontend
* code written against Postgres calls parseISO() and crashes on native
* (SQLite) installs — the exact class fixed for admin Users in #485, which
* listed api tokens / photos / activity as an out-of-scope follow-up.
*
* Pins:
* - gallery /photos serializes uploaded_at / captured_at as ISO strings
* even when the row holds an epoch number (pre-fix archive restores)
* - the api-tokens list serializes created_at / expires_at / last_used_at /
* revoked_at as ISO strings for epoch-stored rows
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'epoch-test-secret';
const SLUG = 'epoch-test-event';
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
describe('SQLite epoch timestamp normalization', () => {
let db;
let cleanup;
let app;
let eventId;
let adminToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Epoch Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'epoch-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
// The pre-fix corruption shape: epoch numbers in timestamp columns.
await db('photos').insert({
event_id: eventId,
filename: 'restored.jpg',
path: 'events/epoch/restored.jpg',
type: 'individual',
uploaded_at: Date.now() - 3600_000,
captured_at: Date.now() - 7200_000,
});
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'epoch-admin',
email: 'epoch-admin@example.com',
password_hash: await bcrypt.hash('EpochAdmin123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'epoch-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
await db('api_tokens').insert({
name: 'epoch-token',
hashed_token: 'x'.repeat(64),
preview: 'pk_test…abcd',
scopes: JSON.stringify(['events:read']),
created_by: rootId,
created_at: Date.now() - 86400_000,
last_used_at: Date.now() - 3600_000,
revoked_at: Date.now() - 60_000,
});
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/admin/api-tokens', require('../../src/routes/adminApiTokens'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('gallery /photos serializes epoch-stored uploaded_at/captured_at as ISO strings', async () => {
const galleryToken = jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken}`);
expect(res.status).toBe(200);
expect(res.body.photos).toHaveLength(1);
const photo = res.body.photos[0];
expect(typeof photo.uploaded_at).toBe('string');
expect(photo.uploaded_at).toMatch(ISO_RE);
expect(photo.captured_at).toMatch(ISO_RE);
});
it('api-tokens list serializes epoch-stored timestamps as ISO strings', async () => {
const res = await request(app)
.get('/api/admin/api-tokens')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
const token = res.body.find((t) => t.name === 'epoch-token');
expect(token).toBeTruthy();
for (const field of ['created_at', 'last_used_at', 'revoked_at']) {
expect(`${field}:${typeof token[field]}`).toBe(`${field}:string`);
expect(token[field]).toMatch(ISO_RE);
}
});
});
@@ -0,0 +1,107 @@
/**
* The admin photo list's category filter, and the value it answers to (#1211).
*
* The frontend used to send `category_id=0` for "Uncategorized". This route
* skips `'0'` outright — the guard reads `category_id !== '0'` — so no
* condition was applied and the whole event came back. Four lines below that
* guard sits the branch that does the work, keyed on the literal
* `uncategorized`, which nothing was sending.
*
* Reported in #1209 by someone trying to isolate a few thousand uncategorised
* imports. The frontend half is fixed in PhotoFilters; this pins the backend
* half of the same contract, because the failure mode was the two ends
* disagreeing about a string and neither one being wrong on its own.
*/
const request = require('supertest');
const express = require('express');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
describe('admin photo list — uncategorized filter (#1211)', () => {
let db; let cleanup; let app;
let eventId; let categoryId;
let uncategorisedIds; let categorisedId;
const list = async (query = '') => {
const res = await request(app).get(`/api/admin/events/${eventId}/photos${query}`);
expect(res.status).toBe(200);
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
};
beforeAll(async () => {
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const [ev] = await db('events').insert({
slug: 'uncat-filter', event_type: 'wedding', event_name: 'Uncat Filter',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/uncat-filter/share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [cat] = await db('photo_categories')
.insert({ name: 'Ceremony', slug: 'ceremony', event_id: eventId })
.returning('id');
categoryId = typeof cat === 'object' ? cat.id : cat;
const insertPhoto = async (filename, category) => {
const [p] = await db('photos').insert({
event_id: eventId, filename, path: `events/uncat/${filename}`,
type: 'individual', category_id: category,
uploaded_at: new Date().toISOString(),
}).returning('id');
return typeof p === 'object' ? p.id : p;
};
// Two with no category — the shape a plugin upload leaves behind — and one
// filed properly, so a filter that does nothing is visibly different from
// a filter that works.
uncategorisedIds = [await insertPhoto('a.jpg', null), await insertPhoto('b.jpg', null)];
categorisedId = await insertPhoto('c.jpg', categoryId);
uncategorisedIds.sort((a, b) => a - b);
app = express();
app.use(express.json());
app.use('/api/admin/events', require('../../src/routes/adminPhotos'));
}, 180000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('returns only the photos with no category', async () => {
expect(await list('?category_id=uncategorized')).toEqual(uncategorisedIds);
});
it('returns everything when no category filter is given', async () => {
expect(await list()).toEqual([...uncategorisedIds, categorisedId].sort((a, b) => a - b));
});
it('still filters by a real category id', async () => {
expect(await list(`?category_id=${categoryId}`)).toEqual([categorisedId]);
});
it('treats 0 as no filter at all', async () => {
// Pinning the behaviour that made the bug silent rather than loud: '0' is
// not "uncategorized" and never was, it simply falls through the guard. A
// future change that made 0 mean uncategorized here would be fine too —
// but it must be a decision, not an accident, and this test forces it.
expect(await list('?category_id=0')).toEqual([...uncategorisedIds, categorisedId].sort((a, b) => a - b));
});
});
@@ -10,7 +10,7 @@ const { bootCrmDb } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
jest.setTimeout(120000);
let db;
let cleanup;
@@ -239,7 +239,7 @@ describe('workflow engine', () => {
expect(again.already).toBe(true);
});
test('seeds the invoice-dunning built-in as the delegation graph (v6, disabled for first beta)', async () => {
test('seeds the invoice-dunning built-in as the delegation graph (disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
@@ -248,7 +248,7 @@ describe('workflow engine', () => {
expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true);
expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(6);
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(7);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
@@ -273,7 +273,7 @@ describe('workflow engine', () => {
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const reseeded = await db('workflows').where({ id: wf.id }).first();
expect(reseeded.version).toBe(wf.version + 1); // bumped
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(6);
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(7);
expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled)
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
@@ -9,7 +9,7 @@ const {
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
jest.setTimeout(120000);
let db;
let cleanup;
@@ -0,0 +1,112 @@
/**
* The roles-join fallback in adminAuth fabricates `role_name = 'super_admin'`
* to keep existing sessions working across the RBAC upgrade window. The catch
* around it used to be unconditional, so ANY transient database failure —
* connection reset, deadlock, statement timeout, pool exhaustion — took the
* same branch and handed the caller super_admin for the duration of the fault.
*
* `roleName` is the sole discriminator for every ownership check (ownership.js,
* adminProjects, adminUsers, adminApiTokens, projectService, ...), so that
* inverted the whole authorization model rather than failing the request.
* Issue #968. Same treatment apiTokenAuth already got for the v1 surface.
*/
const jwt = require('jsonwebtoken');
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn().mockResolvedValue(false) }));
jest.mock('../../src/utils/logger', () => ({ warn: jest.fn(), error: jest.fn(), debug: jest.fn(), info: jest.fn() }));
// The joined query throws whatever the test stages; the role-less fallback
// query (no .leftJoin) always succeeds, which is what made the original bug
// reachable — it is the cheaper single-table read.
// `mock`-prefixed so jest's module-factory hoisting allows the reference.
let mockJoinError = null;
const mockAdminRow = { id: 7, username: 'scoped', email: 's@example.com', password_changed_at: null };
jest.mock('../../src/database/db', () => ({
db: () => ({
_joined: false,
leftJoin() { this._joined = true; return this; },
where() { return this; },
select() { return this; },
first() {
if (this._joined && mockJoinError) return Promise.reject(mockJoinError);
return Promise.resolve({ ...mockAdminRow });
},
}),
}));
const { adminAuth } = require('../../src/middleware/auth');
const SECRET = 'test-secret-for-admin-auth-fallback';
function makeReq() {
const token = jwt.sign(
{ id: mockAdminRow.id, type: 'admin' },
SECRET,
{ algorithm: 'HS256', issuer: 'picpeak-auth' },
);
return { headers: { authorization: `Bearer ${token}` }, ip: '127.0.0.1', connection: {} };
}
function makeRes() {
return {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
}
describe('adminAuth roles-join fallback (#968)', () => {
const OLD_SECRET = process.env.JWT_SECRET;
beforeAll(() => { process.env.JWT_SECRET = SECRET; });
afterAll(() => { process.env.JWT_SECRET = OLD_SECRET; });
beforeEach(() => { mockJoinError = null; });
it('grants the upgrade-window fallback only for a genuinely missing roles table', async () => {
mockJoinError = new Error('SQLITE_ERROR: no such table: roles');
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.admin.roleName).toBe('super_admin');
});
it.each([
['connection reset', new Error('Connection terminated unexpectedly')],
['deadlock', new Error('deadlock detected')],
['pool exhaustion', new Error('Knex: Timeout acquiring a connection')],
['statement timeout', new Error('canceling statement due to statement timeout')],
])('does NOT fabricate super_admin on a transient failure (%s)', async (_label, err) => {
mockJoinError = err;
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
// Fails closed: request rejected, req.admin never populated. The specific
// status is 401 (adminAuth's blanket outer catch) — what matters is that
// the caller is not elevated and does not reach the route.
expect(next).not.toHaveBeenCalled();
expect(req.admin).toBeUndefined();
expect(res.statusCode).toBe(401);
});
it('does NOT fabricate super_admin when an unrelated table is missing', async () => {
mockJoinError = new Error('SQLITE_ERROR: no such table: admin_sessions');
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(req.admin).toBeUndefined();
});
});
@@ -0,0 +1,72 @@
/**
* The roles-join fallback in apiTokenAuth grants `super_admin` (upgrade-path
* parity with adminAuth). It must therefore fire ONLY when the roles schema is
* genuinely absent — a catch-all turns any transient database failure into a
* privilege escalation that reopens GHSA-9697 for a demoted token owner.
*/
const { isMissingRolesSchema } = require('../../src/middleware/apiTokenAuth');
describe('apiTokenAuth roles-schema fallback predicate (GHSA-9697)', () => {
it('accepts a genuinely missing roles table on both engines', () => {
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: roles'))).toBe(true);
expect(isMissingRolesSchema(
Object.assign(new Error('relation "roles" does not exist'), { code: '42P01' }),
)).toBe(true);
expect(isMissingRolesSchema(
Object.assign(new Error('column roles.name does not exist'), { code: '42703' }),
)).toBe(true);
});
it('rejects transient failures that must not elevate the caller', () => {
expect(isMissingRolesSchema(new Error('Connection terminated unexpectedly'))).toBe(false);
expect(isMissingRolesSchema(new Error('deadlock detected'))).toBe(false);
expect(isMissingRolesSchema(new Error('Knex: Timeout acquiring a connection'))).toBe(false);
expect(isMissingRolesSchema(new Error('canceling statement due to statement timeout'))).toBe(false);
expect(isMissingRolesSchema(undefined)).toBe(false);
});
it('rejects a missing-table error for an unrelated table', () => {
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: api_tokens'))).toBe(false);
});
// knex prefixes the failing SQL to err.message, and that SQL always names
// `roles` on this join — so the message substring proves nothing about the
// error, and only an exact driver phrase (or a SQLSTATE) may be trusted.
// These are real knex message shapes, captured from the actual query.
describe('with knex\'s SQL prefix on the message (#968)', () => {
const withSql = (driverMessage) => new Error(
'select `roles`.`name` as `role_name` from `admin_users` '
+ 'left join `roles` on `roles`.`id` = `admin_users`.`role_id` '
+ `where \`admin_users\`.\`id\` = 1 limit 1 - ${driverMessage}`,
);
it('accepts both legitimate upgrade-window states', () => {
// pre-054: the roles table does not exist yet
expect(isMissingRolesSchema(
Object.assign(withSql('SQLITE_ERROR: no such table: roles'), { code: 'SQLITE_ERROR' }),
)).toBe(true);
// post-054, pre-057: roles exists, admin_users.role_id not added yet
expect(isMissingRolesSchema(
Object.assign(withSql('SQLITE_ERROR: no such column: admin_users.role_id'), { code: 'SQLITE_ERROR' }),
)).toBe(true);
});
it('rejects an unrelated "does not exist" fault despite the SQL naming roles', () => {
// pgbouncer transaction pooling loses a named prepared statement
// (SQLSTATE 26000). Transient — the fallback query would succeed on a
// fresh connection, so accepting this would fabricate super_admin.
expect(isMissingRolesSchema(
Object.assign(withSql('prepared statement "S_1" does not exist'), { code: '26000' }),
)).toBe(false);
// The DB role/user, not the roles table.
expect(isMissingRolesSchema(
Object.assign(withSql('role "picpeak" does not exist'), { code: '28000' }),
)).toBe(false);
expect(isMissingRolesSchema(
Object.assign(withSql('database "picpeak" does not exist'), { code: '3D000' }),
)).toBe(false);
expect(isMissingRolesSchema(withSql('Connection terminated unexpectedly'))).toBe(false);
});
});
});
@@ -0,0 +1,67 @@
/**
* #868 — the admin gallery-preview gate. isAdminPreview must fail CLOSED: it
* grants the draft/password bypass only for an explicit `?admin_preview=1` flag
* AND a verified admin JWT (type 'admin', issuer 'picpeak-auth') read from the
* httpOnly admin_token cookie or a Bearer header — never from the URL, never for
* a guest/gallery token.
*/
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-preview-test-secret';
const jwt = require('jsonwebtoken');
const { isAdminPreview } = require('../../src/middleware/gallery');
// Read the secret at call time — a jest setup file can set JWT_SECRET after this
// module loads, and isAdminPreview verifies against the live value.
const adminToken = () => jwt.sign({ type: 'admin', id: 1 }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
const galleryToken = () => jwt.sign({ type: 'gallery', eventId: 1 }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
function req({ flag, cookie, bearer } = {}) {
return {
query: flag === undefined ? {} : { admin_preview: flag },
cookies: cookie ? { admin_token: cookie } : {},
headers: bearer ? { authorization: `Bearer ${bearer}` } : {},
};
}
describe('isAdminPreview (#868) fails closed', () => {
it('false without the explicit flag, even with a valid admin cookie (plain link stays guest-identical)', () => {
expect(isAdminPreview(req({ cookie: adminToken() }))).toBe(false);
});
it('false with the flag but no session token', () => {
expect(isAdminPreview(req({ flag: '1' }))).toBe(false);
});
it('true with the flag + a valid admin cookie', () => {
expect(isAdminPreview(req({ flag: '1', cookie: adminToken() }))).toBe(true);
});
it('true with the flag + a valid admin Bearer header', () => {
expect(isAdminPreview(req({ flag: '1', bearer: adminToken() }))).toBe(true);
});
it('false for a gallery (guest) token — must be type admin', () => {
expect(isAdminPreview(req({ flag: '1', cookie: galleryToken() }))).toBe(false);
});
it('true from the admin cookie even when a gallery Bearer is also present (#981 coexisting session)', () => {
expect(isAdminPreview(req({ flag: '1', cookie: adminToken(), bearer: galleryToken() }))).toBe(true);
});
it('false when only a gallery Bearer is present — a gallery header can never satisfy it (#981)', () => {
expect(isAdminPreview(req({ flag: '1', bearer: galleryToken() }))).toBe(false);
});
it('false on a tampered token', () => {
expect(isAdminPreview(req({ flag: '1', cookie: `${adminToken()}x` }))).toBe(false);
});
it('false on the wrong issuer', () => {
const t = jwt.sign({ type: 'admin' }, process.env.JWT_SECRET, { issuer: 'not-picpeak' });
expect(isAdminPreview(req({ flag: '1', cookie: t }))).toBe(false);
});
it('false when the flag is anything other than exactly "1"', () => {
expect(isAdminPreview(req({ flag: 'true', cookie: adminToken() }))).toBe(false);
expect(isAdminPreview(req({ flag: '0', cookie: adminToken() }))).toBe(false);
});
});
@@ -0,0 +1,123 @@
/**
* Regression test for the maintenance-mode lockout in single-container mode.
*
* In the compose stack nginx serves the frontend, so a request for /admin/login
* or /gallery/<slug> never reaches Express. The all-in-one image (#1042) has no
* nginx: server.js serves the SPA itself, and maintenanceMiddleware is mounted
* far ahead of that static block. Gating those paths therefore answered the
* HTML document with 503 JSON, which broke two things at once —
*
* 1. an admin who enabled maintenance mode could never disable it, because
* /admin/login and its /assets/ bundle would not load (the login *API* was
* already exempt, but nothing could call it), and
* 2. a guest saw raw JSON instead of the branded maintenance screen the
* frontend already ships.
*
* The shell is inert HTML: it boots, calls /api/public/settings (exempt) and
* renders MaintenanceMode itself, so letting it through costs nothing.
*
* The dividing line is taken from frontend/nginx.conf rather than invented:
* paths nginx answers from the frontend container are exempt, paths it
* proxy_passes to the backend stay gated. That makes the all-in-one image
* behave exactly like compose in both directions. The gated half is where the
* risk lives — a negative "everything that is not an API is a shell" rule
* looks right and quietly un-gates /og/ (event names, cover images) and the
* public CMS at the site root — so most of the cases below assert it.
*/
const { maintenanceMiddleware } = require('../../src/middleware/maintenance');
jest.mock('../../src/database/db', () => {
const settings = { setting_key: 'general_maintenance_mode', setting_value: 'true' };
const db = jest.fn(() => ({
where: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue(settings),
}));
return { db };
});
jest.mock('../../src/utils/logger', () => ({
error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(),
}));
// Maintenance state is cached for a minute; each case starts from a clean read.
const { clearMaintenanceCache } = require('../../src/middleware/maintenance');
async function run(path, { method = 'GET', authorization } = {}) {
clearMaintenanceCache();
const req = { path, method, headers: authorization ? { authorization } : {} };
const res = {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
const next = jest.fn();
await maintenanceMiddleware(req, res, next);
return { passed: next.mock.calls.length === 1, status: res.statusCode, body: res.body };
}
describe('maintenanceMiddleware — SPA shell vs API split', () => {
describe('passes the frontend shell through so the branded screen can render', () => {
it.each([
['/admin', 'admin shell entry'],
['/admin/login', 'the page that calls the exempt login API'],
['/assets/index-abc123.js', 'hashed bundle the shell loads'],
['/gallery/some-event', 'guest gallery route'],
['/customer/portal', 'customer portal route'],
])('%s (%s)', async (path) => {
const { passed } = await run(path);
expect(passed).toBe(true);
});
});
describe('still gates everything that is not a shell', () => {
it.each([
['/api/gallery/some-event/verify', 'public gallery API'],
['/api/photos/1', 'photo API'],
['/photos/anything.jpg', 'backend-owned photo mount'],
['/thumbnails/anything.jpg', 'backend-owned thumbnail mount'],
['/fonts/anything.woff2', 'backend-owned font mount'],
// nginx proxy_passes these to the backend, so compose gates them today
// and the all-in-one image must not be the one deployment that does not.
['/', 'site root — nginx `location = /` hands this to the public CMS'],
['/og/gallery/some-event', 'OG renderer: leaks the event name'],
['/og/gallery/some-event/cover', 'OG cover: leaks the hero thumbnail'],
['/s/abc123', 'short-link renderer'],
['/robots.txt', 'proxied one-to-one by nginx'],
['/favicon.ico', 'proxied one-to-one by nginx'],
])('%s (%s) returns 503', async (path) => {
const { passed, status, body } = await run(path);
expect(passed).toBe(false);
expect(status).toBe(503);
expect(body).toMatchObject({ maintenance: true });
});
it('does not let a non-GET request masquerade as a shell load', async () => {
const { passed, status } = await run('/api/gallery/some-event/verify', { method: 'POST' });
expect(passed).toBe(false);
expect(status).toBe(503);
});
});
describe('keeps the pre-existing admin exemptions', () => {
it('admin login API stays reachable', async () => {
expect((await run('/api/auth/admin/login', { method: 'POST' })).passed).toBe(true);
});
it('/api/public/settings stays reachable so the shell can read the flag', async () => {
expect((await run('/api/public/settings')).passed).toBe(true);
});
it('an authenticated admin still reaches /api/admin', async () => {
const { passed } = await run('/api/admin/events', { authorization: 'Bearer token' });
expect(passed).toBe(true);
});
it('an unauthenticated /api/admin request is not served by this middleware', async () => {
// isAdminRoute suppresses the 503 so the auth layer can answer 401.
const { passed, status } = await run('/api/admin/events');
expect(passed).toBe(true);
expect(status).toBeNull();
});
});
});
@@ -0,0 +1,78 @@
/**
* Regression test for the bulk archive/delete ownership bypass.
*
* bulk-archive and bulk-delete acted on body-supplied event ids with no
* ownership filter, so an admin/editor scoped to their own events (the
* single-event routes enforce requireEventOwnership) could archive or
* cascade-delete ANY event by id. filterOwnedEventIds is the helper those
* routes now use to drop foreign/non-existent ids.
*/
// events owned by admin 7; event 3 owned by someone else; event 4 is
// ownerless (legacy). The mock models:
// whereIn('id', ids).andWhere(created_by IS NULL OR created_by = admin.id)
const EVENTS = [
{ id: 1, created_by: 7 },
{ id: 2, created_by: 7 },
{ id: 3, created_by: 99 }, // foreign
{ id: 4, created_by: null }, // ownerless/legacy
];
jest.mock('../../src/database/db', () => ({
db: () => {
const q = {
_ids: null,
_adminId: null,
whereIn(_col, ids) { this._ids = ids; return this; },
andWhere(cb) {
// Emulate the (created_by IS NULL OR created_by = admin.id) builder
// by capturing the admin id the callback closes over via a probe.
const probe = {
_adminId: null,
whereNull() { return this; },
orWhere(_col, id) { this._adminId = id; return this; },
};
cb(probe);
this._adminId = probe._adminId;
return this;
},
select() {
return Promise.resolve(
EVENTS
.filter((e) => this._ids.includes(e.id))
.filter((e) => e.created_by === null || e.created_by === this._adminId)
.map((e) => ({ id: e.id }))
);
},
};
return q;
},
}));
const { filterOwnedEventIds } = require('../../src/middleware/ownership');
describe('filterOwnedEventIds', () => {
it('super_admin gets every id, nothing denied', async () => {
const { allowed, denied } = await filterOwnedEventIds(
{ id: 7, roleName: 'super_admin' }, [1, 3, 4, 999]
);
expect(allowed).toEqual([1, 3, 4, 999]);
expect(denied).toEqual([]);
});
it('non-super_admin keeps owned + ownerless, denies foreign and non-existent', async () => {
const { allowed, denied } = await filterOwnedEventIds(
{ id: 7, roleName: 'admin' }, [1, 2, 3, 4, 999]
);
expect(allowed.sort()).toEqual([1, 2, 4]); // owns 1,2; 4 is ownerless
expect(denied.sort()).toEqual([3, 999]); // 3 foreign, 999 missing
});
it('foreign-only request yields empty allowed', async () => {
const { allowed, denied } = await filterOwnedEventIds(
{ id: 7, roleName: 'editor' }, [3]
);
expect(allowed).toEqual([]);
expect(denied).toEqual([3]);
});
});
@@ -0,0 +1,103 @@
/**
* Regression test for the cross-event thumbnail enumeration leak.
*
* Thumbnails are served flat from /thumbnails/thumb_<name> with
* deterministic, enumerable filenames. photoAuth previously granted any
* holder of a gallery token for ANY active event access to ANY thumbnail
* (it set eventSlug=null and returned next() as long as the token's event
* existed), so a visitor to one gallery could pull another (password-
* protected) gallery's entire thumbnail set. The fix scopes thumbnail
* access to the token's event by matching the requested file against
* photos.thumbnail_path for that event_id.
*/
process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000';
const jwt = require('jsonwebtoken');
// Two events, each owning one thumbnail. The photos mock resolves a row
// only when BOTH event_id and thumbnail_path match — i.e. it models the
// real ownership query.
const EVENTS = [
{ id: 10, slug: 'event-a', is_active: 1 },
{ id: 20, slug: 'event-b', is_active: 1 },
];
const PHOTOS = [
{ id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' },
{ id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' },
];
jest.mock('../../src/database/db', () => ({
db: (table) => ({
_cond: null,
where(cond) { this._cond = cond; return this; },
first() {
if (table === 'events') {
return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null);
}
if (table === 'photos') {
return Promise.resolve(
PHOTOS.find((p) => p.event_id === this._cond.event_id
&& p.thumbnail_path === this._cond.thumbnail_path) || null
);
}
return Promise.resolve(null);
},
}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
const photoAuth = require('../../src/middleware/photoAuth');
function galleryToken(eventId) {
return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
}
function makeReqRes(token, thumbPath) {
const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} };
const res = {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
return { req, res };
}
describe('photoAuth — thumbnail ownership scoping', () => {
it('denies a gallery token for event A fetching event B\'s thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg');
const next = jest.fn();
await photoAuth(req, res, next);
// Access denied: middleware must not pass the request through.
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(req.event).toBeUndefined();
});
it('allows a gallery token to fetch its own event\'s thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg');
const next = jest.fn();
await photoAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.event).toMatchObject({ id: 20 });
});
it('denies a traversal / foreign filename that matches no owned thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd');
const next = jest.fn();
await photoAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(req.event).toBeUndefined();
});
});
@@ -0,0 +1,35 @@
/**
* Migration 167 (projects.created_by) — idempotent on re-run, reversible,
* and backfills the owner from a project's single linked event (GHSA-wrg5).
*/
const path=require('path'), fs=require('fs'), os=require('os');
process.env.NODE_ENV='test';
process.env.TEST_DATABASE_PATH=path.join(fs.mkdtempSync(path.join(os.tmpdir(),'picpeak-mig167-')),'db.sqlite');
process.env.JWT_SECRET='mig';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const mig = require('../../migrations/core/167_add_projects_created_by');
describe('migration 167', () => {
let db, cleanup;
beforeAll(async()=>{ ({db,cleanup}=await bootCrmDb()); await seedMinimal(db); },120000);
afterAll(async()=>{ if(cleanup) await cleanup(); });
it('is idempotent on re-run and reversible', async () => {
await mig.up(db); // already applied by boot; must no-op
await mig.up(db); // and again
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
await mig.down(db);
expect(await db.schema.hasColumn('projects','created_by')).toBe(false);
await mig.up(db); // re-apply cleanly
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
});
it('backfills created_by from a single linked event owner', async () => {
const p = await db('projects').insert({name:'bf',status:'active',created_at:new Date(),updated_at:new Date()}).returning('id');
const pid = p[0]?.id ?? p[0];
await db('events').insert({slug:'bf-ev',event_type:'wedding',event_name:'bf',event_date:'2026-08-01',
host_email:'h@e.com',admin_email:'a@e.com',password_hash:'x',share_token:'t1',share_link:'/g/bf-ev/t1',
created_by: 4242, project_id: pid, expires_at:new Date(Date.now()+864e5).toISOString(),
is_active:1,is_archived:0,is_draft:0,created_at:new Date().toISOString()});
await mig.up(db);
const row = await db('projects').where({id:pid}).first();
expect(row.created_by).toBe(4242);
});
});
@@ -0,0 +1,83 @@
/**
* GHSA-jhcf round 3: scoping the activity feed does nothing about the rows
* already on disk. expenseService used to pass adminId into logActivity's
* `eventId` slot, so upgraded instances carry accounting rows whose event_id
* is an ADMIN id — and the scope predicate happily matches those against a
* same-numbered event the caller owns.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-mig168-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mig168-test-secret';
const { bootCrmDb } = require('../integration/helpers/crmDb');
const migration = require('../../migrations/core/168_fix_expense_activity_event_id');
describe('migration 168 — legacy accounting activity rows (GHSA-jhcf)', () => {
let db; let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('re-attributes the admin id and clears event_id, leaving real rows alone', async () => {
await db('activity_logs').insert([
// Legacy shape: event_id is really admin #7, no actor recorded.
{
activity_type: 'expense_created',
actor_type: 'system',
actor_id: null,
event_id: 7,
metadata: JSON.stringify({ expenseId: 1 }),
created_at: new Date().toISOString(),
},
{
activity_type: 'incoming_invoice_captured',
actor_type: 'system',
actor_id: null,
event_id: 9,
metadata: JSON.stringify({ inboundDocumentId: 2 }),
created_at: new Date().toISOString(),
},
// A genuine event-scoped row from another subsystem must survive intact.
{
activity_type: 'photo_uploaded',
actor_type: 'admin',
actor_id: 3,
event_id: 7,
metadata: JSON.stringify({}),
created_at: new Date().toISOString(),
},
]);
await migration.up(db);
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
expect(expense.event_id == null).toBe(true);
expect(Number(expense.actor_id)).toBe(7);
expect(expense.actor_type).toBe('admin');
const captured = await db('activity_logs').where({ activity_type: 'incoming_invoice_captured' }).first();
expect(captured.event_id == null).toBe(true);
expect(Number(captured.actor_id)).toBe(9);
const photo = await db('activity_logs').where({ activity_type: 'photo_uploaded' }).first();
expect(Number(photo.event_id)).toBe(7);
expect(Number(photo.actor_id)).toBe(3);
});
it('is idempotent on re-run', async () => {
await expect(migration.up(db)).resolves.toBeUndefined();
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
expect(Number(expense.actor_id)).toBe(7);
expect(expense.event_id == null).toBe(true);
});
});
@@ -0,0 +1,166 @@
/**
* Migration 177 (#1074) — face recognition schema.
*
* The acceptance criteria for #1074 name three properties explicitly, so
* they get tests rather than a manual check:
*
* - idempotent on re-run,
* - a working down(),
* - and — the one that matters most — installing it must NOT enqueue
* anything. A `face_status` column defaulting to 'pending' would put
* every existing photo on every install into a queue the operator never
* asked for, on installs with no sidecar at all.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-mig177-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mig177-test-secret';
const { bootCrmDb } = require('../integration/helpers/crmDb');
const migration = require('../../migrations/core/177_add_face_recognition');
describe('migration 177 — face recognition schema', () => {
let db; let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('creates both tables with the columns the pipeline writes', async () => {
expect(await db.schema.hasTable('photo_faces')).toBe(true);
expect(await db.schema.hasTable('event_people')).toBe(true);
for (const col of [
'photo_id', 'event_id', 'bbox_x', 'bbox_y', 'bbox_w', 'bbox_h',
'det_score', 'yaw', 'pitch', 'blur', 'embedding', 'model_version',
'person_id', 'created_at',
]) {
expect(await db.schema.hasColumn('photo_faces', col)).toBe(true);
}
for (const col of [
'event_id', 'label', 'cover_face_id', 'centroid', 'face_count_total',
'model_version', 'is_hidden', 'is_ignored',
]) {
expect(await db.schema.hasColumn('event_people', col)).toBe(true);
}
});
it('adds the photos and events columns', async () => {
for (const col of ['face_status', 'face_count', 'face_started_at', 'face_error']) {
expect(await db.schema.hasColumn('photos', col)).toBe(true);
}
for (const col of [
'face_recognition_enabled', 'faces_visible_to_guests', 'faces_last_scan_at',
]) {
expect(await db.schema.hasColumn('events', col)).toBe(true);
}
});
it('enqueues nothing — face_status has no default', async () => {
// The whole "zero behaviour change by default" guarantee rests on this.
const [{ id: eventId }] = await db('events').insert({
slug: 'mig177-event',
event_type: 'wedding',
event_name: 'Migration 177',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: 'mig177-share',
expires_at: new Date().toISOString(),
}).returning('id');
const eid = typeof eventId === 'object' ? eventId.id : eventId;
await db('photos').insert({
event_id: eid, filename: 'a.jpg', path: '/tmp/a.jpg', type: 'individual',
});
const row = await db('photos').where({ event_id: eid }).first();
expect(row.face_status).toBeNull();
expect(await db('photo_faces').count({ c: '*' }).first()).toMatchObject({ c: 0 });
});
it('seeds the tunable thresholds rather than hardcoding them', async () => {
// Immich's clustering guide exists because no single threshold survives
// contact with every library — these must be operator-reachable.
const keys = [
'face_match_threshold', 'face_min_cluster_size',
'face_quality_min_score', 'face_quality_min_px',
];
const rows = await db('app_settings').whereIn('setting_key', keys);
expect(rows).toHaveLength(keys.length);
expect(rows.every((r) => r.setting_type === 'faces')).toBe(true);
});
it('is idempotent on re-run', async () => {
await expect(migration.up(db)).resolves.not.toThrow();
// And did not duplicate the settings rows.
const rows = await db('app_settings').where('setting_key', 'face_match_threshold');
expect(rows).toHaveLength(1);
});
it('down() removes everything it added, and up() restores it', async () => {
await migration.down(db);
expect(await db.schema.hasTable('photo_faces')).toBe(false);
expect(await db.schema.hasTable('event_people')).toBe(false);
expect(await db.schema.hasColumn('photos', 'face_status')).toBe(false);
expect(await db.schema.hasColumn('events', 'face_recognition_enabled')).toBe(false);
expect(await db('app_settings').where('setting_key', 'face_match_threshold')).toHaveLength(0);
await migration.up(db);
expect(await db.schema.hasTable('photo_faces')).toBe(true);
expect(await db.schema.hasColumn('photos', 'face_status')).toBe(true);
});
it('cascades face rows when a photo is deleted', async () => {
// #1074 acceptance criterion: deleting a photo removes its face rows.
//
// SQLite ignores foreign keys unless the pragma is on, and PicPeak does
// NOT enable it globally (a large amount of existing data and fixtures
// would start failing). So the cascade below proves only that the schema
// declares it correctly — the code does not RELY on it. Deletion paths
// purge face rows explicitly; see faceProcessor.purgeEvent /
// purgePhotoFaces and the erasure tests in facePrivacy.test.js.
await db.raw('PRAGMA foreign_keys = ON');
const [{ id: eventId }] = await db('events').insert({
slug: 'mig177-cascade',
event_type: 'wedding',
event_name: 'Cascade',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: 'mig177-cascade-share',
expires_at: new Date().toISOString(),
}).returning('id');
const eid = typeof eventId === 'object' ? eventId.id : eventId;
const [{ id: photoId }] = await db('photos')
.insert({ event_id: eid, filename: 'c.jpg', path: '/tmp/c.jpg', type: 'individual' })
.returning('id');
const pid = typeof photoId === 'object' ? photoId.id : photoId;
await db('photo_faces').insert({
photo_id: pid,
event_id: eid,
bbox_x: 1, bbox_y: 2, bbox_w: 3, bbox_h: 4,
model_version: 'test',
created_at: new Date().toISOString(),
});
expect(await db('photo_faces').where({ photo_id: pid })).toHaveLength(1);
await db('photos').where({ id: pid }).del();
expect(await db('photo_faces').where({ photo_id: pid })).toHaveLength(0);
});
});
@@ -0,0 +1,242 @@
/**
* Repairing the bundled templates' fixed image height (#1131).
*
* The risk in a migration that rewrites user-visible CSS is doing too much,
* so most of what is pinned here is what it must NOT touch: the other pixel
* heights inside the very same templates (a 1px divider, an 8px scrollbar),
* and any rule a user wrote themselves.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const migration = require('../../migrations/core/181_fix_css_template_photo_height');
const ELEGANT_DARK = `
.photo-card {
border-radius: 12px;
}
.photo-card img {
width: 100%;
height: 200px;
object-fit: cover;
transition: transform 0.3s ease;
}
`;
const LIQUID_GLASS_DARK = `
.gallery-page::after {
content: '';
height: 1px;
background: linear-gradient(90deg, transparent, #fff, transparent);
}
.photo-card img {
width: 100%;
height: 240px;
object-fit: cover;
filter: brightness(0.9);
}
.gallery-page ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
@media (max-width: 640px) {
.photo-card img {
height: 180px;
}
}
`;
describe('migration 181 — CSS template image height (#1131)', () => {
let knex; let tmpDir;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig181-'));
knex = require('knex')({
client: 'sqlite3',
connection: { filename: path.join(tmpDir, 'db.sqlite') },
useNullAsDefault: true,
});
await knex.schema.createTable('css_templates', (t) => {
t.increments('id').primary();
t.string('name');
t.text('css_content');
});
});
afterAll(async () => {
if (knex) await knex.destroy();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => { await knex('css_templates').del(); });
const contentOf = async (name) =>
(await knex('css_templates').where({ name }).first()).css_content;
it('relaxes the default template so the layouts h-full can win', async () => {
await knex('css_templates').insert({ name: 'Elegant Dark', css_content: ELEGANT_DARK });
await migration.up(knex);
const css = await contentOf('Elegant Dark');
expect(css).toContain('height: 100%');
expect(css).not.toContain('height: 200px');
// Everything else about the rule survives.
expect(css).toContain('object-fit: cover');
expect(css).toContain('transition: transform 0.3s ease');
});
it('fixes both the base rule and the mobile override of the dark glass template', async () => {
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
await migration.up(knex);
const css = await contentOf('Liquid Glass Dark');
expect(css).not.toContain('height: 240px');
expect(css).not.toContain('height: 180px');
expect(css.match(/height: 100%/g)).toHaveLength(2);
});
it('leaves the divider and the scrollbar alone', async () => {
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
await migration.up(knex);
// The whole reason this matches full rule bodies rather than every
// `height: <n>px`: these are in the same stylesheet and are correct.
const css = await contentOf('Liquid Glass Dark');
expect(css).toContain('height: 1px');
expect(css).toContain('width: 8px');
expect(css).toContain('height: 8px');
});
/**
* The case that forced the scope wider. `sanitizeCSS` strips control
* characters, so any template ever saved through the editor — including a
* save that only changed its name — has had every newline REMOVED. An
* exact-text migration finds nothing on those installs, is recorded as
* applied, and leaves them broken permanently.
*/
it('fixes a template that has been through the editor, newlines and all', async () => {
const { sanitizeCSS } = require('../../src/utils/cssSanitizer');
const { sanitized } = sanitizeCSS(ELEGANT_DARK);
// Precondition: the sanitizer really did flatten it.
expect(sanitized).not.toContain('\n');
expect(sanitized).toContain('height: 200px');
await knex('css_templates').insert({ name: 'Saved Once', css_content: sanitized });
await migration.up(knex);
const css = await contentOf('Saved Once');
expect(css).not.toContain('200px');
expect(css).toContain('height: 100%');
});
it('relaxes a user-authored fixed height too, but only on .photo-card img', async () => {
// Deliberately broader than the seeded text — see the migration header. A
// pixel height on the image cannot be right under any of the seven
// layouts, whoever wrote it; a height anywhere else is none of our
// business.
const mine = '.photo-card img {\n height: 220px;\n}\n.hero { height: 400px; }';
await knex('css_templates').insert({ name: 'My Own', css_content: mine });
await migration.up(knex);
const css = await contentOf('My Own');
expect(css).toContain('height: 100%');
expect(css).not.toContain('220px');
expect(css).toContain('.hero { height: 400px; }');
});
it('does not rewrite other properties that merely end in -height', async () => {
// `line-height: 200px` contains `height: 200px` as a substring, so an
// unanchored pattern silently rewrites it — in a migration that cannot be
// undone.
const mine = [
'.photo-card img {',
' line-height: 200px;',
' max-height: 300px;',
' min-height: 14px;',
' --tile-height: 220px;',
' height: 200px;',
'}',
].join('\n');
await knex('css_templates').insert({ name: 'Adjacent Props', css_content: mine });
await migration.up(knex);
const css = await contentOf('Adjacent Props');
expect(css).toContain('line-height: 200px');
expect(css).toContain('max-height: 300px');
expect(css).toContain('min-height: 14px');
expect(css).toContain('--tile-height: 220px');
// Only the real one moved.
expect(css).toContain('height: 100%');
expect(css).not.toMatch(/(?<![\w-])height:\s*200px/);
});
it('handles a grouped selector list', async () => {
// Requiring `{` straight after `img` skipped these entirely — and the
// migration is still recorded as applied, so the template kept the bug.
const mine = '.photo-card img, .thumbnail img {\n height: 200px;\n}';
await knex('css_templates').insert({ name: 'Grouped', css_content: mine });
await migration.up(knex);
const css = await contentOf('Grouped');
expect(css).toContain('.photo-card img, .thumbnail img {');
expect(css).toContain('height: 100%');
expect(css).not.toContain('200px');
});
it('skips a nested rule rather than rewriting the wrong declaration', async () => {
// Valid nested CSS that passes the validator. A brace-greedy body would
// capture the inner block and rewrite the CAPTION's height, which cannot
// be undone. Leaving it untouched is the lesser evil.
const mine = '.photo-card img {\n & + .caption { height: 200px; }\n}';
await knex('css_templates').insert({ name: 'Nested', css_content: mine });
await migration.up(knex);
expect(await contentOf('Nested')).toBe(mine);
});
it('leaves non-pixel heights on the image alone', async () => {
const mine = '.photo-card img { height: 50vh; }\n.photo-card img { height: auto; }';
await knex('css_templates').insert({ name: 'Relative', css_content: mine });
await migration.up(knex);
expect(await contentOf('Relative')).toBe(mine);
});
it('is idempotent and safe on a row with no CSS', async () => {
await knex('css_templates').insert([
{ name: 'Elegant Dark', css_content: ELEGANT_DARK },
{ name: 'Empty', css_content: null },
]);
await migration.up(knex);
const once = await contentOf('Elegant Dark');
await migration.up(knex);
expect(await contentOf('Elegant Dark')).toBe(once);
expect(await contentOf('Empty')).toBeNull();
});
it('no-ops when the table does not exist yet', async () => {
await knex.schema.dropTable('css_templates');
await expect(migration.up(knex)).resolves.toBeUndefined();
await knex.schema.createTable('css_templates', (t) => {
t.increments('id').primary();
t.string('name');
t.text('css_content');
});
});
});
@@ -0,0 +1,554 @@
/**
* One row per external file per event (#1162).
*
* The migration has two halves and they fail differently: the cleanup can take
* out the wrong row of a pair (losing a thumbnail, orphaning an event's hero),
* and the index can fail to be created at all — leaving an install that looks
* migrated and is still racing. Both are pinned here.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const migration = require('../../migrations/core/186_external_relpath_unique');
describe('migration 186 — unique (event_id, external_relpath) (#1162)', () => {
let knex; let tmpDir;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig186-'));
knex = require('knex')({
client: 'sqlite3',
connection: { filename: path.join(tmpDir, 'db.sqlite') },
useNullAsDefault: true,
});
});
afterAll(async () => {
if (knex) await knex.destroy();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
for (const table of [
'photos', 'events', 'photo_categories', 'photo_feedback',
'photo_admin_marks', 'photo_faces', 'image_access_logs', 'transfer_files',
'event_people', 'event_people_merge_dismissals',
]) {
await knex.schema.dropTableIfExists(table);
}
await knex.schema.createTable('events', (t) => {
t.increments('id').primary();
t.integer('hero_photo_id');
t.string('download_zip_path');
t.string('download_zip_generated_at');
});
await knex.schema.createTable('photo_categories', (t) => {
t.increments('id').primary();
t.integer('hero_photo_id');
});
await knex.schema.createTable('photos', (t) => {
t.increments('id').primary();
t.integer('event_id');
t.string('external_relpath');
t.string('thumbnail_path');
t.string('source_origin').defaultTo('managed');
t.integer('feedback_count').defaultTo(0);
t.integer('like_count').defaultTo(0);
t.decimal('average_rating', 3, 2).defaultTo(0);
t.integer('favorite_count').defaultTo(0);
t.integer('reaction_count').defaultTo(0);
t.integer('color_label_count').defaultTo(0);
t.string('face_status');
t.integer('view_count').defaultTo(0);
t.integer('download_count').defaultTo(0);
t.integer('face_count');
t.string('face_started_at');
t.text('face_error');
});
// Declared exactly as the real schema declares them — CASCADE and all.
// The point of these tables here is that SQLite does NOT enforce any of
// it (PicPeak never sets `PRAGMA foreign_keys = ON`), so a bare delete of
// the photo row leaves every one of them dangling.
await knex.schema.createTable('photo_feedback', (t) => {
t.increments('id').primary();
t.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
t.integer('event_id');
t.string('feedback_type');
t.text('comment_text');
t.string('guest_identifier');
// Per-person guest identity (migration 078). Nullable: galleries without
// guest identity leave it NULL and fall back to guest_identifier.
t.integer('guest_id');
t.integer('rating');
t.boolean('is_hidden').defaultTo(false);
t.boolean('is_approved').defaultTo(true);
});
await knex.schema.createTable('photo_admin_marks', (t) => {
t.increments('id').primary();
t.integer('photo_id').notNullable().references('id').inTable('photos').onDelete('CASCADE');
t.integer('event_id');
t.integer('admin_id');
t.integer('rating');
// Independently writable alongside rating, per photoAdminMarksService.
t.string('color_label', 16);
t.unique(['photo_id', 'admin_id'], 'photo_admin_marks_photo_admin_uniq');
});
await knex.schema.createTable('photo_faces', (t) => {
t.increments('id').primary();
t.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
t.integer('event_id');
// purgePhotoFaces rebuilds the people that lose members, so the cluster
// link and the vectors recomputeCentroid reads have to be here for this
// to exercise the real path rather than a stub.
t.integer('person_id');
t.binary('embedding');
t.float('det_score');
});
await knex.schema.createTable('event_people', (t) => {
t.increments('id').primary();
t.integer('event_id');
t.binary('centroid');
t.integer('face_count').defaultTo(0);
});
await knex.schema.createTable('event_people_merge_dismissals', (t) => {
t.increments('id').primary();
t.integer('event_id');
t.binary('centroid_a');
t.binary('centroid_b');
});
await knex.schema.createTable('image_access_logs', (t) => {
t.increments('id').primary();
t.integer('photo_id');
});
await knex.schema.createTable('transfer_files', (t) => {
t.increments('id').primary();
t.integer('transfer_id');
t.integer('photo_id');
t.unique(['transfer_id', 'photo_id'], 'transfer_files_unique');
});
});
/** Two duplicate rows for the same file: id 1 survives, id 2 is doomed. */
const seedPair = async () => {
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
]);
};
const rows = () => knex('photos').orderBy('id', 'asc').select('*');
it('collapses a duplicated pair to one row and leaves distinct paths alone', async () => {
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't1', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't2', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/y.jpg', thumbnail_path: 't3', source_origin: 'external' },
]);
await migration.up(knex);
const after = await rows();
expect(after.map((r) => r.external_relpath)).toEqual(['a/x.jpg', 'a/y.jpg']);
// Lowest id survives when both sides are equally complete.
expect(after[0].id).toBe(1);
});
it('does not collapse the same path across different events', async () => {
// The constraint is per event. Two events referencing the same NAS folder
// is a supported setup, and treating those as duplicates would delete one
// event's entire library.
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
{ event_id: 2, external_relpath: 'a/x.jpg', source_origin: 'external' },
]);
await migration.up(knex);
expect(await knex('photos').count('* as c').first()).toEqual({ c: 2 });
});
it('never touches managed rows, however many carry NULL', async () => {
// Every managed photo has external_relpath NULL. Grouping on it without
// the NOT NULL filter would make them all one enormous "duplicate" group
// and delete the entire library bar one row.
await knex('photos').insert([
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
]);
await migration.up(knex);
expect(await knex('photos').count('* as c').first()).toEqual({ c: 3 });
});
it('keeps the row that has a thumbnail, not merely the lowest id', async () => {
// An import killed mid-flight leaves rows without a thumbnail. Dropping
// the completed one would blank a tile in the grid for no reason.
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: null, source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 'thumb.jpg', source_origin: 'external' },
]);
await migration.up(knex);
const after = await rows();
expect(after).toHaveLength(1);
expect(after[0].thumbnail_path).toBe('thumb.jpg');
});
it('repoints a hero that pointed at the row being removed', async () => {
// events.hero_photo_id is ON DELETE SET NULL, so without this the cleanup
// silently strips the event's hero image — a visible regression caused
// entirely by the fix.
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
]);
await knex('events').insert({ id: 1, hero_photo_id: 2 });
await knex('photo_categories').insert({ id: 1, hero_photo_id: 2 });
await migration.up(knex);
expect((await knex('events').where({ id: 1 }).first()).hero_photo_id).toBe(1);
expect((await knex('photo_categories').where({ id: 1 }).first()).hero_photo_id).toBe(1);
});
it('leaves a hero that pointed at the survivor untouched', async () => {
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
]);
await knex('events').insert({ id: 1, hero_photo_id: 1 });
await migration.up(knex);
expect((await knex('events').where({ id: 1 }).first()).hero_photo_id).toBe(1);
});
it('makes a second insert of the same path impossible afterwards', async () => {
// The whole point. Without this the route is still racing, and the
// migration is recorded as applied.
await knex('photos').insert({ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' });
await migration.up(knex);
await expect(
knex('photos').insert({ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' })
).rejects.toThrow(/unique/i);
});
it('still admits managed rows once the index exists', async () => {
await migration.up(knex);
await knex('photos').insert([
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
]);
expect(await knex('photos').count('* as c').first()).toEqual({ c: 2 });
});
it('leaves nothing dangling behind the deleted row', async () => {
// SQLite never enforces the ON DELETE CASCADE these tables declare, so a
// bare delete strands biometric embeddings, feedback and marks pointing at
// a photo id that no longer exists — on every SQLite install.
await seedPair();
await knex('photo_faces').insert({ photo_id: 2, event_id: 1 });
await knex('image_access_logs').insert({ photo_id: 2 });
await migration.up(knex);
expect(await knex('photo_faces').where('photo_id', 2).first()).toBeUndefined();
expect(await knex('image_access_logs').where('photo_id', 2).first()).toBeUndefined();
});
it('does not carry the duplicate\'s faces over to the survivor', async () => {
// Both rows were scanned independently, so the survivor already holds its
// own embeddings. Moving these would fabricate a second copy of every face
// and split the person clusters built from them.
await seedPair();
await knex('photo_faces').insert([{ photo_id: 1, event_id: 1 }, { photo_id: 2, event_id: 1 }]);
await migration.up(knex);
expect(await knex('photo_faces').count('* as c').first()).toEqual({ c: 1 });
});
it('moves a guest comment to the survivor rather than deleting it', async () => {
// The duplicates were separate tiles in the grid, so a guest could have
// commented on either. Silently dropping that inside a fix for silent data
// loss would be its own bug.
await seedPair();
await knex('photo_feedback').insert({
photo_id: 2, event_id: 1, feedback_type: 'comment',
comment_text: 'lovely shot', guest_identifier: 'guest-a',
});
await migration.up(knex);
const rows = await knex('photo_feedback');
expect(rows).toHaveLength(1);
expect(rows[0].photo_id).toBe(1);
expect(rows[0].comment_text).toBe('lovely shot');
});
it('keeps both comments when the same guest commented on both tiles', async () => {
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'comment', comment_text: 'one', guest_identifier: 'g' },
{ photo_id: 2, event_id: 1, feedback_type: 'comment', comment_text: 'two', guest_identifier: 'g' },
]);
await migration.up(knex);
const rows = await knex('photo_feedback').orderBy('id');
expect(rows.map((r) => r.comment_text)).toEqual(['one', 'two']);
expect(rows.every((r) => r.photo_id === 1)).toBe(true);
});
it('does not double-count a like the same guest left on both tiles', async () => {
// Unlike comments, a like is a per-guest toggle: moving it would show two
// likes from one person.
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'like', guest_identifier: 'g' },
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'g' },
]);
await migration.up(knex);
expect(await knex('photo_feedback').count('* as c').first()).toEqual({ c: 1 });
});
it('moves a like from a guest the survivor has never seen', async () => {
await seedPair();
await knex('photo_feedback').insert({
photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'other',
});
await migration.up(knex);
const rows = await knex('photo_feedback');
expect(rows).toHaveLength(1);
expect(rows[0].photo_id).toBe(1);
});
it('moves an admin mark, and drops it when that admin already marked the survivor', async () => {
// photo_admin_marks is UNIQUE(photo_id, admin_id), so a blind move would
// throw and abort the migration.
await seedPair();
await knex('photo_admin_marks').insert([
{ photo_id: 1, event_id: 1, admin_id: 7, rating: 5 },
{ photo_id: 2, event_id: 1, admin_id: 7, rating: 2 },
{ photo_id: 2, event_id: 1, admin_id: 9, rating: 4 },
]);
await migration.up(knex);
const rows = await knex('photo_admin_marks').orderBy('admin_id');
expect(rows.map((r) => [r.admin_id, r.rating])).toEqual([[7, 5], [9, 4]]);
expect(rows.every((r) => r.photo_id === 1)).toBe(true);
});
it('respects the transfer_files uniqueness when moving membership', async () => {
await seedPair();
await knex('transfer_files').insert([
{ transfer_id: 3, photo_id: 1 },
{ transfer_id: 3, photo_id: 2 },
{ transfer_id: 4, photo_id: 2 },
]);
await migration.up(knex);
const rows = await knex('transfer_files').orderBy('transfer_id');
expect(rows.map((r) => r.transfer_id)).toEqual([3, 4]);
expect(rows.every((r) => r.photo_id === 1)).toBe(true);
});
it('recomputes the survivor\'s feedback totals after reparenting rows', async () => {
// photos carries denormalized counters (migration 033). A survivor that
// now OWNS the feedback but still renders zero is the visible half of
// getting this wrong.
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'g1' },
{ photo_id: 2, event_id: 1, feedback_type: 'rating', rating: 4, guest_identifier: 'g1' },
]);
await migration.up(knex);
const survivor = await knex('photos').where('id', 1).first();
expect(survivor.like_count).toBe(1);
expect(Number(survivor.average_rating)).toBe(4);
expect(survivor.feedback_count).toBe(1);
});
it('keeps two people who share a device apart', async () => {
// guest_identifier is per-device; guest_id is per-person (migration 078),
// and feedbackService scopes by guest_id when it is present. Keying on the
// identifier alone would read these as one person and delete a rating.
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'rating', rating: 5, guest_identifier: 'shared', guest_id: 10 },
{ photo_id: 2, event_id: 1, feedback_type: 'rating', rating: 2, guest_identifier: 'shared', guest_id: 11 },
]);
await migration.up(knex);
const rows = await knex('photo_feedback').orderBy('guest_id');
expect(rows.map((r) => [r.guest_id, r.rating])).toEqual([[10, 5], [11, 2]]);
});
it('still dedupes one person voting on both tiles', async () => {
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'like', guest_identifier: 'shared', guest_id: 10 },
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'shared', guest_id: 10 },
]);
await migration.up(knex);
expect(await knex('photo_feedback').count('* as c').first()).toEqual({ c: 1 });
});
it('rebuilds the people that lose members, rather than deleting faces raw', async () => {
// purgePhotoFaces is "called from every photo-deletion path" precisely
// because event_people counts and centroids are derived from the rows
// being removed. A bare delete leaves a ghost person behind.
await seedPair();
await knex('event_people').insert({ id: 5, event_id: 1, face_count: 1 });
await knex('photo_faces').insert({ photo_id: 2, event_id: 1, person_id: 5 });
await migration.up(knex);
expect(await knex('photo_faces').count('* as c').first()).toEqual({ c: 0 });
// The person had exactly one member and loses it, so it goes with it.
expect(await knex('event_people').where('id', 5).first()).toBeUndefined();
});
it('keeps a hidden moderation record from swallowing the visible replacement', async () => {
// feedbackService lets both coexist and counts only the visible one.
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'like', guest_identifier: 'g', is_hidden: true },
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'g', is_hidden: false },
]);
await migration.up(knex);
expect(await knex('photo_feedback').count('* as c').first()).toEqual({ c: 2 });
});
it('merges the independent halves of one admin\'s mark', async () => {
// rating and color_label are written independently, so the same admin can
// have rated one tile and coloured the other.
await seedPair();
await knex('photo_admin_marks').insert([
{ photo_id: 1, event_id: 1, admin_id: 7, rating: 5, color_label: null },
{ photo_id: 2, event_id: 1, admin_id: 7, rating: null, color_label: 'red' },
]);
await migration.up(knex);
const rows = await knex('photo_admin_marks');
expect(rows).toHaveLength(1);
expect([rows[0].rating, rows[0].color_label]).toEqual([5, 'red']);
});
it('requeues the survivor when the duplicate held the only scan', async () => {
// Otherwise the sole embeddings go with the purge and nothing re-queues:
// the photo just silently stops having a face.
await seedPair();
await knex('photo_faces').insert({ photo_id: 2, event_id: 1 });
await migration.up(knex);
expect((await knex('photos').where('id', 1).first()).face_status).toBe('pending');
});
it('carries the duplicate\'s views and downloads over', async () => {
await seedPair();
await knex('photos').where('id', 1).update({ view_count: 2, download_count: 1 });
await knex('photos').where('id', 2).update({ view_count: 5, download_count: 3 });
await migration.up(knex);
const survivor = await knex('photos').where('id', 1).first();
expect([survivor.view_count, survivor.download_count]).toEqual([7, 4]);
});
it('fails loudly rather than recording itself applied without the index', async () => {
// Swallowing a failed CREATE INDEX would leave the install permanently
// racy — the in-flight guard only covers one process — with nothing to
// trigger a retry. Driven through the helper the migration calls, against
// a table that still holds duplicates — i.e. what it would face if the
// dedupe above had not achieved uniqueness.
await seedPair();
const { createExternalRelpathIndex } = require('../../src/services/externalPhotoDedupe');
await expect(createExternalRelpathIndex(knex)).rejects.toThrow(/unique/i);
});
it('invalidates the pre-built download zip for the affected event', async () => {
// The cached archive still contains the rows just removed, and every
// ordinary photo-deletion path invalidates it for exactly that reason.
// getZipInfo treats a cleared record as a miss and rebuilds on request.
await seedPair();
await knex('events').insert({
id: 1, download_zip_path: 'events/active/x/.download-cache/all.zip',
download_zip_generated_at: '2026-01-01',
});
await migration.up(knex);
const ev = await knex('events').where('id', 1).first();
expect(ev.download_zip_path).toBeNull();
expect(ev.download_zip_generated_at).toBeNull();
});
it('leaves an untouched event\'s zip alone', async () => {
await seedPair();
await knex('events').insert([
{ id: 1, download_zip_path: 'a.zip', download_zip_generated_at: '2026-01-01' },
{ id: 2, download_zip_path: 'b.zip', download_zip_generated_at: '2026-01-01' },
]);
await migration.up(knex);
expect((await knex('events').where('id', 2).first()).download_zip_path).toBe('b.zip');
});
it('is idempotent', async () => {
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
]);
await migration.up(knex);
const once = await rows();
await migration.up(knex);
expect(await rows()).toEqual(once);
});
it('rolls back to an unconstrained table', async () => {
await migration.up(knex);
await migration.down(knex);
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
]);
expect(await knex('photos').count('* as c').first()).toEqual({ c: 2 });
});
it('no-ops before 041 has added the column', async () => {
await knex.schema.dropTableIfExists('photos');
await knex.schema.createTable('photos', (t) => { t.increments('id').primary(); });
await expect(migration.up(knex)).resolves.toBeUndefined();
});
});

Some files were not shown because too many files have changed in this diff Show More