Commit Graph
1060 Commits
Author SHA1 Message Date
Paul Nothaft 8cbb37310b chore(main): release 3.98.2-beta.0 (#945)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-08-01 17:37:57 +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) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
2026-08-01 10:32:31 +00: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) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
2026-07-31 09:56:00 +02:00
Paul NothaftandPaul 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 <[email protected]>
2026-07-31 08:57:08 +02:00
Paul NothaftandPaul 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 <[email protected]>
2026-07-31 08:56:48 +02:00
Paul NothaftandPaul 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 <[email protected]>
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 / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
2026-07-30 12:28:18 +00:00
Paul NothaftandPaul 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 <[email protected]>
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) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-07-30 10:19:02 +00:00
Paul NothaftandPaul 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 <[email protected]>
2026-07-30 12:14:41 +02:00
Paul Nothaft 55b344b531 chore(main): release 3.97.4-beta.0 (#918)
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
2026-07-29 20:26:14 +00:00
Paul NothaftandPaul 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 <[email protected]>
2026-07-29 22:23:55 +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) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-07-29 19:48:17 +00:00
Paul Nothaft 1ee7fe7336 chore(main): release 3.97.2-beta.0 (#906)
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
2026-07-29 16:02:10 +00:00
Paul NothaftandPaul 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 <[email protected]>
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) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-07-29 10:40:56 +00: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) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
2026-07-29 09:46:14 +00:00
Paul NothaftandPaul 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 <[email protected]>
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-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
2026-07-29 09:30:02 +00:00
Paul NothaftandPaul 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 <[email protected]>
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) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-07-29 09:25:35 +00:00
Paul NothaftandPaul 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 <[email protected]>
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) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
2026-07-29 08:55:34 +00:00
Paul NothaftandPaul 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 <[email protected]>
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/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
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/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-07-27 07:57:33 +00: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) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
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) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-07-26 18:41:13 +00: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) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
2026-07-25 13:10:23 +02:00
Paul NothaftandPaul 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 <[email protected]>
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/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
2026-07-23 19:59:53 +00:00
Paul NothaftandPaul 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 <[email protected]>
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) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-07-22 19:30:46 +00: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) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-07-22 19:10:00 +00:00
Paul NothaftandPaul 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 <[email protected]>
2026-07-22 21:07:32 +02:00
Paul NothaftandPaul 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 <[email protected]>
2026-07-22 20:59:59 +02:00
Paul NothaftandPaul 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 <[email protected]>
2026-07-22 20:59:43 +02:00
Paul NothaftandPaul 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 <[email protected]>
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) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-07-19 20:40:52 +00: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/[email protected].

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) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-07-19 20:05:52 +00:00
Paul Nothaft f891b16503 chore(main): release 3.92.1-beta.0 (#842)
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-07-19 09:22:09 +00: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) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-07-18 19:02:48 +00: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-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
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