Commit Graph

730 Commits

Author SHA1 Message Date
Paul Nothaft 743086d3cb fix(lightbox): smooth carousel swipe + drop instructional hint (#348)
Two fixes for discussion #348.

Carousel-style swipe
The lightbox previously snapped to the next photo on swipe, then showed
a loading spinner while the new image fetched — choppy compared with
the reference video the reporter shared. The current photo is now
rendered inside a 3-slide track (prev/current/next). As the finger
drags, the track follows; on release the track animates to the
neighbouring slot or springs back if the gesture didn't pass the
threshold. Because the prev/next AuthenticatedImages render up front,
the browser starts fetching them while the user is still on the
current photo, so there's no loader flash on commit.

- Phase machine ('idle' | 'dragging' | 'committing' | 'springing')
  drives the track's transform/transition. Commit + spring use a 280ms
  cubic-bezier ease.
- Percentage-based transforms avoid measuring container width before
  the first paint. Commit threshold (read from the ref on demand) is
  max(60px, 20% of width) OR a fast flick (>0.5 px/ms with at least
  40px of movement).
- transitionend advances currentIndex with wrap-around and resets the
  track in one batch — slot contents rotate and the track snaps from
  the commit position back to centered with transition: none, so the
  visible image stays put. No flicker.
- Vertical-cancel (>24px dy) abandons the drag and springs back so the
  user keeps the gesture they intended.
- touch-action: none on the carousel container stops the browser
  fighting us with edge-swipe back navigation and native pinch-zoom.
- Pinch starting mid-drag springs the track back smoothly so the image
  doesn't jerk under the second finger.
- onTouchCancel covers system-interrupted gestures (incoming call etc).
- dragX === 0 short-circuits to 'idle' instead of 'springing' so taps
  don't get stuck waiting for a transitionend that never fires.
- Neighbour slides use a simplified AuthenticatedImage render (no
  canvas/fragment-grid pipeline) since they're only on screen during
  the swipe; the current slide keeps the full protection chain.
- Neighbour videos render their thumbnail rather than spinning up a
  VideoPlayer. When the *current* photo is a video, the carousel is
  bypassed entirely — single VideoPlayer + no swipe handlers — because
  sliding a video element during a drag is awkward and adds nothing.
- Removed the now-redundant imageLoaded state + spinner;
  AuthenticatedImage already shows a placeholder while loading.

Keyboard arrows and the on-screen Prev/Next buttons still snap (no
animation) — animating them would have required input queuing for
fast double-presses, and the request was specifically about swipe.

"Swipe to navigate" hint
Removed the mobile-only overlay text. Swipe is universal in image
viewers; the instruction read like training wheels and competed with
the photo for attention.
2026-05-01 20:55:42 +02:00
Paul Nothaft d9d81372b8 fix(gallery): lazy-render skeleton grid for fast loads (#321 follow-up)
The gallery loading skeleton now renders the header bars immediately but
delays the 12-tile placeholder grid by 300ms. Galleries that load
quickly (the common case) never flash the empty grid before the real
photos render — addressing the follow-up reported on #321 — while
slower loads still get a placeholder so the page doesn't sit blank.
2026-05-01 20:55:19 +02:00
Paul Nothaft a5b20ca3fe fix(events): server-side search/pagination to remove first-100 cap (#346)
Counters and search on Admin → Events were bounded to the first 100 rows
returned from /admin/events?page=1&limit=100, so on instances with more
events the totals were wrong and search couldn't find anything outside
that window. The dashboard's expiring list had the same first-100 issue.

Backend
- adminEvents.js: extend search to include customer_email so the column
  shown in the table is actually queryable.
- adminDashboard.js: add totalEvents to /dashboard/stats so the events
  page can render an accurate "All (N)" / Total Events counter without
  walking the full table on the client.

Frontend
- events.service.ts: getEvents() now accepts search + the full status
  enum (active|inactive|archived|draft|expiring); response type matches
  the actual {events, pagination} shape.
- admin.service.ts: DashboardStats gains totalEvents.
- EventsListPage.tsx: rewired around server-side pagination, status
  filter, and 300ms-debounced search; Prev/Next + range/page indicator
  below the table; placeholderData keeps the previous page visible
  during fetches; stat cards and "All (N)" pull from /dashboard/stats so
  totals stay accurate regardless of the visible page; archive/delete
  invalidates dashboard-stats so cards refresh.
- AdminDashboard.tsx: expiring list now fetches getEvents(1, 5,
  'expiring') directly instead of slicing the first 100 client-side. As
  a side effect the dashboard's "expiring" definition now matches the
  backend (was excluding events expiring within the next 24h).
2026-05-01 20:55:13 +02:00
Paul Nothaft 92847bc06b Merge pull request #345 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-frontend (push) Failing after 3m23s
Build and Push Docker Images / build-backend (push) Failing after 3m23s
Build and Push Docker Images / summary (push) Successful in 2s
chore(beta): release 3.32.1-beta.0
v3.32.1-beta.0
2026-04-30 09:09:37 +02:00
github-actions[bot] 7e65921ba6 chore(beta): release 3.32.1-beta.0 2026-04-30 07:08:45 +00:00
Paul Nothaft 02ed5d4007 Merge pull request #344 from the-luap/chore/move-docs-to-picpeak-app
docs: move documentation to docs.picpeak.app, drop in-repo copies
2026-04-30 09:08:16 +02:00
Paul Nothaft 0faf9b3281 docs: move documentation to docs.picpeak.app, drop in-repo copies
The full documentation now lives at https://docs.picpeak.app — built
from the picpeak-docs Nextra repo. The README, SIMPLE_SETUP, and the
v1 OpenAPI generation flow all point there now.

Removed (now living at docs.picpeak.app):
- DEPLOYMENT_GUIDE.md (root-level — content covered by docs.picpeak.app/deployment)
- docs/ADMIN_SETUP_GUIDE.md
- docs/JWT_SECRET_MIGRATION.md
- docs/SECURITY_BEST_PRACTICES.md
- docs/admin-api-quickstart.md → docs.picpeak.app/api
- docs/nginx-fix.md → docs.picpeak.app/deployment/reverse-proxy
- docs/openapi.json, docs/openapi.yaml → still generated locally as a
  build artifact (now gitignored), synced into picpeak-docs by
  scripts/sync-api-docs.sh
- docs/picpeak-admin-api.openapi.yaml → ditto

Kept:
- docs/*.png (logo + screenshots — README still img-tags these)

Updated:
- README.md — replaced six in-repo doc links with docs.picpeak.app
  pointers, restructured the Documentation section as a curated link
  list to the new site
- SIMPLE_SETUP.md — single deployment-guide link redirected
- .gitignore — docs/openapi.{json,yaml} are now build artifacts, not
  tracked
- backend/src/routes/v1/events.js — comment clarifies the OpenAPI flow
2026-04-29 22:24:15 +02:00
Paul Nothaft 39af382eb2 Merge pull request #343 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m36s
Build and Push Docker Images / build-frontend (push) Failing after 3m36s
Build and Push Docker Images / summary (push) Successful in 3s
chore(beta): release 3.32.0-beta.0
v3.32.0-beta.0
2026-04-29 20:43:46 +02:00
github-actions[bot] 784c92fc4d chore(beta): release 3.32.0-beta.0 2026-04-29 18:35:09 +00:00
Paul Nothaft 7ea4801544 Merge pull request #342 from the-luap/feat/webhook-payload-enrichment
feat(webhooks): enrich event.* payloads with customer contact + share_token (#341)
2026-04-29 20:34:46 +02:00
Paul Nothaft 1e69d5ff71 feat(webhooks): enrich event.* payloads with customer contact + share_token (#341)
The event.published webhook reporter wired into n8n to send WhatsApp
gallery links was missing the data needed to actually message the
customer — only event_name + share_url were in the payload, no
customer_name / customer_email / customer_phone, and no bare share
token to construct alternate URLs.

Adds a single canonical event subject helper (webhookService.buildEventSubject)
so every event.* webhook returns the same shape:

  { id, slug, event_name, event_type, event_date,
    share_url, share_token,
    customer_name, customer_email, customer_phone }

Fields the caller does not have in scope come back as null — keys are
always present so receivers do not have to distinguish "field missing"
from "field null". Pure addition: existing receivers continue to work,
existing templates ${data.event.event_name} keep working, and new
templates can now reference ${data.event.customer_phone} etc.

Wired into all five firing sites:
- routes/events.js — public event create (created + published)
- routes/adminEvents.js — admin create + draft→publish
- routes/v1/events.js — public v1 API (created + published)
- services/expirationChecker.js — event.expired (extra: expires_at)
- services/archiveService.js — event.archived (extra: archive_path)

PII surface area widens (customer email/phone now flow to webhook
receivers), so:
- Settings → Webhooks UI gets an amber Callout above the create form
  warning admins to only point webhooks at receivers they trust.
- Docs page updated with the new payload sample, the always-present
  null contract, and a Callout warning.

Verified end-to-end against the local dev webhook receiver — delivered
payload contains all 10 fields. webhookDelivery integration suite
remains 8/8 green.
2026-04-29 20:32:20 +02:00
Paul Nothaft 1b1d816009 Merge pull request #340 from the-luap/refactor/settings-nav-grouped
refactor(settings): grouped left-rail nav replaces overflowing tab bar
2026-04-29 00:03:04 +02:00
Paul Nothaft f171f6b974 refactor(settings): grouped left-rail nav replaces overflowing tab bar
The Settings page packed 13 tab buttons into a single horizontal nav
that overflowed even at 1440px — items wrapped or got clipped, and
"Webhooks" disappeared off the right edge entirely. Pattern was the
right call at 5 tabs and broken at 13.

Replaces the flat row with the macOS Settings / Stripe / GitHub pattern:

- **Desktop (lg+)**: 220px sticky left rail with five labelled groups —
  General, Display, Privacy & Security, Integrations, System — and a
  lucide icon next to every item. Active state uses the existing primary
  token. Adds a section header on the right pane that echoes the active
  item so the context is obvious after a switch.
- **Mobile (< lg)**: native <select> with <optgroup> per category. One
  tap to switch, no horizontal scroll, screen-reader friendly.

Categories chosen to be balanced (avg 2.6 items/group) and to map to
how admins actually think about these settings rather than alphabetical
or insertion order. Ports the existing inline-fallback i18n pattern for
the new group labels.
2026-04-28 23:59:54 +02:00
Paul Nothaft 625711af96 Merge pull request #339 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m52s
Build and Push Docker Images / build-frontend (push) Failing after 3m51s
Build and Push Docker Images / summary (push) Successful in 2s
chore(beta): release 3.31.1-beta.0
v3.31.1-beta.0
2026-04-28 22:15:23 +02:00
github-actions[bot] c5a2ec3842 chore(beta): release 3.31.1-beta.0 2026-04-28 16:06:38 +00:00
Paul Nothaft 1e4067713c Merge pull request #338 from the-luap/fix/post-329-bug-triage
fix: mobile lightbox + share previews + customer phone bug triage
2026-04-28 18:06:15 +02:00
Paul Nothaft 42a7ae4be8 fix(lightbox): mobile toolbar clipping + iOS safe-area + viewport-fit (#336)
When feedback was enabled the lightbox bottom toolbar packed counter +
zoom + download + like + 5-star + comments into a single row that
overflowed the viewport on iPhone-class widths, putting the rating
stars under the screen edge and below the iOS home indicator.

Changes:
- Bottom toolbar now uses flex-wrap with reduced gap/padding on mobile,
  so all controls fit (375px viewport: max-right 363 < 375; 390px:
  max-right 378 < 390; 393px: max-right 393 < 393).
- pb computed as max(0.75rem, env(safe-area-inset-bottom)) so the row
  sits above the iOS home indicator on devices with a gesture bar.
- Close button top/right now use max(1rem, env(safe-area-inset-*)) so
  it doesn't disappear under the notch / dynamic island.
- "Swipe to navigate" hint moved from bottom-20 to bottom-40 so it
  clears the now-taller wrapped toolbar.
- index.html viewport meta gains viewport-fit=cover to enable
  env(safe-area-inset-*) on iOS Safari.

Verified in mobile emulation across iPhone SE (375x667), iPhone 13/14
(390x844), iPhone 14 Pro (393x852) portrait, and 14 Pro landscape
(852x393) — toolbar fits, photo centered, no clipping.
2026-04-28 16:50:44 +02:00
Paul Nothaft fcddfe094b fix(gallery): use ref for swipe-start to avoid stale-closure miss (#332)
Found via real-browser verification: with useState the prior commit's
handleTouchEnd captures swipeStart from its render closure, so when
touchstart and touchend fire inside the same React batch (fast swipe,
synthetic events, or a tight render cycle) the end handler reads the
stale null and skips navigation. useRef sidesteps the closure entirely
and is the right primitive for cross-event scratchpad state anyway.

Verified in a 4-photo gallery on mobile-emulation (390x844 touch):
- left swipe (-200px) advances 1/4 → 2/4
- right swipe (+200px) returns 2/4 → 1/4
- 20px swipe (under threshold) does not navigate
- vertical swipe (dy 300, dx 20) does not navigate
2026-04-28 15:55:09 +02:00
Paul Nothaft 5275621fcd fix(share): OG/Twitter-card metadata for gallery share URLs (#333)
WhatsApp / Slack / Facebook / Twitter previews showed nothing useful for
shared gallery links — the SPA's stub index.html has no OG tags and the
meta-injection in DynamicFavicon happens at runtime, which crawlers
never see (they don't execute JS).

Add a backend OG handler at /og/gallery/:slug that returns minimal HTML
with proper og:* and twitter:* meta sourced from the event row + branding
settings (event name, formatted date, welcome_message excerpt as
description, configured logo as the preview image, FRONTEND_URL-based
canonical). Honours slug redirects so renamed galleries still get rich
previews.

Wire crawler detection in both nginx configs (production and dev) — UA
match against the standard list (facebookexternalhit, WhatsApp, Slackbot,
Twitterbot, Discordbot, LinkedInBot, etc.) triggers an internal
rewrite to /og/gallery/:slug, while humans fall through to the SPA via
try_files. The OG endpoint is also wired into the native-install SPA
fallback in server.js for setups that bypass nginx.

The OG image is intentionally the brand logo, not a gallery photo —
crawlers fetch it without auth, and password-protected gallery photos
must not leak via share previews.
2026-04-28 15:05:44 +02:00
Paul Nothaft 4c8eba0cb4 fix(gallery): single-finger swipe nav in mobile lightbox (#332)
The lightbox showed a "Swipe to navigate" hint on mobile, but the touch
handlers only implemented pinch-to-zoom (2-finger). Single-finger swipe
fell through and the user could only navigate with the on-screen arrows.

Add a 1-finger swipe detector: track the initial touch position, and on
touchEnd compute deltaX/deltaY/duration. Trigger goToPrevious /
goToNext when the horizontal swipe exceeds 50px, dominates over
vertical motion (1.2x), and completes within 600ms. Suppressed while
zoomed in so the user can pan the image instead.
2026-04-28 15:05:30 +02:00
Paul Nothaft 4c73d228ed fix(events): show customer phone in event details view (#331)
The phone field added in #322 was wired into the edit form but never
rendered in the read-only event-info panel, so admins could only see the
number while editing. Add a phone row gated on event_phone_field_enabled
(same toggle the form uses), and tighten the Event type so customer_phone
is no longer accessed via `(event as any)`.
2026-04-28 15:05:22 +02:00
Paul Nothaft ca8acacd43 Merge pull request #335 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-frontend (push) Failing after 3m23s
Build and Push Docker Images / build-backend (push) Failing after 3m25s
Build and Push Docker Images / summary (push) Successful in 3s
chore(beta): release 3.31.0-beta.0
v3.31.0-beta.0
2026-04-28 14:53:06 +02:00
github-actions[bot] f58b52a9d1 chore(beta): release 3.31.0-beta.0 2026-04-28 12:51:00 +00:00
Paul Nothaft 06d54bec4d Merge pull request #334 from the-luap/feat/post-319-fixes-and-features
feat: S3 storage + webhooks + settings dedupe + backup fixes
2026-04-28 14:50:35 +02:00
Paul Nothaft e232f9f2cf fix(backup): incremental backups against S3 + jsonb stats parsing
Three fixes uncovered while bringing the backup-s3 integration suite to
12/12 against MinIO + Postgres:

- backupService.getDatabaseBackupInfo: pg's jsonb driver auto-parses
  `statistics` / `table_checksums` to objects; the old JSON.parse() then
  threw "[object Object]" is not valid JSON and the manifest dropped
  database info silently. Accept both string and object inputs.

- backupService.runBackup: incremental path called
  backupManifest.loadManifest() with an s3:// URI directly, which falls
  through to fs.readFile() and ENOENTs — every "incremental" backup
  silently downgraded to a full one. Added loadManifestFromAnywhere()
  helper that downloads s3:// to a tmp file before delegating.

- backupManifest.generateIncrementalManifest: attached the `incremental`
  section AFTER generateManifest() had already stamped
  verification.total_checksum, so every incremental manifest failed
  validateManifest() on read-back. Recompute the checksum after.

Test side: updated assertions to the current manifest shape
(`incremental.changes.modified_files_count`), Number()-coerce bigint
columns from pg, and gate the logger mock on UNMOCK_LOGGER for
diagnosing similar silent-failure modes in the future.
2026-04-28 10:52:49 +02:00
Paul Nothaft ab4095f592 fix(backup): cron schedule mapping + manifest format detection + bigint coerce
Three pre-existing bugs surfaced by re-running the backup-s3 integration
suite. backup-s3 went 0/12 → 7/12 (storage-refactor session bootstrap
fixes) → 10/12 with this commit.

1. Backup service crashes on backend startup with
   `TypeError: Cannot read properties of undefined (reading 'replace')`
   from node-cron's expression parser.

   Root cause: `backup_schedule` stores a UI label like "weekly", while
   `backup_schedule_cron` stores the actual cron expression. Startup
   code read the label and passed it straight to cron.schedule() —
   "weekly" is not a cron expression.

   Fix in startBackupService(): read backup_schedule_cron first; fall
   back to mapping known labels (hourly/daily/weekly/monthly) to cron
   expressions; back-compat for deployments that wrote a cron expression
   into the legacy backup_schedule field.

2. Backup manifest retrieval fails with
   `SyntaxError: Unexpected token 'a', "applicatio"...` when the
   manifest format is YAML.

   Root cause: getBackupManifest() downloads the s3:// manifest to a
   tmp file hardcoded as `manifest-N.json`. loadManifest() then
   detects format from extension only — sees .json, runs JSON.parse on
   YAML content (which starts with "application: …"), fails.

   Fix in backupManifest.loadManifest(): detect format from BOTH the
   extension AND the content's first non-whitespace character. JSON
   starts with { or [; anything else falls through to yaml.load.
   Backwards compatible — extension is still authoritative when present
   AND content matches.

3. Test assertion `expect(backupRun.total_size_bytes).toBeGreaterThan(0)`
   fails with "received value must be a number or bigint" because pg
   driver returns bigint columns as strings. Coerce via Number() in
   the test.

Remaining 2 failures (out of scope here, both are spec-level drift):
- "should include database backup" expects the runBackup() flow to
  upload the database backup file at S3 key `database/db-backup.sql`.
  Current implementation reads db backup metadata for the manifest but
  does not upload the file itself. Missing feature, not a test bug.
- "should only upload changed files" expects manifest.incremental.
  modified_files_count. Implementation writes backupType: 'incremental'
  on the run row but no per-run incremental subobject in the manifest.
  Field shape mismatch.
2026-04-28 10:15:46 +02:00
Paul Nothaft 446d80a4cc feat: presigned download UI + S3 prefix walker auto-importer (follow-ups)
Closes the user-facing surface for the two #328 follow-ups previously
landed in code form (presigned route + S3 mode notes), plus the schema
migration that backs both #328 and #327 follow-ups.

Migration 083
- events.allow_presigned_download — per-event opt-in for the
  presigned-URL "Download All" path. Off by default because it bypasses
  watermarks; admins flip it knowingly. Mutually exclusive with
  watermark_downloads.
- webhooks.filter (jsonb default {}) — dot-path equality predicate
  evaluated at fire time. Empty object = no filter, fire always.
  Backs the filter logic that shipped with #327.
- webhooks.template (text nullable) — optional ${dot.path} string
  substitution applied at delivery time. NULL = use the default JSON
  envelope (back-compat). Backs the template logic from #327.

S3 prefix walker (services/s3AutoImporter.js)
- Replaces the chokidar file-watcher in S3 mode (where there's no
  inotify equivalent on remote objects).
- Polls every active event's S3 prefix every 5 min by default
  (STORAGE_AUTO_IMPORT_INTERVAL_MS overridable).
- Eventual-consistency gate: an object is only imported after it's
  been seen for two consecutive polls. Avoids flapping when S3 returns
  a freshly-uploaded object that disappears on the next list (a
  documented S3 behavior on certain backends).
- Skips generated artifacts (thumb_*, hero_*, dot-files).
- Inserts photos rows + fires photo.uploaded webhooks the same way
  the local fileWatcher does.
- Opt-in via STORAGE_AUTO_IMPORT=true. Off by default because it adds
  API call cost.

EventDetailsPage UI (frontend)
- Round D queryKey alignment for #325 dedup — replaces useQuery on
  publicSettingsService with the shared usePublicSettings() hook so
  the page joins the same React Query cache as every other consumer.
- Per-event "Allow direct S3 download (no watermark, S3 mode only)"
  toggle in Download Protection. Disabled when watermark_downloads is
  on; tooltip explains the bandwidth/watermark trade-off. Toggling
  watermark_downloads on automatically clears allow_presigned_download
  to keep the two mutually exclusive in the UI.

Verified live against MinIO
- Presigned: GET /api/gallery/.../download-all → 302 with
  Location: http://minio:9000/...?X-Amz-Signature=...&X-Amz-Expires=300.
  Following the URL inside the docker network → HTTP 200, valid
  PK ZIP archive containing the photo.
- Auto-importer: dropped a file via `mc cp` directly into the bucket;
  watcher imported it after 2 polls; webhook subscribed to
  photo.uploaded fired with source=s3-auto-import; receiver got POST
  with valid HMAC, status=success, 3ms latency.
2026-04-28 10:08:21 +02:00
Paul Nothaft c488f481ca feat: outbound webhooks for event/photo lifecycle (#327)
PicPeak POSTs lifecycle notifications to admin-configured URLs. Each
delivery is signed HMAC-SHA256 in the X-PicPeak-Signature header.
Verified end-to-end: 1/1 Playwright spec, 8/8 backend integration
tests, full UI click-through via Chrome DevTools.

Schema (migration 082)
- webhooks: id, name, url, secret (plaintext — required to compute HMAC
  for every outbound POST), secret_preview, events[], active, filter,
  template, created_by, timestamps, last_success_at/last_failure_at.
- webhook_deliveries: webhook_id (FK CASCADE), event_type, payload,
  attempt_count, status (pending|success|failed), response_status,
  response_body (truncated to 1KB), latency_ms, next_retry_at,
  last_error, created_at, completed_at. Composite index
  (status, next_retry_at) serves the worker's hot-path query.

Service + worker
- webhookService.fire(eventType, data) — non-throwing entry point used
  by lifecycle hooks. Looks up active webhooks subscribed to the event
  and applies their per-webhook filter (dot-path equality predicate)
  before enqueueing one webhook_deliveries row per match. Filter and
  template logic ship in this commit; admin surfaces in the follow-up.
- webhookDeliveryWorker — setInterval(5s) poller; fetches up to 5
  pending rows; per delivery: re-validates URL via networkValidation
  (DNS-rebinding mitigation, opt-out via WEBHOOK_ALLOW_PRIVATE_URLS),
  signs body with HMAC-SHA256, POSTs with 10s timeout, records outcome.
  Backoff schedule: 1m → 5m → 30m → 2h → 12h, max 5 attempts. Response
  body truncated to 1KB before storage. If a webhook has a template,
  the rendered string replaces the JSON envelope as the request body
  (signature is computed over the bytes actually sent).

Lifecycle wiring
- adminEvents.js POST /events → event.created (+ event.published when
  not draft); POST /:id/publish → event.published.
- routes/events.js (legacy public POST) → event.created + event.published.
- routes/v1/events.js (#322 API) → event.created + event.published on
  create, photo.uploaded on photo POST.
- archiveService.archiveEvent() → event.archived. Per-photo
  photo.deleted intentionally NOT fired during cascade — receivers
  infer from event.archived to avoid flooding (issue spec).
- expirationChecker.handleExpiredEvent() → event.expired BEFORE the
  cascading archive (so receivers see expired→archived in order).
- adminPhotos.js — photo.uploaded on each batch row, photo.deleted on
  single + bulk delete.
- photoProcessor.js — photo.uploaded for guest uploads + auto-import
  (covers all entry paths).
- fileWatcher.js — photo.uploaded on add, photo.deleted on unlink
  (local mode only).

Admin endpoints (mirrors adminApiTokens.js pattern)
- /api/admin/webhooks: GET list, POST create (returns plaintext secret
  exactly once), GET :id, PUT :id, DELETE :id, POST :id/test (synthetic
  fire), GET :id/deliveries (paginated, filter by status), GET
  :id/deliveries/:deliveryId, POST :id/deliveries/:deliveryId/replay.

Frontend
- Settings → Webhooks tab (mirrors API Tokens layout): name + URL +
  event checkboxes + "Advanced" expander for filter (JSON) and template.
  Plaintext secret shown once on creation with a Copy button. Active/
  Disabled toggle button per row.
- /admin/webhooks/:id/deliveries — operational debug surface. Table
  with timestamp/event/status/attempts/HTTP/latency. Status filter chips
  (all/pending/success/failed). Row click → slide-over with payload +
  signature + response body. Replay button on failed rows. Send-test-event
  dialog. Auto-refresh every 10s.

Dev infrastructure
- dev/webhook-receiver/ — tiny node:alpine HTTP server (~100 LOC) that
  records every POST to an in-memory ring buffer. Exposes GET /requests
  for the E2E spec to assert deliveries landed with the right HMAC.
  Sibling pattern to MinIO. Reachable from the backend at
  http://webhook-receiver:8888 inside the picpeak network.

Tests
- backend/__tests__/integration/webhookDelivery.test.js (8/8) —
  signature verification, headers, retry/backoff, max-attempts → failed,
  response truncation, disabled-mid-flight, SSRF block, start/stop
  idempotency.
- tests/e2e/webhooks-roundtrip.spec.ts (1/1) — create webhook → trigger
  event.published → assert receiver got POST with valid HMAC → visit
  deliveries page → row visible with status=success → API test event →
  API replay → disable webhook → assert no new delivery.

Docs
- README §"Webhooks" — event catalog, payload shape, HMAC verification
  in Node + Python + bash, retry semantics, SSRF protection.
- .env.example — WEBHOOK_ALLOW_PRIVATE_URLS, WEBHOOK_DELIVERY_INTERVAL_MS,
  WEBHOOK_DELIVERY_CONCURRENCY, WEBHOOK_HTTP_TIMEOUT_MS,
  WEBHOOK_MAX_ATTEMPTS.

Out of scope for v1 (per issue): webhook templates' code-eval (the
${dot.path} substitution that ships is pure string replacement, no
expression engine — see follow-up commit), per-webhook rate limiting
beyond the global concurrency cap, synchronous "ask before delete"
webhooks.

Spanning files
- App.tsx pulls in this commit with both the AnalyticsBootstrap
  (#325 dedup) and the WebhookDeliveriesPage route registration.
  Splitting via git add -p was forfeit for sanity; the single 92-line
  diff is honest about both contributions.
- adminEvents.js diff bundles the webhook fires AND the
  allow_presigned_download field plumbing (#328 follow-up). Same
  reasoning.
- The new webhookService/Worker/adminWebhooks files include the filter
  and template logic from the follow-up — they were authored in one
  pass; splitting them post-hoc would have produced fragile partial
  files. The follow-up commit covers the migration and the UI for these.
2026-04-28 10:07:39 +02:00
Paul Nothaft 1b717ce5ed feat: native S3 storage backend (#328) + presigned download follow-up
Lets PicPeak write photos, thumbnails, hero images, watermarks, and
archive zips to any S3-compatible bucket (AWS S3, MinIO, Cloudflare R2,
Backblaze B2, Wasabi, DigitalOcean Spaces) instead of the local
filesystem. Selected via STORAGE_BACKEND=local|s3.

Architecture
- backend/src/services/storage/StorageBackend.js — abstract interface
  (put/get/exists/stat/delete/list/copy/rename/signedUrl/putFromFile/
  getToFile) — typedef-only, documents the contract.
- LocalFsStorage.js — wraps fs with atomic-write-via-tmp-rename, path
  traversal protection, list-as-walker.
- S3StorageBackend.js — thin wrapper around the existing
  S3StorageAdapter (used by backupService) mapping it onto the canonical
  interface; supports optional STORAGE_S3_PREFIX namespace.
- index.js — factory selected by STORAGE_BACKEND with startup ping
  (HEADs sentinel key on S3, fs.stat on local) so misconfig fails fast
  before the first request.

Consumer refactors (~12 services + routes), each parametrized over the
abstraction:
- imageProcessor / videoProcessor — pipe Sharp/ffmpeg output through
  storage.put; expose withLocalCopy() helper for S3-mode regeneration
  paths that need a local file for sharp/ffmpeg.
- archiveService / downloadZipService — finalize zip in tmp dir, then
  storage.putFromFile. Atomic-rename pattern preserved on local; S3
  emulates via copy + delete (worker prunes orphaned .tmp.* on startup).
- photoProcessor / photoReplacementService / adminPhotos upload+delete /
  routes/v1/events.js POST /events/:id/photos / routes/events.js — every
  upload path now goes storage.putFromFile(temp) → unlink temp.
- gallery.js bulk-download (cached + on-the-fly + selected) — managed
  photos via storage.get, external-mode unchanged.
- protectedImages / secureImages / photoResolver — read via
  storage.get; resolvePhotoStorageKey returns the canonical key.
- watermarkService / watermarkGeneratorService — persistent watermarks
  via storage.put.
- fileWatcher — bails out with a clear log warning when STORAGE_BACKEND=s3
  (chokidar can't watch S3); auto-import lands via the S3 prefix walker
  introduced in the follow-up commit.
- expirationChecker — small touch (event.expired webhook fire from #327
  shipping in the next commit).

Migration tooling
- backend/scripts/migrate-storage.js — one-shot --dry-run capable script
  that walks photos.path, thumbnail_path, hero_path, watermark_path and
  events.archive_path/download_zip_path; streams local → S3; sha256
  size-match skip for idempotent re-run; failures CSV.

Presigned-URL "Download All" (#328 follow-up shipped in this commit)
- routes/gallery.js — when STORAGE_BACKEND=s3 + event.allow_presigned_download
  + downloads enabled + watermark NOT enabled, /download-all returns a
  302 redirect to a 5-minute presigned S3 URL. Per-event opt-in surface
  ships in the next commit's UI.

Tests
- backend/__tests__/integration/storageBackend.test.js — parametrized
  contract suite running against BOTH LocalFs AND MinIO (18 tests, both
  backends — 36 cases total).
- backend/__tests__/integration/imageProcessor.storage.test.js — same
  parametrized pattern for the image processor (10 tests × 2 backends).
- backend/__tests__/integration/backup-s3.test.js — bootstrap fix:
  drop the redundant initDb() (001_init handles it) and remove
  schema-drift in configureS3Backup (app_settings has no created_at
  anymore and the unique constraint is on setting_key alone, not
  composite). 0/12 → 7/12 (5 remaining are unrelated assertion drift).
- backend/src/services/photoResolver.js — mixed-source events (reference
  mode with managed-uploaded photos) now fall back to managed when
  external_relpath is missing instead of throwing.
- tests/e2e/s3-storage-roundtrip.spec.ts — Playwright spec that
  auto-skips against local backend; full upload → serve → delete
  round-trip when run against an S3-mode backend.

Server wiring (server.js)
- initStorage() called after database init, before rate limiters.
- This commit's diff also includes the webhook delivery worker startup
  and the S3 auto-importer startup. Those features ship in the next two
  commits — co-located here for one bisectable diff per file.

Docs + ops
- README §"Storage Backends" — capability matrix, switching playbook,
  IAM policy snippet, MinIO/R2/B2 examples.
- README §"Webhooks" — also added here (full diff bundled).
- .env.example — STORAGE_BACKEND + STORAGE_S3_* + STORAGE_AUTO_IMPORT
  documented; WEBHOOK_* added in the same diff.
- .gitignore — re-anchor the existing `storage/` rule to `/storage/`
  so backend/src/services/storage/ (the new abstraction code) is
  trackable. The runtime ./storage/ data dir stays ignored.

Out of scope for v1 (per the issue): presigned URLs for individual
photo display (always streamed for protection middleware), CDN
integration, hybrid hot/cold tiers, S3 → local migration, multi-bucket
per-event.
2026-04-28 10:06:36 +02:00
Paul Nothaft 3d4ae4d7e9 feat(frontend): dedupe /public/settings via shared usePublicSettings hook (#325)
Pre-dedup: 7 calls to /api/public/settings on a single /admin/login page
load — 4 from raw-fetch consumers + 3 from React Query consumers using
inconsistent queryKeys. Captured live in Chrome DevTools.

Post-dedup: 1 call. Verified by tests/e2e/public-settings-dedup.spec.ts.

Adds:
- frontend/src/hooks/usePublicSettings.ts — single React Query hook,
  60s staleTime, queryKey ['public-settings']. Vitest with mocked api
  proves multi-mount dedup.
- Extended PublicSettings interface with seo_meta_* fields used by
  RobotsMetaTags and branding_logo_* fields used by GalleryView/AdminHeader.

Migrates 19 call sites across 4 risk-ordered rounds:
- Round A (high fan-in): GlobalThemeProvider, MaintenanceContext (with
  refetchInterval to preserve maintenance polling), MaintenanceWrapper
  (drops the now-redundant per-route ping; axios interceptor already
  handles 503), AdminHeader.
- Round B (gallery/login): GalleryView, GalleryPage, ClientAccessPage,
  AdminLoginPage, MaintenanceMode.
- Round C (decorative): RobotsMetaTags, DynamicFavicon, CMSContentBlock,
  ReCaptcha, useWatermarkSettings (rips out raw fetch + local state),
  LegalPage.
- Round D (queryKey alignment): useLocalizedDate, UserPhotoUpload,
  CreateEventPage. EventDetailsPage Round D ships in the follow-up
  commit that adds presigned-download UI on the same page.

App.tsx (AnalyticsBootstrap) and EventDetailsPage are deferred to
later commits — both files mix #325 changes with backend feature work.
2026-04-28 10:01:53 +02:00
Paul Nothaft 2794ed6722 Merge pull request #330 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m36s
Build and Push Docker Images / build-frontend (push) Failing after 3m35s
Build and Push Docker Images / summary (push) Successful in 2s
chore(beta): release 3.30.0-beta.0
v3.30.0-beta.0
2026-04-27 23:53:57 +02:00
github-actions[bot] ca0e48eb68 chore(beta): release 3.30.0-beta.0 2026-04-27 20:41:31 +00:00
Paul Nothaft 11de7b65c9 Merge pull request #329 from the-luap/feat/post-319-fixes-and-features
Bugfixes, public API, CMS error pages, BMC sponsor link
2026-04-27 22:41:02 +02:00
Paul Nothaft 46bc894d91 docs: add Buy Me a Coffee badge + Support section
Adds a yellow Buy Me a Coffee badge to the header alongside the existing
License/Docker/Node/React badges, plus a small "Support the Project"
section above Acknowledgments with the standard BMC button image. Also
adds a link in the inline nav row at the top of the README so first-time
visitors can find it.

Link: https://buymeacoffee.com/theluap

Lightweight, opt-in support — explicitly notes that starring, sharing,
filing good bug reports, and opening PRs are equally welcome ways to
help if money isn't in the budget.
2026-04-27 22:38:00 +02:00
Paul Nothaft 038e84cae7 fix: dedupe parallel admin 401 redirects to /admin/login
Visiting /admin/dashboard while logged out caused a navigation storm:
the dashboard fires ~7 /api/admin/* queries on mount, each returns 401,
each axios interceptor call did `window.location.href = '/admin/login'`.

The path-based guard `currentPath.includes('/admin/login')` reads
`location.pathname` *synchronously* — but `location.href = …` is async,
so all 7 parallel handlers saw the still-old pathname and each fired a
fresh navigation. The browser logged 6+ ERR_ABORTED entries and the user
saw a flicker storm. Same shape would bite any admin page that fans out
queries on mount.

Add a module-level `adminLoginRedirectPending` flag set the moment we
kick off the first redirect; subsequent 401s in the same tick see it
and skip. Single navigation, clean transition to login.

Smoke spec 10-admin-redirect-loop locks the regression in by sampling
the URL across 5 ticks — if any tick lands somewhere other than
/admin/login, the spec fails.
2026-04-27 22:38:00 +02:00
Paul Nothaft 2eead52319 fix: theme picker buttons no longer submit the parent form (#326)
Every <button> inside ThemeCustomizerEnhanced was bare — no `type`
attribute, defaulting to `type="submit"`. Inside CreateEventPage's
<form onSubmit={handleSubmit}>, that turned every theme/layout/header/
divider/control/colour-mode/CSS-template click into a form submission.

When the form was empty, validation killed the submit silently — that
showed up earlier as #317.2 ("theme picker unclickable").

When the form was filled (event_name set, etc.), validation passed,
`createMutation.mutate(payload)` ran, and the user was navigated to a
freshly-created event they never asked for — #326's reported symptom.

Fix: add `type="button"` to all 9 unmarked <button>s in the customizer.
Also covered by smoke spec 09-create-event-no-instant-submit which fills
the form, clicks Modern Masonry, and asserts the URL stays on
/admin/events/new and the events count is unchanged.
2026-04-27 22:38:00 +02:00
Paul Nothaft 808b15bafb feat: public v1 API + token management + OpenAPI docs (#322)
Adds a long-lived bearer-token mechanism + scoped REST surface designed
for n8n-style automation: create a gallery, upload photos, fetch the
share URL — all via documented HTTPS endpoints instead of poking at the
admin UI's internal routes.

API
- Migration 081 adds `api_tokens` (hashed_token, scopes, owner FK,
  last_used/expires/revoked timestamps).
- New apiTokenAuth middleware: parses `Authorization: Bearer pp_live_…`,
  resolves to the owner admin user, attaches `req.admin` so existing
  permission decorators (events.create etc.) still work. Token-level
  scope check (read/write/admin) layers on top as defence in depth —
  a leaked read-only token cannot mutate even if its owner is super_admin.
- adminApiTokens route exposes list/create/revoke for admins (cookie-
  authed). Plaintext token is returned exactly once on creation.
- v1 surface mounted at /api/v1: POST/GET /events, GET /events/:id,
  POST /events/:id/photos (multipart, single file), GET
  /events/:id/share-link. Each endpoint annotated with @openapi JSDoc.

Documentation
- swagger-jsdoc + swagger-ui-express produce a live spec at
  /api/openapi.json and a Swagger UI at /api/docs (admin-gated).
- backend/scripts/generate-openapi.js writes docs/openapi.{json,yaml}
  to the repo so the spec is versioned.
- scripts/sync-api-docs.sh runs in pre-push: regenerates the spec and
  copies it into the picpeak-docs Nextra site at app/api/. Writes only,
  never commits or pushes the docs repo (PUSH_SKIP_DOCS=1 to bypass).

Frontend
- New Settings → API Tokens tab: generate, list, revoke. Plaintext
  tokens are shown once with a copy-to-clipboard control.
2026-04-27 22:38:00 +02:00
Paul Nothaft be6cb28c80 feat: optional customer phone field gated by global toggle (#322)
Adds a `customer_phone` column on events plus an `event_phone_field_enabled`
admin setting (default off) that surfaces the input in the create-event
and event-detail forms. Designed for downstream automation tooling — once
exposed via the upcoming public API, n8n / similar can pick it up to
deliver gallery links over WhatsApp, SMS, etc.

- Migration 080 adds the column + seeds the setting as false. Existing
  deployments see no UI change unless the admin opts in via
  Settings → Events.
- Backend strips the field server-side when the toggle is off (defence
  in depth against form bypass).
- Frontend renders the input only when the public-settings flag is true;
  always optional even then.
- publicSettings + EventSettings types extended; CreateEventPage and
  EventDetailsPage wired to read the toggle and submit the value.
2026-04-27 22:38:00 +02:00
Paul Nothaft 4f77905b87 feat: customisable 404 + gallery-not-found pages via CMS (#324)
The 404 catch-all and the "gallery not found" branches in GalleryPage
were hard-coded English strings on a default-themed background — the
one place where a white-labelled deployment leaked the PicPeak default
look. Pluggable now via the existing CMS Pages mechanism.

Backend:
- Seed two new default CMS pages: `not-found` and `gallery-not-found`,
  with sensible English/German copy admins can edit in /admin/cms.
- Add `cms_pages.logo_url` (nullable) for per-page logo override; online
  migration on existing deployments. Null falls back to the global
  branding logo.
- New per-page logo upload (POST /api/admin/cms/pages/:slug/logo) +
  clear endpoint (DELETE …/logo). Reuses the existing /uploads/logos
  storage location with a `cms-<slug>-` filename prefix.
- adminCMS PUT now accepts logo_url; publicCMS GET returns it.

Frontend:
- New <CMSContentBlock slug fallback> component renders the CMS page in
  the standard branded shell (logo precedence: page → branding → bundled
  default), with DOMPurified content and footer/legal links.
- App.tsx: `path="*"` catch-all routes through CMSContentBlock("not-found").
- GalleryPage: collapses the two "gallery not found" branches (invalid
  identifier + infoError archived/missing) into a single
  CMSContentBlock("gallery-not-found"), so admins can edit one source
  of truth.
- Admin CMS Page editor gains an "Upload Logo / Use site default"
  control per page; falls back to the page's own English title in the
  page list when no `legal.<slug>` translation is registered.
2026-04-27 22:38:00 +02:00
Paul Nothaft b63a8774c4 fix: theme-preset match loop ignores extra fields like logoUrl (#323)
The "which preset does this saved theme match?" loop in BrandingPage and
CreateEventPage was doing a full JSON.stringify equality on preset.config
vs the loaded theme. The previous #323 logo-preservation work means the
saved theme legitimately carries a `logoUrl` (and any other fields the
parent maintains), so the equality check would never match and the
preset summary fell back to "Custom Theme" / Classic Grid even when the
saved theme was structurally Dark Modern, etc.

Compare only on the preset's own keys instead. Surfaced by the new
smoke spec 07-branding-default-on-create-event which would otherwise
pass green against the broken state.
2026-04-27 22:38:00 +02:00
Paul Nothaft 793e410554 fix: floor password_changed_at when comparing against JWT iat
JWT `iat` has 1-second resolution; `password_changed_at` is stored with
sub-second precision. The previous comparison rejected tokens whose iat
fell in the same wall-clock second as a password change — e.g. a token
issued by an immediate re-login after a password reset, or by any
script-driven flow that resets and logs in in quick succession. Floor
the stored timestamp to whole seconds before comparing.

Caught while wiring up the local E2E suite: the seeder needed a
"set password_changed_at 10 s in the past" hack to avoid this race;
with the fix in place that hack is gone and the suite is naturally
deterministic.
2026-04-27 22:38:00 +02:00
Paul Nothaft 8d0fb8e157 chore: expose pid + uptime on /health for crash-detection monitors
Adds `pid` and `uptime` fields to the /health response so external monitors
(and the local E2E watchdog) can detect a silent process restart between
two checks — e.g. an unhandled rejection that crashes Node and Docker
quietly relaunches the container.

Also adds .gitignore patterns for a local-only E2E suite that lives in
tests/e2e/local/ on individual machines and is never pushed.
2026-04-27 22:38:00 +02:00
Paul Nothaft 822be9a9b2 fix: theme save without Live Preview, Branding default on new events, gallery loading flicker (#323, #321)
#323-A — Branding colour changes weren't persisting unless "Apply changes
immediately (Live Preview)" was checked. ThemeCustomizerEnhanced was
gating its `onChange` callback on `isPreviewMode`, but the parent
BrandingPage already gates global `setTheme()` on its own copy of that
flag — so the customizer's gate was double-gating and silently dropped
the new values from the parent state that Save reads from. Always
propagate `onChange`; let parents decide what's "live". Removed the now
no-op `isPreviewMode` prop and dropped the unused passers.

#323-B — Default theme set in Branding wasn't applied to new events.
CreateEventPage only inherited the event-type's recommended preset, with
'default' falling back to Classic Grid. Now reads `settings.theme_config`
on first load and uses it as the form's starting theme; the event-type
effect skips the generic 'default' so the Branding default sticks for
event types like "Other".

#321 — Visitors saw four sequential render states when opening a gallery
(full-page "Loading Gallery" → "publicly accessible — loading photos"
card → skeleton grid → real gallery). Extracted the skeleton into a
shared <GallerySkeleton/> and used it for both GalleryView's photos-
loading state and GalleryPage's gallery-info-loading + public-auto-login
phases. The "publicly accessible" interstitial is gone. Net: one
continuous skeleton from URL open until real photos render.
2026-04-27 22:38:00 +02:00
Paul Nothaft 63a6bfebce Merge pull request #320 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m41s
Build and Push Docker Images / build-frontend (push) Failing after 3m40s
Build and Push Docker Images / summary (push) Successful in 2s
chore(beta): release 3.29.1-beta.0
v3.29.1-beta.0
2026-04-26 22:52:44 +02:00
github-actions[bot] 3d5759738f chore(beta): release 3.29.1-beta.0 2026-04-26 20:52:07 +00:00
Paul Nothaft 2f2f405d9b Merge pull request #319 from the-luap/feat/prezip-and-photo-replace
fix: discussion #317 issues and #318 archive crash
2026-04-26 22:51:46 +02:00
Paul Nothaft 6cfff6f6a6 fix: address bugs and feature requests from discussion #317
- Share link: display and copy now use the absolute URL built from the
  current origin instead of the relative path stored in events.share_link.
  Added a Copy Link button to the events list (inline + dropdown).
- Detect dev tools default: event creation now reads the global
  enable_devtools_protection app setting instead of always falling back to
  the column default; admins who disable it globally get new events with
  it disabled too.
- Require password default: added a global "Require password by default"
  setting (event_default_require_password, default true), exposed via
  Settings -> Events. Create-event form initialises from it.
- Filter bar: added gallery_show_filter_bar setting and hide the search/
  sort row in the public gallery when off, or when the gallery has zero
  photos (fixes the empty-state UX from the screenshot).
- Theme picker unclickable on Create Event: memoised availableEventTypes
  so its identity is stable. The "auto-apply event-type recommended
  preset" effect was firing on every render due to the unstable array
  reference and silently overwriting the user's preset selection ~1ms
  after each click.
- Branding logo disappearing on theme change: handlePresetChange and
  handleThemeChange no longer wipe the existing logoUrl when a preset
  config (which carries no logoUrl) is applied; handleSave falls back to
  brandingSettings.logo_url. themeMutation now invalidates the
  admin-settings and public-settings caches so saved theme changes appear
  immediately.
2026-04-26 22:48:59 +02:00
Paul Nothaft e4b0f961b7 fix: prevent backend crash on archive when admin_email is null (#318)
Archiving an event with no admin_email queued an email_queue row with
recipient_email=null, violating the NOT NULL constraint. The error was
thrown inside the output.on('close') callback (detached from the caller),
becoming an unhandled rejection that crashed Node and dropped admin
sessions on bulk archive.

- Skip queueEmail when event.admin_email is null/empty (admin_email has
  been nullable since migration 073).
- Wrap the close handler in try/catch so any post-archive failure logs
  instead of crashing the process.
2026-04-26 22:15:00 +02:00
Paul Nothaft c0989796e4 Merge pull request #315 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 4m3s
Build and Push Docker Images / build-frontend (push) Failing after 4m3s
Build and Push Docker Images / summary (push) Successful in 3s
chore(beta): release 3.29.0-beta.0
v3.29.0-beta.0
2026-04-23 16:59:00 +02:00
github-actions[bot] 82adcd1f71 chore(beta): release 3.29.0-beta.0 2026-04-23 14:54:21 +00:00