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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
#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.
- 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.
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.
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
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().
- 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
- 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
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.
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.
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.
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