The root devDependencies still pinned an older Playwright (1.48.2)
plus a stray `dotenv` that nothing in the e2e suite or root scripts
actually requires (verified via grep across tests/). Updates the
Playwright version to match the current upstream stable and removes
the unused dotenv to keep the root install lean.
Originated from a local stash that picked up these changes; landing
them as a small dedicated commit so they don't blend into the auth
fix that follows.
Two further fixes for the gallery loading sequence shown in
@Rekoo-PS's frame breakdown on issue #358 — both about colours that
didn't track the active theme.
1. Initial white frame (frame f1)
The pre-React bootstrap script in #359 sets the cached background
on documentElement, but the browser may paint the very first frame
*before* that <script> tag runs (synchronous parse-time JS in the
<head> is still slightly later than CSS apply-time). On first-visit
dark-OS devices that meant a single white frame before the script
resolved.
Fix: move the OS-preference default into a <style> block that
precedes the script. CSS @media (prefers-color-scheme) is applied
before paint, so dark-OS devices land on dark from frame zero.
The script keeps the per-gallery cache hit on top, and now also
stamps the colour onto document.body in case the body element has
already mounted by the time the script runs.
2. "Most annoying" skeleton tile frame (frame f4)
Skeleton placeholders rendered as bright `bg-neutral-200` light grey
regardless of theme. On a dark gallery that's the highest-contrast
thing on screen during loading — the exact frame Rekoo-PS labelled
"the most annoying" in the issue.
Fix: the Skeleton component's background now reads
`var(--color-surface-border)`, which ThemeContext already wires up
per active theme (`#e5e5e5` light / `#2e2e2e` dark by default; per-
event themes can override). The bare `<div>` no longer carries any
colour utility class — the inline style supplies the active value.
Also dropped the leftover `bg-white` on SkeletonCard / SkeletonTable
in favour of `var(--color-surface)` for the same reason.
Tests:
New src/components/common/__tests__/Skeleton.test.tsx covers
- Skeleton uses var(--color-surface-border, ...)
- bg-neutral-200 is no longer present
- SkeletonGalleryGrid tiles all inherit the theme colour
- SkeletonCard surface uses var(--color-surface)
Two settings with overlapping names but different value sets were being
conflated:
- branding_logo_position (header bar, horizontal): 'left'|'center'|'right'
- hero_logo_position (hero block, vertical): 'top'|'center'|'bottom'
getBrandingDefaults() copied the global branding value over the per-event
hero value when seeding new events. Any admin with branding logo set to
'left' (the most common choice) created events with hero_logo_position
= 'left' written to the DB. Subsequent PUTs to /admin/events/:id then
failed validation with "Invalid value (field: hero_logo_position)" — the
validator only accepts top/center/bottom.
Fix:
1. Drop the bogus mapping. branding_logo_position is no longer read by
getBrandingDefaults — it doesn't belong there. The fallback default
('top') is used unless the request body explicitly provides
hero_logo_position, which is independently validated.
2. Migration 084_fix_hero_logo_position normalises any existing rows
whose hero_logo_position is outside ('top','center','bottom') back
to 'top'. Without this, affected events would continue to 400 on
every save until the admin manually picks a valid option.
Reproduction: admin sets branding logo position to 'left' under global
branding, creates an event, opens the event detail page, clicks Save
without changing anything → 400. After this fix, save succeeds and new
events default to 'top' regardless of branding-bar position.
Opening a gallery with a dark theme briefly painted a white background
between the initial HTML render and React applying the per-event theme.
The HTML shipped with no theme info, so the first paint used the
default (#fafafa) before /gallery/:slug/info resolved.
Two-part fix.
1. Inline bootstrap script in index.html runs synchronously before React
mounts. Reads the URL, looks up a per-slug background colour from
localStorage (gallery-theme-bg-<slug>), and applies it to
documentElement immediately. Falls back to #171717 when no cache
exists and the OS prefers dark, so first visits with dark OS still
land on a dark background.
2. ThemeContext.applyTheme writes the resolved background to
localStorage keyed by slug whenever a gallery theme loads. Revisits
then hit the bootstrap cache and never see a flash.
Added a 200ms transition on html.background-color so the rare
cache→API drift (e.g. theme palette changed admin-side since last
visit) is a smooth fade instead of a snap.
Limitation: first visit on a light-OS device to a dark gallery still
flashes once. Killing that case requires a server-rendered theme hint,
out of scope for an SPA bootstrap fix.
The empty-skeleton-grid part of the same report is already addressed
by the 300ms lazy render in #352 — Rekoo-PS just needs to update from
v3.32.1-beta.0 to v3.32.2-beta.0+.
The "Expires on" preview under the days-after-event input rendered
nonsense dates (e.g. 25.04.2026 + 120 days → 08.01.2095, ~68 years
out). Cause: handleInputChange stores e.target.value verbatim, which is
a string for <input type="number">, so formData.expires_in_days is "120"
not 120. date-fns addDays does:
_date.setDate(_date.getDate() + amount)
When amount is a string, the + is string concatenation:
25 + "120" = "25120". setDate("25120") then sets day-of-month to 25120,
which carries over by ~68 years.
Fix: cast to Number at the call site. The validation/API-payload
codepaths already work because the comparisons at line 330 and the
JSON payload coerce numerically through different paths — only addDays
was actually broken.
The TypeScript type FormData.expires_in_days: number is a lie because
handleInputChange's [field]: e.target.value sets a string regardless.
Tightening that handler is a separate cleanup; this commit only fixes
the visible date bug.
Asymmetric JWT verification was causing a /admin/login → /admin/dashboard
→ /admin/login redirect loop for users carrying admin cookies issued
before the iss: 'picpeak-auth' claim was added (commit 23cd9cb,
"address Shannon security assessment findings (37 vulnerabilities)").
The frontend uses GET /auth/session as the source of truth for "is the
user authenticated?". That endpoint called jwt.verify(token, JWT_SECRET)
with no issuer option, so it accepted pre-issuer tokens and reported
valid: true. AdminLoginPage then redirected to /admin/dashboard, every
protected endpoint went through adminAuth which DOES verify the issuer,
each one rejected the token with 401, the response interceptor
window.location.href'd back to /admin/login, and the loop closed.
Fix: pass { issuer: 'picpeak-auth' } to /auth/session's jwt.verify so it
matches adminAuth and galleryAuth. Tokens without the claim now correctly
return valid: false from the session check, AdminLoginPage shows the
login form, and a fresh login mints a properly-issued cookie.
The other intentionally-lax verify call sites (logout-flow logging,
photoAuth, sessionTimeout, rateLimit) are unrelated to the loop and stay
lax — their callers don't gate "authenticated?" decisions on the result.
Reproducer: open a removed/archived gallery URL with a stale admin
cookie from before the issuer claim was added, click "Back to home" on
the gallery-not-found page → loop.
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.
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.
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).
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
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.
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.
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.
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
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.
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.
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)`.
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.
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.
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.
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.