Commit Graph
100 Commits
Author SHA1 Message Date
Paul Nothaft 07f2c90055 fix(security): mask backup credentials on read + unblock MFA login during maintenance
Two pre-existing bugs surfaced while reviewing #806 (kept separate per
scope policy — no OIDC code here):

- backup_s3_secret_key and backup_rsync_ssh_key (an SSH PRIVATE KEY)
  were returned in PLAINTEXT by GET /admin/backup/config and by the
  generic settings reads (GET /admin/settings and /admin/settings/:type
  — which mask the recaptcha/umami/rybbit keys but not these). All
  three now mask with the established bullet sentinel, and
  PUT /admin/backup/config skips the sentinel on write so the edit form
  round-trips without clobbering stored credentials (same pattern as
  the email/WhatsApp config endpoints)
- /api/auth/admin/login/mfa was missing from the maintenance-mode
  allowlist: the first login step passed, the second factor got a 503 —
  any MFA-enrolled admin was locked out exactly while maintenance mode
  was on

Regression tests: masking on all three read paths, sentinel round-trip
preserves stored values, real rotation still writes.
2026-07-16 10:13:34 +02:00
Paul Nothaft b9283386a5 fix(gallery): show feedback filter chips on desktop for galleries without categories (#802)
The desktop feedback-filter chips (All/Likes/Saved/Rated/Commented)
were nested inside the categories row conditional, and the standalone
fallback block is lg:hidden — so a gallery without photo categories
(the default) rendered no feedback filter at all on desktop, despite
the docs and a fully working filter implementation behind it.

Render the row whenever either part has content and gate only the
category scroller on categories existing. The media-count label hides
below lg when no categories exist so the mobile layout stays unchanged
(mobile keeps its own chip block). With-categories galleries render
identically to before.

Regression test pins both chip groups in the DOM with and without
categories (fails on the pre-fix component).
2026-07-15 22:51:22 +02:00
Paul Nothaft 5da1c3a12f fix(event-types): un-hardcode event type dependencies in v1 API and CRM (#800)
Split out of #801 so the public-API behavior change gets its own review:

- v1 POST /events validates event_type against the live event_types
  catalog instead of the hardcoded whitelist — custom types created in
  Settings → Event Types were rejected with 400. BREAKING for the
  never-seeded 'family' slug, which the old whitelist silently accepted
  and wrote as a dangling reference; create a matching event type to
  keep using it
- new GET /api/v1/event-types (read scope) so API-token clients can
  discover valid slugs; OpenAPI enum replaced accordingly
- standalone contract→event conversion no longer hardcodes
  event_type: 'wedding' — it resolves via crm_default_event_type, then
  the catalog catch-all, same chain as quote→event conversion
- resolveDefaultEventType moved from quoteService to eventTypeService
  for shared use (no behavior change)
2026-07-15 22:31:28 +02:00
Paul Nothaft 93301002ba refactor: move v1 API + CRM event-type un-hardcoding to a follow-up PR
Keeps #801 scoped to the setup-wizard event-types feature and its
load-bearing guards. The v1 validator/discovery endpoint and the
contract-conversion default fix ship separately so the public-API
behavior change gets its own review weight.
2026-07-15 22:30:00 +02:00
Paul Nothaft f8ba669716 fix(event-types): harden setup window + catalog validation (codex review)
Three review rounds on PR #801; fixes in response:

- isValidEventType: live catalog is authoritative when it has rows — a
  deleted or deactivated slug no longer validates via the legacy
  fallback (fallback now only serves an empty-catalog install)
- deleteEventType: refuse deleting the last (and last ACTIVE) type;
  updateEventType: refuse deactivating the last active type (unknown
  slugs are rejected since the validator change, so an empty active
  catalog would brick event creation)
- setup window fails closed: only an explicit stored `false` opens it
  (a portable-backup restore can leave the key absent) and a normal
  admin login durably closes it (abandoned-wizard case)
- reserved bootstrap keys (setup_wizard_completed, setup_token) are
  stripped from ALL generic settings upserts (/general, /security,
  /analytics, /seo) so the marker is genuinely one-way
- wizard step: deletes ordered so the catalog can never end up empty,
  and a genuinely failed system-type deletion reloads the list and
  stays on the step instead of advancing past the only window in which
  it can be retried
- CreateEventPage: snap the hardcoded initial 'wedding' selection to
  the first active type when the catalog no longer contains it
- v1 API: new GET /event-types (read scope) so token clients can
  discover valid slugs; OpenAPI enum replaced with the live-catalog
  description
2026-07-15 22:21:20 +02:00
Paul Nothaft 00fff24a1c test(v1): stub eventTypeService in events.create suite + cover unknown-type 400
The catalog-backed event_type validator (#800) makes a db('event_types')
lookup before the handler runs, which consumed the first queued mock
chain and shifted the pinned db() call sequence — 5 tests failed on CI.
Stub isValidEventType to true (validation isn't this suite's subject)
and add an explicit test for the new 400-on-unknown-type path.
2026-07-15 21:36:50 +02:00
Paul Nothaft 7eb6357b4a feat(setup): event-types step in first-run wizard + un-hardcode event type deps (#800)
Fresh installs can now shape the event-type catalog during the setup
wizard — rename, delete or replace the seeded defaults while nothing
references them. On existing installs system types stay protected.

- New wizard step between features and config: edit name/URL prefix,
  remove, or add types; defaults shown as recommendations
- setup_wizard_completed app setting (migration 161): seeded true when
  an admin already exists, false on fresh installs; POST /api/setup/
  complete (adminAuth) flips it when the wizard finishes
- deleteEventType: system types deletable only while the flag is unset;
  in-use check extended to quotes; per-type reminder template
  (event_reminder_<slug>) is deleted with the type
- reminder-template self-heal no longer resurrects templates for slugs
  removed from the catalog
- v1 API event creation validates event_type against the live catalog
  instead of a hardcoded whitelist (custom types were rejected; the
  never-seeded 'family' slug is no longer silently accepted)
- contract→event conversion resolves the event type via
  crm_default_event_type / resolveDefaultEventType instead of
  hardcoding 'wedding' (resolveDefaultEventType moved from quoteService
  to eventTypeService for reuse)
2026-07-15 21:30:23 +02:00
Paul Nothaft 39db7bf6cb fix(ci): publish v-prefixed image tags via type=ref,event=tag (#668)
#783 added `type=semver,pattern=v{{version}}` to the merge-job metadata,
but metadata-action silently dropped it on prereleases — the 3.84.0-beta.0
build published only :3.84.0-beta.0 + :sha, not :v3.84.0-beta.0 (verified
in the merge-backend push log + GHCR: :v3.84.0-beta.0 → 404).

Replace the v{{version}}/v{{major}} semver patterns with type=ref,event=tag,
which emits the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0) for both
stable and beta tags — exactly the string users pin (matches the GitHub
release). Applies to both backend + frontend merge metadata steps.

Takes effect on the next release build. The bare :3.84.0-beta.0 tags stay
(the {{version}} patterns are unchanged), so both forms resolve.
2026-07-10 14:13:17 +02:00
Paul Nothaft b768a53c5b feat(slideshow): per-event play order + category filter (#202)
The Live Slideshow already covers the core of #202 (fullscreen kiosk,
live-appending new uploads, timing/transitions/watermark, per-event
opt-in via the share link). This adds the two customization dimensions
the reporter also asked for:

- **Play order** (show_order): 'chronological' (upload order, default) or
  'random' — the client shuffles the initial set (Fisher-Yates) so
  live-appended uploads keep working.
- **Category filter** (show_category_id): restrict the slideshow to a
  single photo category (NULL = all photos, default). Enforced
  server-side on the slideshow /photos access and mirrored in the
  /session + /state photo_count, so the kiosk viewer can't widen the set.

Per-event enable/disable (default off) is unchanged — it's the existing
'Generate/Disable slideshow link' flow (no token = no slideshow).

- Migration 158: show_order (default 'chronological') + show_category_id.
- Admin: Play-order dropdown + category picker in the Live Slideshow card
  (picker hidden for events without categories); EN + DE i18n.
- Verified: migration (SQLite + PG); live API (category filter → 3/2/5
  photos + matching count; order propagates) and the running kiosk
  requests exactly the filtered set; tsc clean, 136 backend tests pass.
2026-07-10 10:46:54 +02:00
Paul Nothaft 5dea0c9695 docs(releasing): align stable version to main on promote (Option A)
The two release-please tracks count independently — main bumps on every
merge, stable only on promotion — so they drifted far apart (main
v3.83.x-beta while stable sat at v3.45.0 for the same code). Document
the alignment convention: a promotion pins the stable version to main's
base version via a Release-As commit (new step 5 in the cut procedure),
so stable tracks main instead of lagging.

Also records the release-engineering note that release-please.yml must
keep target-branch: stable (the missing pin cut a bogus v2.7.0 once).
2026-07-10 10:03:30 +02:00
Paul Nothaft d3d7df46f2 feat(admin): GitHub repo button in the sidebar footer (#778)
Adds a subtle 'View PicPeak on GitHub' link in the admin sidebar footer
(next to the version/storage widgets), so admins can reach the repo —
star it, browse source, report an issue — from anywhere in the dashboard,
not just the setup screen.

- Centralizes the repo URL as `repoUrl` in utils/githubReleaseUrl.ts
  (githubReleaseUrl now derives from it) so the org URL lives in one place.
- target=_blank + rel=noopener noreferrer; EN + DE i18n
  (`admin.viewOnGithub`); dark-mode aware, matches the muted footer style.
2026-07-10 09:50:05 +02:00
Paul Nothaft 784d059c3d fix(ci): publish v-prefixed image tags so :vX.Y.Z resolves (#668)
docker/metadata-action's type=semver strips the leading 'v', so releases
published only :3.45.0 / :3.83.1-beta.0. But git tags + GitHub releases
are named v3.45.0, so anyone pinning ghcr.io/.../backend:v3.45.0 (the
obvious choice) hit 'manifest unknown' — exactly #664.

Add v-prefixed semver patterns (v{{version}}, v{{major}}.{{minor}},
v{{major}}) alongside the existing bare ones, for both backend and
frontend. Now both :v3.45.0 and :3.45.0 resolve.

Applies to future releases; the already-published v3.45.0 only has the
bare :3.45.0 tag (retagging past releases is out of scope).
2026-07-10 09:46:10 +02:00
Paul Nothaft 65ac6eddac fix(release): target stable in release-please.yml + undo the bogus 2.7.0 bump
The stable release-please workflow (release-please.yml, triggered on
push to stable) had no `target-branch`, so it defaulted to the repo
default branch (main) and computed the next version from main's stale
`.release-please-manifest.json` (2.6.1) — cutting a spurious **v2.7.0**
stable release (a version regression from 3.44.0) when #771 landed on
stable, and bumping main's package.json + manifest to 2.7.0.

- release-please.yml: add `target-branch: stable` so it releases from
  the stable branch (3.44.0 → 3.45.0), like release-please-beta.yml
  already pins `target-branch: main`.
- Restore main's version to 3.83.0-beta.0 (backend + frontend
  package.json), set `.release-please-manifest.json` to 3.44.0, and drop
  the bogus 2.7.0 CHANGELOG section.

The v2.7.0 tag/release is deleted separately; the real v3.45.0 stable is
cut by re-running release-please on the stable branch after this lands.
2026-07-09 11:39:16 +02:00
Paul Nothaft 80503c52b9 ci: run the Tests workflow on stable-targeted PRs
tests.yml (the backend/frontend Jest+Vitest jobs) only triggered on
main/beta, but those two jobs are required status checks on the stable
branch. A beta→stable promote PR therefore hung forever on
'Expected — Waiting for status to be reported' for backend/frontend,
while docker-build / install-smoke / schema-drift (already listing
stable) ran fine. Add stable to the push + pull_request filters so the
Tests suite runs on promote PRs too.
2026-07-08 20:29:19 +02:00
Paul Nothaft 945026d446 fix(admin): stop the event-date field crashing the page on backspace
Repro: create an event, click into the date field, backspace a day digit.
The whole page white-screened and needed a reload.

Root cause: LocalizedDateInput's `toIso` only checked the day/month were
1-2 digits, not that they formed a real date — so a mid-backspace value
like "0/07/2026" was coerced to the string "2026-07-00" and committed to
`event_date`. CreateEventPage then rendered
`format(addDays(new Date('2026-07-00'), days))`, and date-fns `format`
throws RangeError on an Invalid Date — thrown during render, so React
tore the tree down to the error boundary.

Two complementary fixes:
- `toIso` round-trips the parsed y/m/d through `Date` and rejects
  impossible dates (day 00, month 13, 31 Feb…), so the field never
  commits a value that isn't a real calendar date.
- `useLocalizedDate.format`/`formatDistanceToNow` guard with `isValid`
  and return '' instead of throwing — defence in depth for the ~57 call
  sites that could otherwise white-screen on a bad date.

Verified live: backspacing to a partial/invalid date no longer crashes
(the form stays rendered), a valid date still commits + the expiry
preview renders. Adds a LocalizedDateInput regression test; tsc + build
green.
2026-07-07 08:56:04 +02:00
Paul Nothaft 60b03b1728 fix(branding): unify hero logo SIZE the same way as visibility (#756)
Follow-on to the visibility fix in this PR — hero_logo_size had the same
split-brain: GalleryLayout read the global branding_logo_size live while
the hero-header path used the per-event snapshot, so a hero logo could
render at different sizes on different layouts and the global size didn't
reach hero-header galleries.

Now mirrored on the visibility model: NULL per-event hero_logo_size =
inherit branding_logo_size; explicit = override.

- Migration 153: hero_logo_size nullable + backfill NULL so existing
  galleries inherit the global size (restores GalleryLayout's prior
  live-global behaviour and fixes the hero-header staleness).
- Creation stores NULL unless explicit; gallery.js resolves
  per-event ?? global and sends the effective size.
- GalleryLayout now consumes that resolved size for the hero logo (new
  heroLogoSize prop) instead of the global — both render paths match.
- Admin size control gains a 'Use branding default' (inherit) option.

Verified: migration on SQLite + PG; live resolution (inherit follows
global both ways, override wins); creation stores NULL on PG; tsc clean,
106 adminEvents+gallery tests pass, build green.
2026-07-06 11:44:33 +02:00
Paul Nothaft 96fe478bf8 fix(branding): make 'Show logo in hero' a true global toggle with per-event override (#756)
Before: the global branding_logo_display_hero toggle was only a
creation-time default — snapshotted into each event's hero_logo_visible
column at creation and never consulted again. Disabling it did nothing
to existing galleries (the reporter's bug), and the two gallery render
paths disagreed (GalleryLayout read the global, HeroHeader read the
per-event snapshot).

Now: NULL per-event hero_logo_visible = 'inherit the global toggle';
an explicit true/false is a per-gallery override.

- Migration 152: make events.hero_logo_visible nullable and NULL out the
  defaulted  rows so existing galleries follow the global going
  forward. Deliberate per-gallery hides () are preserved.
- Creation stores NULL unless the admin explicitly sets it; the update
  path preserves NULL.
- gallery.js resolves per-event ?? global (branding_logo_display_hero,
  default true) and sends the EFFECTIVE value on both gallery responses.
- Both frontend render paths now consume that resolved value
  (GalleryLayout gets it via a new heroLogoVisible prop).
- Admin per-event control is now tri-state: Use branding default /
  Always show / Always hide (en + de).

Verified: SQLite migration + live resolution (inherit follows global
both ways; override wins both ways); PG migration SQL dry-run; the admin
tri-state renders 'Use branding default' for an inherited event; tsc
clean, 106 adminEvents+gallery tests pass, build green.
2026-07-06 11:20:36 +02:00
Paul Nothaft a0a28a4777 fix(og): broaden social-crawler coverage (Bluesky Cardyb, WeChat-scraper, fediverse, etc.)
From alexvaltchev's field UA list on #699. Adds CRAWLER-EXCLUSIVE tokens
to both the nginx UA regex and SOCIAL_CRAWLER_PATTERNS (kept in sync):
Cardyb (Bluesky's actual link-card fetcher), facebookcatalog, Signal,
Misskey, Pleroma, Synapse, Nextcloud, Rocket.Chat, kakaotalk-scrap,
Google-PageRenderer, OdklBot, ZoomBot.

Deliberately NOT added: UAs shared with real human in-app browsers
(WeChat MicroMessenger, LINE 'Line/', Zalo) and broad strings
('InAppBrowser', 'preview', 'unfurl', 'XING' → matches 'boxing'). Our OG
response is meta-only with no redirect, so matching those would serve a
human the bare stub. New negative test locks that exclusion in.

Verified: nginx -t passes; live harness confirms the new tokens rewrite
to /og while the in-app-browser UAs still get the SPA. Backend suite 15/15.
2026-07-05 23:37:16 +02:00
Paul Nothaft 0dffe0ce92 fix(og): route branded short URLs + slideshow links to OG, add Viber (#699)
Follow-up to #699/#700/#702 — the OG SSR handler existed but three link
shapes never reached it behind the frontend nginx:

- Branded short URLs (/s/<slug>, #702) had NO nginx location, so they fell
  through to the SPA — which has no /s/ route. Dead for humans (no 302
  redirect) and crawlers (no OG). Add an ^~ /s/ proxy to the backend, whose
  /s/:shortSlug route already handles both.
- Slideshow links (/gallery/<slug>/show/<token>) have TWO extra path
  segments; the crawler-detect location regex allowed only one, so they
  never rewrote to /og and got generic site-wide OG. Widen to {0,2} extra
  segments (quoted regex — the braces would otherwise be parsed as nginx
  config delimiters). client-access still matches (its token is in ?query,
  one path segment).
- Viber's preview fetcher wasn't in either UA list, so Viber shares showed
  no preview. Add it to nginx + SOCIAL_CRAWLER_PATTERNS (kept in sync).

Verified end-to-end: nginx -t passes; a live nginx+mock-backend harness
confirms /s/ proxies to the backend, slideshow + Viber + share-token +
client-access crawler UAs all rewrite to /og/gallery/<slug>, and browsers
still get the SPA. Backend isSocialCrawler test extended for Viber.
2026-07-05 21:19:53 +02:00
Paul Nothaft dadaaeea77 feat(setup): final community/thank-you step (#732); fix create-admin button overflow (#730)
#730 — the account step's Create-admin button shared a flex row with Back;
its label + loading spinner exceeded the card width, so the button
overflowed the card outline while submitting (and was fragile for longer
i18n labels). Stack both buttons full-width — the primary always has room
for the spinner now, matching every other wizard step.

#732 — add a final 'community' step, shown once on first-run after
config / no-config, before entering the app. Mission line + four link
cards (report a bug, request a feature, star/share, Buy Me a Coffee),
all target=_blank rel=noopener, and a Finish → Dashboard button. Fully
i18n (en + de). Restore keeps its reload flow (a restored instance is no
longer first-run, so it never reaches this step). Adds .github/FUNDING.yml
so GitHub renders a Sponsor button too.

Verified live: drove the real first-run wizard end to end — stacked
account buttons render inside the card, community step shows the mission
+ all four links, Finish lands on the dashboard.
2026-07-03 12:29:58 +02:00
Paul Nothaft f564b38c5a Merge remote-tracking branch 'origin/main' into refactor/codebase-cleanup
# Conflicts:
#	backend/src/routes/adminEvents.js
#	backend/src/routes/protectedImages.js
#	frontend/src/pages/admin/EventDetailsPage.tsx
2026-07-03 11:54:01 +02:00
Paul Nothaft 96e3c68b9d feat(admin-ui): TOTP MFA enrollment + two-step login; remove stub 2FA toggle
Frontend for #738.
- mfa.service.ts + MfaSettingsCard (Settings → General → Admin Account):
  per-user setup (QR + manual secret + verify), recovery codes shown once
  (copy/download/confirm), status, regenerate, disable. Renders for
  super_admin (closes #735).
- Two-step login in AdminLoginPage: on {mfaRequired,mfaToken} swap to a
  code step (TOTP or recovery), call /auth/admin/login/mfa; handle
  MFA_INVALID / MFA_SESSION_EXPIRED / 423 lockout.
- Removed the non-functional global enable_2fa checkbox from SecurityTab
  (and its persistence) — replaced with a note pointing to per-user setup.
- en + de i18n.

Verified live in-browser: enroll (QR→code→recovery codes), logout, and
the two-step challenge into the dashboard as super_admin.
2026-07-03 11:44:07 +02:00
Paul Nothaft cdbfb514bd test(auth): MFA unit + route + CLI coverage (39 tests)
mfaService unit (encrypt/decrypt, TOTP, single-use recovery, isEnrolled),
adminMfa HTTP (enroll/challenge/verify/recovery/disable; super_admin
enrollment guards #735), and reset-admin-mfa.js CLI.
2026-07-03 11:37:51 +02:00
Paul Nothaft 72e2ef6721 feat(auth): admin TOTP MFA — enrollment, login challenge, recovery, CLI reset
Backend for #738. Real TOTP 2FA for admin accounts, all roles incl.
super_admin (closes #735).

- mfaService: otplib TOTP; AES-256-GCM encryption of the secret at rest
  (key derived from MFA_ENCRYPTION_KEY or JWT_SECRET); bcrypt-hashed,
  single-use recovery codes; otpauth URI + QR.
- Migration 151: adds two_factor_recovery_codes + two_factor_enrolled_at
  (secret/enabled columns already existed from legacy 016).
- Enrollment endpoints (behind adminAuth, per-user): GET /mfa/status,
  POST /mfa/{setup,enable,disable,recovery-codes}. Disable/regenerate
  require a current code so a hijacked session can't strip 2FA.
- Login challenge: /admin/login returns {mfaRequired, mfaToken} (no
  session) when 2FA is on; /admin/login/mfa exchanges a TOTP or recovery
  code for the session. Lockout counter is NOT reset until the second
  factor passes, so MFA brute-force is rate-limited too.
- CLI break-glass: scripts/reset-admin-mfa.js --email <e> | --all --yes,
  audit-logged, matches reset-admin-password.js convention.
- Docs + optional MFA_ENCRYPTION_KEY env.

Verified end-to-end on a live backend: enroll (super_admin), challenge,
TOTP + single-use recovery login, disable, and CLI reset.
2026-07-03 11:33:38 +02:00
Paul Nothaft 081f3edcdf fix(security): close cross-event thumbnail leak, bulk-op ownership bypass, + hardening
Auth/access-control audit fixes (all pre-existing on main; none are
regressions). Verified end-to-end where noted.

HIGH
- Thumbnail enumeration: photoAuth granted any gallery token access to any
  flat /thumbnails/thumb_* file, so a visitor to one gallery could
  enumerate another (password-protected) gallery's entire thumbnail set.
  Scope thumbnail access to the token's event via photos.thumbnail_path.
  Live-verified: cross-event fetch now 404s, own-event still 200s.
- Bulk ownership bypass: bulk-archive/bulk-delete acted on body-supplied
  event ids with no owner filter (single-event routes enforce
  requireEventOwnership), letting admin/editor archive or cascade-delete
  any event. Add filterOwnedEventIds; also guard rename + import-external;
  tighten photo-retry to scope admin (not just editor). Fix misleading
  bulk-delete comment.

MED
- verifyGalleryAccess never checked decoded.type — assert 'gallery'
  instead of relying on other token types incidentally lacking eventId.
- secure-images generate-token/secure-download missing denySlideshowToken
  (#646 bypass): a leaked slideshow token could download originals.
- Frontend: AuthenticatedImage + api.ts attached the gallery bearer token
  to absolute/external URLs — only attach to relative same-app paths.

LOW hardening
- Pin algorithms:['HS256'] on all auth-boundary jwt.verify calls.
- crypto.timingSafeEqual for share-token + HMAC compares (utils/timingSafe).
- Remove dead photoAuth import in galleryFeedback.

Tests: new regression suites for thumbnail scoping + filterOwnedEventIds;
fixed verifyGalleryAccess.customerRevoke fixture (real customer tokens
carry type:'gallery'). Full backend suite at the pre-existing baseline
(5 suites/27 tests fail on main too), zero new failures.
2026-07-03 10:27:28 +02:00
Paul Nothaft 766351b588 fix: mirror #734 onto decomposed files (PG NaN slideshow seed, SQLite bool renders)
Same two pre-existing-on-main bugs, at their post-decomposition
locations: clampIntOrUndefined in adminEvents/crud.js slideshow seed;
!! coercion in EventDetailsHeader, EventInformationCard,
ClientAccessCard. Keeps this branch correct in either merge order with
#734 — when merging main afterwards, resolve the adminEvents.js
modify/delete conflict by keeping the deletion.
2026-07-03 09:01:01 +02:00
Paul Nothaft 760c3d7b67 fix(admin): stray literal "0" rendered from SQLite integer booleans
On SQLite deployments boolean event columns come back as 0/1, and
{event.is_draft && ...} renders the 0 as a literal text node. Visible on
the event details page in three spots: above the tab bar (is_draft),
in the download-protection badge row (disable_right_click /
enable_devtools_protection / watermark_downloads), and in the Client
Access card (client_access_enabled). Coerce with !! at the render sites.
2026-07-03 08:57:48 +02:00
Paul Nothaft 8c86518aad fix(events): NaN from slideshow seed breaks event creation on PostgreSQL
The create route seeds show_interval_ms/show_transition_ms from
app_settings through an inline guard that pre-checked Number.isFinite(+v)
but then used parseInt(v). The two disagree for null/''/true — +null is 0
(finite) while parseInt(null) is NaN — so when the slideshow settings rows
are absent (getAppSetting returns its null default), NaN flowed through
Math.min/Math.max into the INSERT. PostgreSQL rejects NaN for integer
columns; SQLite silently stores NULL, which is why every SQLite-based
test passed while POST /api/admin/events 500'd on the PG dev stack and
broke the e2e smoke suite.

Fix: parse first, then check — clampIntOrUndefined in utils/numericHelpers
(unit-tested against every failure-mode input). Verified end-to-end: the
previously-failing minimal create now succeeds against the PG dev stack.
2026-07-03 08:57:48 +02:00
Paul Nothaft 2ea26a4962 fix: conform moved code to eslint indent/quotes, 4-arg mutation callbacks
- eslint --fix on branch-changed backend files (indent shift from the
  module-wrapper nesting in decomposed files); backend lint now 904
  errors vs 1,315 on main
- useMutationWithToast forwards all four TanStack v5 callback args
  (tsc -b strict build flagged the 3-arg passthrough)
2026-07-03 08:17:14 +02:00
Paul Nothaft b5eafc52bd refactor(frontend): decompose EventDetailsPage and ThemeCustomizerEnhanced
Move-code split, entry paths/exports unchanged:
- EventDetailsPage.tsx (2,697 -> 679) + pages/admin/event-details/* (16 files)
- ThemeCustomizerEnhanced.tsx (1,541 -> 349) + admin/theme-customizer/* (11)
Known ephemeral-UI delta: widget-local state (copied-link flags, unsaved
PIN input, modal selection) now resets when a tab unmounts.
2026-07-03 08:10:25 +02:00
Paul Nothaft fdf62dafef refactor(backend): decompose invoiceService, contractService, adminEvents
Move-code split, public entry points unchanged:
- services/invoiceService.js (3,623 -> 99) + services/invoice/* (10 modules)
- services/contractService.js (2,363 -> 83) + services/contract/* (6 modules)
- routes/adminEvents.js -> routes/adminEvents/* (crud, slideshow, resets,
  archive/bulk, logo); route registration order verified identical
Lazy cross-service requires preserved to keep the module graph acyclic.
2026-07-03 08:10:25 +02:00
Paul Nothaft 51f827774a refactor(frontend): extract shared PhotoCard from gallery layouts
One card implementation (hover overlay, download/expand/select, likes,
identity flow, lazy render) replaces per-layout copies in Masonry,
Justified, Grid, Mosaic, Timeline (-1270/+395 in layouts). Carousel,
Premium, Story stay bespoke — different DOM/design by intent.
2026-07-03 07:49:45 +02:00
Paul Nothaft 0f230f53fb refactor(frontend): useMutationWithToast + useModal hooks, migrate admin surfaces
- 92 mutations across 40 files moved to useMutationWithToast
  (success/error toast + invalidateKeys); complex flows left as-is
- 24 boolean modal flags moved to useModal
- Mutations without an original onError intentionally not migrated
  to avoid introducing new error toasts
2026-07-03 07:49:45 +02:00
Paul Nothaft 6eb46d8c31 refactor(backend): standardize error responses, logging, pagination
- errorResponse(res, error, status, publicMessage) in routeHelpers,
  wired into 125 catch blocks across 10 route files; wire format
  ({ error: <string> }) unchanged byte-for-byte
- Replace remaining console.* with logger across src (178 sites);
  3 intentional console sites kept (install boot, unbound .catch ref)
- Adopt getPagination in 6 routes where semantics match exactly
2026-07-03 07:49:45 +02:00
Paul Nothaft d44ead41c5 test: add smoke tests for invoiceService, adminEvents routes, backupService
26 tests as a safety net ahead of decomposition — invoice create/list/
status transitions, adminEvents CRUD via Supertest+SQLite, backup config
parsing and manifest validation.
2026-07-03 07:49:45 +02:00
Paul Nothaft 9d7b6f0e11 test: extend documentSequences mock for shared nextDocumentNumber 2026-07-03 07:27:43 +02:00
Paul Nothaft eb71fcf209 refactor: remove dead files, dedupe formatBytes and document numbering helpers
- Delete unused adminEvents-enhanced.js, backupService.original.js,
  databaseBackup.example.js, s3Storage.example.js, ThemeCustomizer.tsx
- Extract shared formatBytes to utils/formatBytes.js (was copied 4x)
- Centralize formatNumberInTemplate + next-document-number logic in
  utils/documentSequences.js (was copied in invoice/quote/contract services)
2026-07-03 07:25:07 +02:00
Paul Nothaft 64e4925fbb chore(security): bump backend npm 10 -> 11 to patch bundled sigstore/tar
Closes Trivy alerts #375 (sigstore CVE-2026-48815), #321 (@sigstore/core),
#314 (tar) — npm@10 bundles the vulnerable sigstore 3.1.0; npm 11 ships the
patched 4.x. Safe because this npm is CLI-only in the final image: runtime
deps come from the builder stage's node_modules and the entrypoint runs node,
not npm, so the install-behaviour issues that motivated the 10.x pin never run
here. npm 11 requires Node >=22.9 — satisfied by node:22-alpine.
2026-07-02 17:16:09 +02:00
Paul Nothaft 12a9d963f5 chore(security): bump frontend nginx to r4 — close 4 HTTP/2 & module CVEs
Trivy flagged nginx 1.28.3-r1 in the frontend image (alerts #371-374):
- CVE-2026-42055 (HIGH) HTTP/2 heap overflow
- CVE-2026-49975 (HIGH) HTTP/2 DoS
- CVE-2026-9256  (HIGH) rewrite_module code exec / DoS
- CVE-2026-48142 (MED)  charset_module memory disclosure

All fixed in nginx 1.28.3-r4. The Dockerfile already ran 'apk upgrade
--no-cache', but the pushed image predated the fixed package and the layer
was cached on r1. Add an explicit nginx upgrade to force the layer to rebuild
against the current Alpine repos (which now carry r4).
2026-07-02 17:14:29 +02:00
Paul Nothaft d35c413651 feat(setup): step-by-step wizard + argument-driven unattended install
Restructure picpeak-setup.sh around two clear modes:

- Interactive wizard (run_wizard): asks method → install dir → channel →
  domain → HTTPS handling → admin email → SMTP, then shows a review and
  confirms before installing. Each value already passed as a flag is
  respected and its question skipped.
- Unattended (--unattended + flags): validate_unattended fills defaults and
  fails fast on impossible combos (e.g. --enable-ssl without --domain).
  New flags: --admin-password, --install-dir, --channel.

Align the Docker path with the rest of the project:
- Use the committed docker-compose.production.yml (prebuilt GHCR images) via
  COMPOSE_FILE in .env instead of hand-generating a divergent compose file.
- Drop the broken setup_ssl_docker call (was referenced but never defined).
- Update path pulls images instead of building.

Admin bootstrap follows the browser-first model (#714): by default no
password is written; the one-time /setup token is surfaced (from
data/SETUP_TOKEN or the logs) with browser instructions. --admin-password
keeps the legacy seeded-admin + ADMIN_CREDENTIALS.txt flow for headless runs.

Depends on #714 (setup-token backend + secrets-init in production compose)
for the browser-first + zero-secret behavior at runtime.
2026-07-02 16:44:09 +02:00
Paul Nothaft e08a33d9ea fix(ci): enable release-PR auto-merge with the PAT, not GITHUB_TOKEN
Auto-merge enabled via GITHUB_TOKEN attributes the eventual merge commit to
github-actions[bot], so recursion prevention suppresses the resulting push to
main — the follow-up release-please run that cuts the tag/release never fires.
Net: the version PR merges but no release/tag/images are ever produced (#719).

Enable auto-merge with RELEASE_PLEASE_TOKEN instead (a real identity) so the
merge triggers the tag-cutting run. Approval stays on GITHUB_TOKEN because it
must be a different identity than the PR author (the PAT) to count as a review.

Observed on #723: merged 3.77.3-beta.0 but no run followed and no tag was cut.
2026-07-02 15:41:25 +02:00
Paul Nothaft 0cab43ed89 fix(ci): set GH_REPO in release-please auto-merge step
The auto-merge step runs in a job with no actions/checkout, so gh could not
infer the repository from a git remote and failed with 'not a git repository'
(#719 follow-up). Set GH_REPO=${{ github.repository }} so gh pr list/review/
merge work without a checkout — same fix as the whatsnew workflow (2a5f0a8).

Confirmed working otherwise: with RELEASE_PLEASE_TOKEN set, release PR #721 is
now PAT-authored and its required checks run automatically (no manual approval).
2026-07-02 15:20:07 +02:00
Paul Nothaft a3e7232b8e fix(ci): auto-publish release-please PRs without manual approval (#719)
The release PR (authored by github-actions[bot] via GITHUB_TOKEN) sat open
forever: its workflows were held behind 'awaiting approval' and the required
review could not be satisfied by the bot. Both release-please workflows now:

- Use a dedicated ${{ secrets.RELEASE_PLEASE_TOKEN }} (fine-grained PAT) with a
  GITHUB_TOKEN fallback. A PAT-authored PR runs CI automatically (no 'awaiting
  approval') and can be merged without a human.
- Auto-approve (as github-actions[bot], a different identity than the PR
  author) and enable auto-merge on the open release PR, so it publishes once
  checks pass. Skipped when no PAT is configured — falls back to today's manual
  flow, nothing breaks.

A PAT also un-suppresses the tag-push and release-published triggers on
docker-build (GITHUB_TOKEN suppressed them), so add a concurrency group there
to collapse the duplicate same-version builds into one.

Requires (repo/org settings, one-time):
- Create fine-grained PAT RELEASE_PLEASE_TOKEN (contents:write, pull-requests:write).
- Enable 'Allow auto-merge' on the repo (currently off).
- 'Allow GitHub Actions to approve pull requests' — already enabled.
2026-07-02 15:01:52 +02:00
Paul Nothaft 8ca74776f4 docs: require screenshots for UI changes in PRs
Any PR that changes a user-facing surface must include a screenshot of the
result in the description (before/after where it helps). Reviewers ask for
one before reviewing UI-touching PRs; backend/non-visual changes are exempt.
2026-07-02 11:57:32 +02:00
Paul Nothaft 6f95796b7c feat: admin photos list/grid toggle + upload failure report (#707, #708)
Records two features that merged with gitmoji commit subjects and were
therefore skipped by Release Please, so the next beta credits them:
- #707 grid/list layout toggle on the admin Photos tab
- #708 per-file upload failure report in the upload modal

No code change — the features are already on main; this commit only gives
Release Please a Conventional Commit to cut the release from.
2026-07-01 09:38:34 +02:00
Paul Nothaft 4881d2040a ci: validate PR titles against Conventional Commits
Release Please only recognizes Conventional Commit prefixes (feat:, fix:,
...). PRs merged with other conventions (gitmoji, free-form) are silently
skipped, shipping changes with no version bump or changelog entry (see
#707/#708). Fail such PRs early via amannn/action-semantic-pull-request.
2026-07-01 09:38:34 +02:00
Paul Nothaft 2a5f0a8601 fix(ci): whatsnew highlights — set GH_REPO so gh runs without a checkout
Release Please Beta on v3.76.1-beta.0 hard-failed at the very first
`gh release view "$TAG"` call:

  failed to run git: fatal: not a git repository
  (or any of the parent directories): .git

The reusable `whatsnew-highlights.yml` (PR #703) doesn't run
actions/checkout — so when `gh` tried to infer the target repo from
the runner's empty workspace it errored out. The first time it ran
against an actual release (#709 → 3.76.1-beta.0), the whole job died
before the deterministic-fallback path could save it.

Two changes, both single-line:

1. `env.GH_REPO: ${{ github.repository }}` at job scope. `gh` honours
   this and won't fall back to parsing `.git/config`, so no checkout
   is needed (the workflow only calls the GitHub API, never reads
   repo files).

2. `continue-on-error: true` on the "Extract Features" step. The
   file's comments say "never let highlights break a release", but
   the original wiring only soft-failed the AI + inject steps. A
   transient API hiccup at extract still hard-failed the whole job —
   defeating the design intent. Match the comment.

Why not just add actions/checkout? It would work, but pulls the whole
repo over the wire on every release just for `gh` to read its own
config. GH_REPO is the lighter idiom.

Net impact today: v3.76.1-beta.0 shipped without the `<!-- whatsnew -->`
block; the app's parseWhatsNew() already falls back to the raw Features
list so the admin "What's New" banner still works. The next beta release
will pick up the polished version.
2026-06-30 22:47:10 +02:00
Paul Nothaft 56c2386c90 feat(gallery): branded URL shortener — /s/<slug> with OG injection (#699)
Issue 3 from #699 (@alexvaltchev's report): expose a custom-named short
URL per event that bots scrape for OG previews and browsers redirect to
the underlying gallery. WhatsApp / iMessage / Facebook cache the OG
metadata by the URL they crawl, so the SHORT URL becomes the cache key
— admins can rotate or split-test underlying gallery URLs without
re-pushing a fresh link to clients.

Additive feature; no existing route, table, or column is modified.

## Backend

- `gallery_short_urls` table (migration 150): id, short_slug UNIQUE,
  event_id FK CASCADE, target_path TEXT, created_by/at, hit_count,
  last_hit_at, deleted_at/by. hasTable-guarded so the migration is
  idempotent on re-run.

- `src/services/galleryShortUrlService.js` — validator + CRUD +
  resolver. Slug rules: `/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/`,
  reserved blocklist (admin, api, auth, gallery, og, s, login, ...).
  target_path snapshots at create-time from the event + global
  short-URL toggle, so a later flip of the toggle does NOT silently
  change where existing short URLs resolve.

- `src/routes/adminShortUrls.js` — `GET/POST
  /api/admin/events/:eventId/short-urls`, `DELETE
  /api/admin/short-urls/:id`. Structured errors: 400 INVALID_SLUG,
  409 SLUG_TAKEN (with `suggested`), 404 EVENT_NOT_FOUND. Gated by
  events.view / events.edit + requireEventOwnership.

- `server.js` /s/:shortSlug public route. Bot UA → server-render the
  same OG metadata the existing /og/gallery/<slug> handler produces,
  then override og:url to point at /s/<shortSlug> itself (cache-key
  invariant — social platforms key by the URL they scrape).
  Browser UA → 302 to target_path. Soft-deleted slug → 410 Gone
  (intentional-delete signal, distinct from 404 unknown slug).
  Hit accounting is fire-and-forget.

## Frontend

- `services/shortUrls.service.ts` — list/create/remove.
- `components/admin/ShortUrlsCard.tsx` — per-event card on the
  EventDetailsPage. Form for custom or auto-generated slug, list with
  copy-to-clipboard + soft-delete. SLUG_TAKEN error surfaces the
  service's `suggested` slug with a "use suggested" button.
- i18n: events.shortUrls.* added to EN + DE.

## Tests

78 new tests, all passing:

- `__tests__/utils/galleryShortUrlValidation.test.js` (48) — pure-
  function tests for validateSlug: accepts/rejects, reserved-slug
  blocklist, path-traversal + URL-injection vectors.
- `__tests__/integration/galleryShortUrls.test.js` (19) — service
  layer against a real SQLite DB. Covers custom + auto-generated
  slugs, collision + SLUG_TAKEN + suggested, target_path
  snapshotting (backward-compat invariant), soft-delete + slug
  rotation, hit counting.
- `__tests__/integration/galleryShortUrlRoute.test.js` (11) —
  HTTP-level: 302 redirect for browser UA, 200 + OG HTML for bot UA,
  og:url canonical points at /s/<slug>, 410 for soft-deleted +
  orphaned events, 404 unknown + malformed.

Regression sweep: 47 existing migration-chain integration tests still
pass; migration 150 is additive only.

## Backward compatibility

- Existing `/gallery/<slug>`, `/gallery/<32-hex-share-token>`,
  `/gallery/<slug>/show/<token>`, `/og/gallery/<slug>`,
  `/og/gallery/<slug>/cover` routes are untouched.
- The `/s/` namespace is new; no existing route lives there.
- Migration 150 only ADDs the new table — no ALTERs on existing
  schema, no destructive changes.
- target_path is snapshotted at create-time so flipping the global
  "Use short gallery URLs" setting after a short URL exists does NOT
  change where that short URL resolves.
2026-06-30 16:30:13 +02:00
Paul Nothaft 1b8747dc82 fix(og): rich social previews for share-token + slideshow URLs (#699)
Two SSR-OG injection bugs reported by @alexvaltchev. Both made his link
previews fall back to the brand logo + site-wide tagline instead of the
event-specific name/photo, even though the bot UA was hitting our
already-existing OG handler. He compensated with a Cloudflare Worker as
SSR middleware — which then created bug 3 below (og:image at the
auth-gated /api/.../hero/ path, not the public /og/.../cover one), so
Instagram never rendered the image either.

## Bug A — slideshow URLs miss the OG handler entirely

`/gallery/<slug>/show/<token>` has 3 segments after `/gallery/`. The OG
route was wired only at `/gallery/:slug/:token?` (1-2 segments), so
slideshow links fell through to the SPA-catchall `/gallery/*` and never
invoked the OG handler at all. Added a second route handler for the
3-segment slideshow shape, sharing the same intercept middleware so a
recognised social crawler still gets the rich preview.

## Bug B — share-token-only URLs resolve to nothing

`/gallery/<32-char-share-token>` (the form produced when migration 525's
short-URLs option strips the event slug) routes to the OG handler with
`slug=<token>`. resolveSlug then queries `events.slug = <token>`, which
never matches because the token is in a separate `share_token` column.
Result: falls through to the "no event found" branch and serves the
generic site-wide OG.

Fix: when the slug shape matches a 32-char hex AND the slug lookup
missed AND no redirect rule applies, try `events.share_token = slug` as
a final fallback. Real slugs are kebab/dot/underscore mixes, never pure
32-hex, so the extra DB roundtrip is gated to only fire for the
token-shaped URL.

## Tests

3 new tests in galleryOgService.shareImage.test.js using non-entropy
32-hex fixtures (deliberately zero-padded to avoid tripping
GitGuardian's Generic High Entropy Secret detector while still
matching the route's /^[a-f0-9]{32}$/i shape check):
- share-token slug resolves via the share_token column (alex's case)
- malformed/expired 32-hex token returns the site-wide fallback (no leak)
- non-hex slugs skip the share_token query entirely (hot-path cost guarded)

All 14 tests in the file pass.

## Out of scope here (separate follow-up)

- Issue 2 (Instagram og:image) — alex-side CF Worker bug pointing
  og:image at /api/gallery/<slug>/hero/<id>, which requires gallery
  auth. PicPeak already has the right unauthenticated path
  (/og/gallery/<slug>/cover) gated by events.og_image_share_enabled
  per-event opt-in (#474). Documented in the issue reply.
- Issue 3 (URL shortener with custom names) — real feature request,
  meaningfully different from the existing #525 short-URLs option that
  just strips the slug. Designing separately.
2026-06-30 16:03:49 +02:00
Paul Nothaft a40ab6a9b1 ci: required-check workflows now fire on every PR (no paths filter)
Branch protection on `main` + `stable` lists `upgrade-from-bootstrap`
and `fresh-install` as REQUIRED checks. The producing workflows had
`paths:` filters in their `pull_request` triggers, so they correctly
skipped on PRs that didn't touch migrations / package.json. But a
skipped workflow doesn't satisfy a required check — it leaves the
status "missing", which blocks merge on every unrelated PR.

Concretely surfaced on PR #692 (security bumps): all 12 visible checks
were green, but the merge button was blocked because the two
path-filtered workflows skipped and their required-check names never
reported.

This PR drops the `paths:` filter from both workflows so they always
fire on PRs against `main` + `stable`. Costs:
- `schema-drift` (`upgrade-from-bootstrap`): ~75 s per PR (Postgres
  service boot + migrate:safe run + schema assertion).
- `install-smoke` (`fresh-install`): ~2 min per PR (full Docker
  Compose boot + login).

Both are buying unconditional safety nets on the install + migration
paths, which is what the required-check gate is supposed to model.

Also fixes the trigger branch list while in the file: `[main, beta]`
→ `[main, stable]`, completing the post-#669 rename for these two
workflows that were missed in PR #686.

## What this does NOT fix

`GitGuardian Security Checks` is the third required check that's
currently missing on PRs — but that's a separate problem. The
GitGuardian GitHub App was installed at the user-account level
(`the-luap`) before the org transfer and didn't move with the repo.
Re-installing it on the org via the GitHub Marketplace is a UI step
the maintainer needs to do; can't be done via API.
2026-06-30 00:07:44 +02:00
Paul Nothaft 7546f104a3 chore(security): close 27 code-scanning alerts via dep + base-image bumps
Single PR closing every open code-scanning alert at
https://github.com/PicPeak/picpeak/security/code-scanning. Both repos go
from 27 open alerts → 0 across direct deps, transitive deps, and build-
time bundled deps.

## Backend (`backend/package.json` + overrides)

Direct dep bumps:
- axios          1.15.2   → 1.16.0       (closes 9 alerts: 7 high + 1 med + 1 low)
- nodemailer     8.0.10   → ^9.0.1       (closes 1 high — SSRF + file-read via raw option)
- multer         2.1.1    → 2.2.0        (closes 2 alerts: 1 high + 1 med)
- form-data      4.0.5    → 4.0.6        (closes 1 high)
- tar            ≥7.5.13  → ≥7.5.16      (closes 1 med)
- postcss        8.5.6    → 8.5.10       (closes 1 med)
- i18next-http-backend  3.0.2  → 3.0.5   (closes 1 med — backend lagged frontend)
- js-yaml        4.1.1    → ^4.2.0       (closes 1 med)
- joi            17.13.3  → ^17.13.4     (closes 1 med)

Overrides updated to match deps (npm rejected the install otherwise) +
nodemailer ^9.0.1 added as override so imapflow + mailparser transitive
bundling of older nodemailer is also fixed. Babel devDep auto-bumped via
`npm audit fix` (low-severity arbitrary file read).

Backend npm audit: 0 vulnerabilities.

## Frontend (`frontend/package.json`)

Direct dep bumps:
- axios                 1.15.2  → 1.16.0
- postcss               8.5.6   → 8.5.10
- i18next-http-backend  3.0.5   → 3.0.5  (already current — kept for parity)

`npm audit fix` swept up 12 transitive issues at the same time:
- vitest (1 critical — file read on UI server)
- vite (2 high — fs.deny bypass, NTLM hash via launch-editor)
- ws (2 high — uninitialized memory + DoS)
- dompurify (8 mod — multiple IN_PLACE / hook-pollution XSS vectors)
- react-router-dom + react-router (1 mod transitive)
- esbuild (1 mod — dev server file read)
- @babel/core (1 low)

Frontend npm audit: 0 vulnerabilities.

## Frontend Dockerfile

- Build stage: `node:20-alpine` → `node:22-alpine`

Closes the npm-bundled CVE class (picomatch, ip-address, brace-expansion,
@sigstore/core, tar) that came from Node 20's older bundled npm. Matches
the backend Dockerfile base. The nginx serving stage stays at
`nginx:1.28-alpine` — that tag is rolling, so the next build picks up
the fixed 1.28.3-r4 layer that closes the 4 nginx CVEs.

## Verification

- Backend: `npm audit` → 0 vulnerabilities 
- Frontend: `npm audit` → 0 vulnerabilities 
- Backend Jest (workflow engine, rounding, WhatsApp): 47/47 pass 
- Frontend Vitest: 84/84 pass 
- `frontend npm run build`: succeeds 
- nodemailer 9 sanity check: our usage is `createTransport({host,port,secure,auth})`
  + `sendMail({from,to,subject,html,text})` — we don't touch the `raw`
  option that 9.x tightened, so the major bump is API-compatible.
2026-06-29 23:26:32 +02:00
Paul Nothaft 806b1ac921 ci: bypass size gate — cap self-merge PR size for review-bypass users
@Luca-Timo is on main's review-bypass list so he can self-merge small
bugfixes without waiting for a maintainer review. The bypass list alone
is binary (he can merge anything), so this adds a complementary required
status check that fails when a bypass user's PR exceeds a configured
line-count threshold — blocking merge for genuine features while leaving
small bugfixes flowing.

How it works:
- Trigger: pull_request_target (so the workflow runs in the base repo's
  context with permissions to write a check status — script never
  executes PR code, so fork-PR-attack-safe).
- For PRs authored by a bypass user (default: @Luca-Timo):
    - linesChanged = additions + deletions
    - If ≤ LINE_LIMIT (300): check = success → bypass works → self-merge OK
    - If > LINE_LIMIT: check = failure → required-check gate blocks merge
      regardless of bypass; needs a maintainer review.
- For everyone else: check = success ("not applicable"). They go through
  the normal review path and are unaffected.

Both constants (LINE_LIMIT, BYPASS_USERS) are at the top of the workflow
for easy tuning.

After this lands on main, a separate API step adds 'bypass-size-gate' to
the main branch's required_status_checks list so the gate is actually
enforced. Until that's in place the check runs but doesn't block.
2026-06-29 23:13:09 +02:00
Paul Nothaft 5839bba72a docs: prominent migration banner at the top of README (#669)
GitHub-flavored `> [!IMPORTANT]` callout right below the title, before
the badges/hero block, so it's the first thing a visitor or repo browser
sees in the rendered README. Mirrors the in-app banner (#687) so an
operator gets the same message whether they're browsing the repo or
logged into the admin dashboard.

Body covers:
- Image-path change with the literal new path
- Branch rename (beta → main, main → stable) with auto-redirect note
- Link to docs/migration-to-org.md for the exact compose-file edit

Remove (or downgrade to a regular note) after the migration window
settles, same lifecycle as the in-app banner constant.
2026-06-29 22:24:56 +02:00
Paul Nothaft 2a4bf3b868 feat(admin): in-app migration banner for the org rename (#669)
One-time banner shown at the top of the admin layout to surface the org
rename + GHCR registry change for operators who haven't read the release
notes. Sits right below the existing maintenance banner — same pattern.

## What it looks like

Blue, dismissible banner with a short body:

> PicPeak's image registry has moved
> Update your docker-compose.yml to pull from
> ghcr.io/picpeak/picpeak/{backend,frontend} — the old path is no longer
> being updated. [See migration notes]

The link goes to `docs/migration-to-org.md` on the new org repo.

## Design choices

- **No backend feature flag.** A hard-coded `MIGRATION_BANNER_ENABLED`
  constant in the component file (1 line) gates global display. After
  ~1 quarter, flip it to false (or drop the mount in `AdminLayout.tsx`)
  in a small follow-up PR. A backend `app_settings` row + Settings UI
  toggle would be overkill for a one-time migration event.

- **Per-admin dismissal via localStorage.** Key is `picpeak:migration-banner:v1`
  (versioned so a future "we've moved AGAIN" banner can show without
  inheriting earlier dismissal). Wrapped in try/catch so private-mode
  browsers + storage-quota-exceeded errors don't crash the layout.

- **EN + DE strings** under a new top-level `migrationBanner` namespace.
  Other locales (fr, nl, pt, ru) fall through to EN — `migrationBanner.*`
  keys aren't translated there yet, deliberate (per #669 the ops
  banner is operator-facing and admins reading EN/DE is the majority).

- **Reuses `common.dismiss`** for the close-button aria-label.

## Test plan

- [ ] Frontend `npm run build:check` passes (TS + build)
- [ ] Open admin dashboard in EN → banner shows at top, below header,
      above main content
- [ ] Switch to DE → banner shows German strings
- [ ] Click dismiss → banner hides, doesn't re-appear on hard refresh
- [ ] Clear localStorage `picpeak:migration-banner:v1` → banner returns
- [ ] Flip `MIGRATION_BANNER_ENABLED` to false → banner doesn't render
      for anyone, regardless of dismissal state

Refs #669.
2026-06-29 22:19:22 +02:00
Paul Nothaft 3ca6378bd7 chore: workflows + RELEASING.md for the post-rename branch model
After the org move + branch rename (#669):
  beta  → main   (active development)
  main  → stable (curated release channel)

This PR rewires the workflows that referenced the old branch names so
release-please and the Docker build target the right channels.

## Workflow changes

### `.github/workflows/docker-build.yml`

- **Push triggers**: `[main, beta]` → `[main, stable]` (both `push.branches`
  and `pull_request.branches`). `beta` no longer exists; `stable` is the
  curated channel that should also produce builds.
- **`is_prerelease` detection**: pre-release context was decided by
  `refs/heads/beta`; now decided by `refs/heads/main` (active dev →
  prerelease, `-beta.N` version suffix unchanged).
- **`:latest` + `:stable` tagging**: were gated on `{{is_default_branch}}`
  (which used to be `main` = stable channel). Default branch is now `main`
  = active dev, so the implicit gate would have aliased `:latest` to dev.
  Both tags now explicitly gate on `refs/heads/stable` OR a non-prerelease
  release tag.
- **`:beta` tag**: REMOVED. Active-dev pulls are `:main` (auto-generated
  by `type=ref,event=branch`). The pre-rename `:beta` tag remains frozen
  at its last build under Option B / #669 — operators are expected to
  update to `:main` or pin to a versioned tag.

### `.github/workflows/release-please.yml`

- `branches: [main]` → `branches: [stable]`. This is the **stable**
  release-please workflow (uses `release-please-config.json`); after the
  rename, the stable channel lives on the `stable` branch.

### `.github/workflows/release-please-beta.yml`

- `branches: [beta]` → `branches: [main]`.
- `target-branch: beta` → `target-branch: main`.
- This is the **pre-release** release-please workflow (uses
  `release-please-config-beta.json`, `prerelease: true`); after the rename,
  pre-releases are cut from the new `main` (active dev). The version-suffix
  scheme stays `-beta.N` so existing operator pins keep working.

## RELEASING.md

Rewrote the TL;DR, "How a stable release is cut", and hotfix path to
reference the new branch names. Added a one-line "branch model background"
note pointing at #669 so future maintainers know why `main` means active
dev (the opposite of what some projects use). Filename conventions:
`release/X.Y.Z-merge-from-main` (was `…-from-beta`); promotion PR title
`promote main → stable as vX.Y.Z` (was `promote beta → main`).

## Why combined with PR A's content as a single PR

Originally planned as two PRs (B = workflow triggers, C = release-please
reconfigure). Splitting wasn't worth it: the configs are branch-agnostic
(`release-please-config.json` and `release-please-config-beta.json` don't
mention branch names internally), and not bundling them meant a window
where the stable release-please workflow would fire on pushes to the new
`main` (active dev) — exactly the wrong place. Single PR closes that gap.

## Versioning scheme — kept

No version-scheme decision needed. The `-beta.N` suffix on pre-release
versions is preserved (existing operator pins like `v3.71.3-beta.0` keep
working). If a `v4.0.0-pre.N`-style reset is desired later, that's a
separate PR with explicit operator-comms attached.
2026-06-29 22:15:31 +02:00
Paul Nothaft d606fcd5a4 docs: branch model + migration-to-org guide + PR-template target hint
Operator + contributor docs for the post-org-move world. None of these
files reference the legacy branch names (`beta` / old `main` meaning) —
they describe the new shape (`main` = active dev, `stable` = curated
release channel), so they're correct from the moment the rename happens.

Three additions/edits:

1. `docs/migration-to-org.md` (new) — operator-facing one-pager that the
   in-app migration banner + the OLD GHCR package URLs (now 404) can
   point at. Walks through the single `docker-compose.yml` edit needed.

2. `CONTRIBUTING.md` — new "Branch model" section explaining which
   branch to target (`main` for features + most fixes; `stable` only
   for small, surgical bugfix backports). Updates the "fork from beta"
   step to "fork from main". Updates the release-process paragraph to
   describe the two-channel model instead of the old beta→main promote.

3. `.github/PULL_REQUEST_TEMPLATE.md` — adds a target-branch hint at
   the top of the template (HTML comment so it shows during PR
   composition but doesn't render in the merged PR body).
2026-06-29 22:05:56 +02:00
Paul Nothaft 0205c7dcce chore: migrate Docker registry + GitHub URLs to PicPeak org
Repo transferred from the-luap/picpeak → PicPeak/picpeak. Docker images
publish to ghcr.io/picpeak/picpeak/{backend,frontend} (lowercase, per the
GHCR canonical form computed by docker-build.yml's `${GITHUB_REPOSITORY,,}`).

Sweep covers:
- docker-compose.production.yml + Dockerfiles → new image registry path
- README, CONTRIBUTING, SECURITY, SIMPLE_SETUP, scripts/picpeak-setup.sh
  → new GitHub URLs
- Update-check / release-notes services (updateCheckService,
  environmentService, updateNotificationService, adminSystem,
  UpdateNotification, githubReleaseUrl) → GitHub API + tag URLs use the
  canonical PicPeak/picpeak path
- Issue templates + README-DOCKER + workflow README → updated package URLs
- One commit-context comment in migrations/090 + customerAccountsService

CHANGELOG.md is intentionally untouched (historical release entries are
immutable; GitHub auto-redirects the old URLs indefinitely).
CLAUDE.md keeps the bare `(the-luap)` reference — that's the maintainer's
personal handle, not a repo URL.

22 files, 48/48 line swaps (every change is a 1:1 URL replacement).
2026-06-29 20:20:13 +02:00
Paul Nothaft 511d647eec fix(events): wire customer notifications into both public API entry points (#647)
Two entry points for event creation were missing customer notifications, both
discovered while triaging @Rekoo-PS's report that "API created events" don't
send WhatsApp after #649/#650 landed.

POST /api/v1/events (the OpenAPI-spec'd bearer-token API at v1/events.js):
- gallery_created email was NEVER queued — only the webhook fired.
- WhatsApp was NEVER queued either.

POST /api/events (legacy admin-auth route at routes/events.js):
- gallery_created email was queued, but WhatsApp was not.
- customer_phone wasn't read from the body at all.

Both routes now mirror the adminEvents.js create-and-publish path: best-effort
queues that never block the API response, gated on customer_email / customer_phone
presence and the global event_phone_field_enabled toggle for the phone field.

The webhook subject from POST /api/events now also includes customer_phone, so
downstream integrations get the same shape as the v1 API.

No schema change. No migration. customer_phone column already exists on events
(migration 080). WhatsApp config + template_language + template_params resolve
through the existing queue processor.
2026-06-27 21:28:56 +02:00
Paul Nothaft ea2852dcb0 fix(admin): stack publish-gallery dialog CTAs so the German label fits (#670)
Reporter @the-luap hit the German `Veröffentlichen & Kunden benachrichtigen`
button overflowing the modal footer in the publish dialog. Two failure modes
chained:

1. The footer was a `flex` row with two `flex-1` buttons inside a
   `max-w-md` (448 px) modal. Default `min-width: auto` on flex children
   meant the primary button kept its content width (~340 px including the
   paper-airplane icon + padding) and pushed the row past the modal frame.

2. Adding `min-w-0 whitespace-normal` doesn't help — the base `.btn` class
   has `@apply ... whitespace-nowrap` (`index.css:149`) which wins over a
   utility className via the Tailwind CSS cascade order. So the text won't
   wrap, the button silently extends past the modal frame, no overflow
   indicator. Verified with `getComputedStyle().whiteSpace = 'normal'` and
   the button still rendering as one ~340 px wide line at ~224 px allocated
   space.

Fix: stack both buttons vertically (`flex flex-col-reverse gap-3`). Primary
appears on top visually (col-reverse), cancel below — standard
confirmation-dialog pattern (Material, Headless UI, Radix all do this for
single-action dialogs). Works in every locale and viewport regardless of
label length. No side-by-side row to overflow.

Tried two prior shapes that didn't hold:
- `flex-col-reverse sm:flex-row` with `sm:flex-1 min-w-0 whitespace-normal`
  on the primary: still overflowed silently because of the
  whitespace-nowrap cascade above.
- `flex-col-reverse sm:flex-row sm:justify-end` with content-width buttons:
  `justify-end` doesn't constrain a row whose content sum exceeds the
  container; row just pushes left of the modal.

Bumping the modal to `max-w-lg` (or wider) was also considered and rejected:
matching modal width is asymmetric (every other admin dialog stays at
`max-w-md`), and any locale longer than German would re-hit the wall.
Stack-always is the only shape that handles every locale + every viewport
without per-language tuning.

Verified end-to-end against a dockerised dev backend:
- DE + EN × desktop (1280px) + mobile (375px) — all four show primary on
  top, cancel below, both inside the modal frame, no overflow.

Lint + tsc + full vitest suite (84/84) clean.

Closes #670.
2026-06-25 18:06:08 +02:00
Paul Nothaft ab501459a4 feat(analytics): pluggable trackers — Umami + Rybbit + Custom (#663 Phase 1)
Implements the hybrid scope agreed on in #663: two native adapters
(Umami + Rybbit) for trackers we'd keep maintained, plus a Custom
script-paste mode for everyone else (Plausible, Matomo, Pirsch, GA4,
GoatCounter, Fathom, Cloudflare Web Analytics). Phase 2 (Plausible
native, deeper metrics) explicitly deferred until someone asks.

## Architecture

**Backend `services/trackers/`**:
  - `TrackerAdapter` shape (single method): `fetchDeviceBreakdown` →
    `{ desktop, mobile, tablet } | null`. Null = route falls back to
    access_logs heuristic.
  - `umamiAdapter.js` — extracted from the `services/umamiClient.js`
    that landed in #662. Same 10 test contract preserved.
  - `rybbitAdapter.js` — new. Hits `/api/site/{id}/breakdown?dimension=
    device` with Bearer auth, accepts both bare-array and `{data:[...]}`
    envelope variants, tolerates `sessions`/`visitors`/`value`/`count`
    metric keys.
  - `customScriptSanitiser.js` — sanitize-html with a tracker-tight
    allowlist (`<script>` / `<noscript>` / `<link rel=preconnect|
    dns-prefetch>` / `<meta>`). Strips event-handler attributes,
    `javascript:` and `data:` URLs.
  - `index.js` factory: `resolveAdapter()` reads
    `analytics_tracker_provider` setting → dispatches. Back-compat:
    when provider is unset, infers `umami` from the legacy
    `analytics_umami_enabled` flag so #662 installs keep working
    without an admin touching settings.

**Backend routes**:
  - `adminDashboard.js /analytics`: now goes through `resolveAdapter()`.
    Old `fetchUmamiDeviceBreakdown` direct import removed; both `umamiClient.js`
    and its test file deleted (replaced by the adapter shape).
  - `adminSettings.js PUT /analytics`: validates the new
    `analytics_tracker_provider` enum, sanitises any incoming
    `analytics_custom_head_html` on save via the sanitiser. Masks
    the new `analytics_rybbit_api_key` on every GET — same pattern as
    Umami's API key and recaptcha secret.
  - `publicSettings.js`: emits `analytics_tracker_provider`,
    `rybbit_url`/`rybbit_website_id` (only when provider=rybbit), and
    the pre-sanitised `analytics_custom_head_html` (only when
    provider=custom). Legacy `umami_*` fields stay for back-compat.

**Frontend**:
  - `analytics.service.ts` reworked into a provider-aware shape.
    `initialize({provider, ...config})` dispatches to Umami /
    Rybbit / Custom / None. `track()` calls dispatch to
    `window.umami.track` / `window.rybbit.event` / no-op based on
    the loaded provider.
  - `App.tsx` `AnalyticsBootstrap` reads `analytics_tracker_provider`
    from public-settings and routes to the right `initialize` call.
    Legacy `umami_enabled`-based path preserved as fallback when the
    new field is missing.
  - `AnalyticsTab.tsx` (Settings → Analytics) reworked with a
    "Provider" dropdown switching between None / Umami / Rybbit /
    Custom panels. Each panel renders its own config fields; Custom
    panel surfaces an explicit CSP-reminder banner.
  - `useSettingsState.ts` shape extended with `tracker_provider`,
    `rybbit_url`/`rybbit_website_id`/`rybbit_api_key`,
    `custom_head_html`. Save mutation keeps `umami_enabled` in sync
    with `tracker_provider==='umami'` for back-compat with downstream
    consumers (publicSettings shape, embedded iframe).
  - `publicSettings.service.ts` type extended.

**i18n**: EN + DE for the provider heading + description + dropdown
options + Rybbit fields + Custom HTML field + CSP warning.

## Custom mode — script execution caveat

When the gallery `<head>` receives the custom HTML, simply assigning
innerHTML to a container element wouldn't execute the embedded
`<script>` tags (per the HTML spec, dynamically-inserted scripts via
innerHTML are non-running). `analytics.service.ts:120-130` re-creates
each `<script>` element manually so the browser actually evaluates
it. Non-script nodes (link, meta, noscript) move in directly.

## Tests

**Backend** (42 cases, all pass locally):
  - `umamiAdapter.test.js` (10) — pinned from the original
    `umamiClient.test.js`: missing-config / URL shape / encoding /
    payload normalisation / `laptop`→`desktop` / unknown buckets /
    empty / non-2xx / invalid JSON / network error.
  - `rybbitAdapter.test.js` (9) — same shape adapted for Rybbit:
    bare-array + envelope payload, `sessions`/`visitors`/`dimension`
    key tolerance, encoding, failure modes.
  - `trackerFactory.test.js` (6) — resolves null for `none`/`custom`,
    correct adapter for `umami`/`rybbit`, back-compat path via
    legacy `analytics_umami_enabled`, garbage-provider defensive null.
  - `customScriptSanitiser.test.js` (12) — Plausible-style passthrough,
    Umami-style passthrough, inline body passthrough, `<noscript>`
    allowed, `<link rel="preconnect|dns-prefetch">` allowed,
    `<link rel="stylesheet">` stripped, disallowed tags stripped,
    `javascript:`/`data:` URLs stripped, `on*` event handlers
    stripped, defensive on malformed input.
  - `analyticsDateMerge.test.js` (5) — preserved from #662.

**Frontend**: full 84-case vitest suite green; tsc + eslint clean
on changed files. Adapter changes are narrow refactors of code
covered by backend tests; no new analytics-page unit test added.

## End-to-end smoke (dockerised backend + my changes mounted)

```
test 1 (back-compat: no provider, umami_enabled=true)
  → factory returns umami adapter, /analytics returns
    devicesSource:access_logs (umami fetch to fake host fails
    gracefully). ✓

test 2 (invalid provider value)
  → 400 "analytics_tracker_provider must be one of: none, umami,
    rybbit, custom" ✓

test 3 (save custom HTML with XSS payload)
  → stored sanitised:
    `<script>alert(1)</script>evil<script async defer
     data-domain="x.com" src="https://plausible.io/js/script.js"></script>`
    (<div> stripped; script tags survive but CSP `script-src 'self'`
    still blocks inline + non-allowlisted external at runtime) ✓

test 4 (public-settings exposes the provider switch)
  → `analytics_tracker_provider: 'custom'`,
    `analytics_custom_head_html: '<sanitised>'` ✓
```

## Out of scope (next discussions)

- **Plausible native** — covered via Custom mode for now; native is
  Phase 2 if someone explicitly asks.
- **CSP "trusted domains" admin input** — Phase 1.5. For now operators
  add their tracker domain to nginx/proxy CSP manually; the new
  CSP-reminder banner in the Custom panel makes that clear.
- **Refactor `(window as any).umami.track(...)` direct calls** in
  PhotoLightbox/PhotoGrid to go through `analyticsService.track()`
  so events fire on the right tracker. Currently a no-op when Umami
  isn't loaded; functional but not optimal.

Closes #663 Phase 1.
2026-06-23 18:11:58 +02:00
Paul Nothaft 7534447b6c fix(analytics): admin dashboard reads correct fields + Umami device API (#661)
Reporter @alexvaltchev hit three independent bugs on the Analytics
Dashboard. All three fixed in one PR; pluggable-tracker support
(Rybbit, Plausible, etc.) left for a separate discussion.

## Bug A — Summary cards showed 0

Two layers, both fixed.

**Frontend** (`AnalyticsPage.tsx:142-149`): the cards summed
`chartData[].views/uniqueVisitors/downloads`. The backend now (and
already) emits a dedicated `totals` object computed via separate
COUNT queries, which is what the cards should read. Postgres returns
counts as strings, so coerce via `Number()`.

**Backend** (`adminDashboard.js:268-282`): the chartData merge used
`dateObj.date === row.date`. On Postgres, pg's driver auto-converts
`DATE(timestamp)` to a JS Date object — the string-equality match
failed silently and `chartData` stayed all-zero on every Postgres
install with traffic. Added a `normaliseDateKey()` helper that
returns YYYY-MM-DD regardless of driver shape, plus `Number()`
coercion on the counts. SQLite path unchanged.

## Bug B — "Umami Not Configured" banner despite valid config

`AnalyticsPage.tsx:90` did `settings.reduce(...)` on the
`/admin/settings` response. That endpoint returns a
key/value **object** (verified at `adminSettings.js:108-149`), not
an array, so `.reduce` threw `data.reduce is not a function` and
the catch silently rendered the "Not Configured" banner even on
perfectly-configured installs. Read the umami keys directly off the
response object.

## Bug C — Device breakdown 0/0/0

Two-pronged fix.

**Primary path — Umami device API** (`services/umamiClient.js`,
wired into `adminDashboard.js`). When the admin provides an Umami
v2 API key (new setting `analytics_umami_api_key`), the backend
fetches the per-period device breakdown from Umami's
`/api/websites/:id/metrics?type=device` endpoint. Umami tracks
devices natively — far more accurate than our coarse user-agent
heuristic. The new `devicesSource` field in the response lets the
UI hint at where the numbers came from.

**Fallback hardening — local heuristic** (`adminDashboard.js:296-320`).
The existing access_logs `LIKE '%Mobile%' / '%Tablet%'` query stays
in place as a fallback for installs without Umami. Hardened with:
`whereNotNull('user_agent')` skips rows we never captured a UA on,
`Number()` coercion on COUNT results (pg returns strings), and a
guard against divide-by-zero when access_logs is empty.

## API key handling

Mirrors the existing recaptcha-secret pattern: stored plaintext in
`app_settings`, masked as `••••••••` on every GET via the existing
`adminSettings.js` GET handlers, and the frontend save mutation
silently drops the masked sentinel so re-saving without typing a
new key preserves the stored value.

## End-to-end smoke (dockerised backend with my fixes applied)

```
chartData total views: 27   ← previously 0 (date merge broken on PG)
totals: {'views': '27', 'downloads': '3', 'uniqueVisitors': '1'}
devices: {'desktop': 100, 'mobile': 0, 'tablet': 0}   ← was 0/0/0
devicesSource: access_logs   ← falls back correctly
analytics_umami_api_key (GET /settings/analytics): ••••••••
```

## Tests

**Backend** (15 new cases):
- `umamiClient.test.js` (10): missing-config → null, URL shape +
  `x-umami-api-key` header, websiteId URL-encoding, `{x,y}` →
  percentages, `laptop` → `desktop` mapping, unknown buckets
  dropped, empty payload → null, non-2xx → null, invalid JSON →
  null, network error → null.
- `analyticsDateMerge.test.js` (5): YYYY-MM-DD string pass-through,
  ISO timestamp slice, JS Date (pg shape) → YYYY-MM-DD, null/empty
  → null, coercion for unexpected types.

**Frontend**: full 84-case vitest suite still green (no analytics
unit tests existed before; not adding any here — the changes are
narrow and the unit-level confidence comes from the type system +
the backend smoke above).

Closes #661 (bugs A + B + C). Rybbit / pluggable tracker support is
the next conversation per the issue author's follow-up.
2026-06-23 17:37:41 +02:00
Paul Nothaft 98e97e3cf2 fix(i18n): replace ASCII quote with U+201D in DE perGuestLimitsDesc
CI's frontend test job failed with "Failed to parse JSON file, invalid
JSON syntax found at position 163854" on de.json:3041. The German
description used „…" — the opening „ (U+201E) was correct, but the
closing was an ASCII " (U+0022) which the JSON parser treated as the
string terminator, leaving "-Abläufe..." as garbage outside the string.

Replace with the proper German closing quote " (U+201D). 84/84 vitest
suite now passes locally. End-to-end smoke against a dev backend with
migration 141 applied confirms the modal renders correctly on desktop
(centered card) + mobile (bottom slide-up) and the backend returns the
structured 403 on the 11th-click cap hit.

Also flagging adjacent: origin/beta has a pre-existing duplicate `Mail`
import in frontend/src/pages/admin/SettingsPage.tsx (lines 20 + 58 from
commit 69367b45) that breaks Vite dev's Babel parser but passes prod
esbuild — out of scope for this PR, separate fix needed.
2026-06-22 22:40:13 +02:00
Paul Nothaft f2814e4a4c feat(feedback): per-guest favorite + like caps with mobile-friendly limit modal (#655)
Reporter @Duecki1 wants to stop telling guests "pick only 5 photos" by
hand. Per-event cap, enforced server-side, with a clear popup when the
11th click would exceed the limit. Per-guest scope matches the "every
couple picks their top 10" mental model; per-gallery aggregate is
explicitly NOT in scope (creates weird "first 10 visitors use up all
slots" race conditions).

## Schema (migration 141)

Two nullable columns on `event_feedback_settings`:
  - `max_favorites_per_guest`
  - `max_likes_per_guest`

null / 0 = unlimited (preserves current behaviour for every existing
install — operator must opt in). Both shipped together because the
code path is identical; photographers can cap either, both, or neither.

## Backend

- `feedbackService.submitFeedback` cap check on the INSERT branch only.
  Toggle-off (un-favoriting) is always allowed, so a guest at 10/10
  can free a slot by un-clicking an existing favorite.
- New `countGuestFeedback(eventId, type, guestId, guestIdentifier)` —
  matches the exact same guest-key shape the existing duplicate-check
  uses (guest_id when present, fallback to guest_identifier in simple
  identity mode).
- Limit reduction grandfathers: admin lowering 20→10 keeps existing
  rows in place; new adds blocked until the guest removes some.
- Route layer (`galleryFeedback.js` POST) translates a `limit_reached`
  service-return into a structured 403 with `code:
  'FAVORITE_LIMIT_REACHED'` / `'LIKE_LIMIT_REACHED'`, `limit`, and
  `current_count`. Stable UI contract.
- `feedback-settings` GET exposes the caps so the gallery UI can
  optionally render a counter near the heart icon (UI extension TBD;
  the modal alone is the contract this PR commits to).
- `feedbackValidation`: range guard `0..10000`, null allowed,
  per-field error messages.

## Frontend — the popup

New `FeedbackLimitReachedModal` component renders via a `createPortal`
to `document.body` so it escapes any lightbox / sticky parent stacking
context and reliably sits above everything else.

Mobile-first responsive:
  - `items-end sm:items-center` — slides up from the bottom on phones
    (native action-sheet feel), centers on desktop (familiar modal).
  - `w-full sm:max-w-md` — full-width on phones, clamps to 420px on
    desktop.
  - `rounded-2xl sm:rounded-xl` — more rounded on phones for the
    sheet feel.
  - `pb-[env(safe-area-inset-bottom)]` — respects the iOS home indicator
    and Android gesture bar.
  - `z-[60]` — above the lightbox's z-50.

Title + body + "8 of 10 used" pill + "Got it" button. Backdrop click +
Escape both dismiss. Focus management lands on the OK button so
keyboard / screen-reader users can dismiss immediately.

New `useFeedbackLimitModal()` hook is the shared API: components
on every submit-feedback site wire `onError: (err) => handleError(err)`
and render `{limitModal}` in their JSX. Returns `true` from
`handleError` when the error is a structured cap-reached 403 (so the
caller can skip its generic error toast). PhotoFavorites + PhotoLikes
+ PhotoLightbox all wire through the hook — every favorite/like submit
path is covered, including the lightbox's three different submit
sites (guest mode, simple mode, post-identity-modal-confirm).

## Admin UI

`FeedbackSettings` card gets a new "Per-guest limits" section that
only renders when at least one of `allow_favorites` / `allow_likes` is
on. Two numeric inputs (0 / empty = unlimited) side-by-side on
desktop, stacked on mobile. Hint text covers the limit-reduction
grandfathering semantics so admins aren't surprised.

## i18n

EN + DE for:
  - Modal title + body (parameterized with `{{limit}}`)
  - Counter pill (parameterized with `{{current}}` / `{{limit}}`)
  - OK button label
  - Admin field labels + hints + section header + grandfathering note

## Tests

**Backend** (`__tests__/utils/feedbackPerGuestLimit.test.js`, 8 cases):
  - null cap → unlimited (back-compat)
  - 0 cap → unlimited (UI convenience)
  - cap=10: rows 1-10 succeed, 11 returns limit_reached
  - toggle-off frees a slot at the cap
  - limit reduction grandfathers existing rows
  - per-guest scope: guest A's cap doesn't affect guest B
  - favorite cap doesn't block likes (per-type)
  - like cap returns LIKE_LIMIT_REACHED-shaped payload

**Frontend** (`__tests__/useFeedbackLimitModal.test.ts`, 7 cases):
  - Non-axios errors → null
  - Non-403 axios errors → null
  - 403 with wrong code → null
  - FAVORITE_LIMIT_REACHED parsed
  - LIKE_LIMIT_REACHED parsed
  - Falls back to code-implied type when feedback_type missing
  - Missing numeric fields → 0 (not NaN)

All 15 pass. tsc --noEmit clean. eslint clean on changed files.

Closes #655.
2026-06-22 22:02:13 +02:00
Paul Nothaft f4b6b8941a fix(test): raise bootCrmDb beforeAll timeout on slideshow suites
CI runners hit Jest's default 5s `beforeAll` timeout on
slideshowPublic.test.js's bootCrmDb call (~5.4s observed vs ~2s local —
runner-to-runner I/O variance, not a regression). Same hook shape on
slideshowAdmin.test.js is one slow runner away from the same failure.
Raise both to 30s so this stops blocking unrelated PRs branched off beta.

Adjacent to #654 — not strictly part of that fix but the only blocker
between #656 and a green CI right now.
2026-06-22 21:32:12 +02:00
Paul Nothaft b1bfd4838e fix(gallery): unbreak password entry in Instagram in-app browser (#654)
Reporter @Duecki1 hit "Incorrect Password" on byte-correct input from
Instagram's iOS/Android IAB. Backend bcrypt compare is fine — the
frontend was handing it a mangled byte sequence because the password
Input lacked the autocaps/autocorrect/spellcheck/autocomplete defenses
Instagram's WKWebView keyboard bridge needs (the standard `type="password"`
WebKit defaults that suppress autocaps get overridden inside the IAB).

Three layers of defense:

1. **Explicit input attributes** on the gallery password field —
   `autoCapitalize="none"`, `autoCorrect="off"`, `spellCheck={false}`,
   `autoComplete="current-password"`. Stops iOS autocaps turning
   `wedding2026` into `Wedding2026`, stops predictive-text rewrites,
   nudges password managers to autofill the right credential rather
   than the IAB's stale saved-password store.

2. **Silent `.trim()` on submit** — Android Instagram IAB's predictive
   keyboard often appends a trailing space when the user taps the
   submit button. Event-gallery passwords don't legitimately carry
   leading/trailing whitespace (they're set by photographers, usually
   generated short strings), so trimming here is safe.

3. **Instagram IAB detection banner** — `frontend/src/utils/inAppBrowser.ts`
   detects the `Instagram` UA tag and surfaces a one-time advisory at
   the top of the password card with the right platform-specific
   "Open in external browser" instructions (⋯ menu copy for iOS,
   ⋮ for Android). Self-rescue path for users who hit it before we
   can close every keyboard mangling vector.

Scope is strictly Instagram per #654. Facebook IAB (`FBAV`/`FBAN`)
behaves identically and would benefit, but expanding the matcher is
a separate scope decision — the detector + i18n shape leaves room for
it without further refactor.

EN + DE i18n for the banner; 8 vitest cases on `detectInAppBrowser`
(iOS / Android Instagram UAs, plain Safari / Chrome / desktop UAs,
case-insensitive match, word-boundary defense against substring
collisions, SSR-safety when `navigator` is undefined). Lint + tsc
clean; pre-push Playwright smoke still expected green.

Closes #654.
2026-06-22 21:09:17 +02:00
Paul Nothaft 1f46a241d2 chore(whatsapp): renumber migration 138 → 140 (after PR #646's 138+139)
PR #646's review-round renumbered its slideshow migrations to 138 + 139
to slot in after PR #649's 137 (whatsapp_template_language). That now
collides with this PR's 138. Slide ours to 140 so all three land in
strict order: #649 (137) → #646 (138, 139) → this PR (140). Content
unchanged; pure rename + a one-line docstring tweak noting the slot.
2026-06-21 21:12:22 +02:00
Paul Nothaft 16055cdc41 feat(whatsapp): admin-selectable template parameters + reorder (#647 follow-up)
Reporter @Rekoo-PS confirmed the language fix unblocked sending, then
hit a second gap: their template uses only `{{1}} = event_name` +
`{{2}} = gallery_link`, but the legacy `buildComponents` hardcoded all
5 positional values from the `gallery_ready` shape (customer_name,
event_name, gallery_link, password_line, expiry_date). Meta rejected
with a parameter-count mismatch even after the language matched.

This adds a per-config slot list — which built-in values to send, and
in what positional order — so admins can match templates of any shape
without code changes.

## Schema (migration 138)

Additive `template_params` TEXT column on `whatsapp_configs` (default
empty string = legacy 5-slot behaviour for existing installs). Stored
as a JSON-serialized array of slot keys: `customer_name`, `event_name`,
`gallery_link`, `password_line`, `expiry_date`. Unknown / duplicate /
non-string entries are sanitized out at read time.

## Processor

- `parseTemplateParams(raw)` — defensive parser; falls back to the
  5-slot default on empty / malformed / all-invalid input.
- `buildComponents(data, metaLang, params)` — emits ONLY the listed
  slots in the listed order, computed via a small switch on slot key.
  The password line still receives the locale-specific 🔒 label and
  the empty-when-no-real-password sentinel handling.
- Processor reads `config.template_params` once per cycle and passes
  the parsed array to `buildComponents` per message.

## Admin route

- GET surfaces `template_params` as the parsed array (default 5-slot
  when null/empty).
- PUT round-trips the incoming array through `parseTemplateParams`
  before persisting, so the stored value is always the canonical
  sanitized JSON.
- Test send rebuilt to use the same `buildComponents` path so the
  admin's test message matches their configured slot shape — a
  reporter who configures 2 slots gets a 2-parameter test send, not
  the legacy 5-parameter payload.

## UI

- `WhatsAppTab` gets a checkbox + up/down list under the Template
  language field. Each slot shows its current `{{N}}` position when
  checked, an em-dash when unchecked. Live preview below the list:
  "Your template will receive: {{1}} = event_name, {{2}} = gallery_link".
- EN + DE i18n for the field labels, hint, preview, and per-slot
  human-readable names.

## Tests

- 17 unit tests in `__tests__/utils/whatsappBuildComponents.test.js`
  covering: parseTemplateParams sanitization (unknown keys, duplicates,
  non-strings, malformed JSON, all-invalid fallback, pre-parsed array
  acceptance) and buildComponents shape (reporter's 2-slot case,
  reorder, empty list, locale-specific password label, password
  sentinel handling, expiry omission).
- All 17 + the 34 existing networkValidation tests pass.

## Migration numbering

Sits at 138 on top of PR #649's migration 137. If #646 (Live Slideshow)
merges before this, #646's own 137 + 138 take precedence and this
needs renumbering to 139. Coordinated via PR #646's review thread.

## Honest caveat

Still no Meta Business API account on my side. Spec-built, sanitizer +
shape unit-tested, lint + tsc clean. End-to-end against Meta needs the
reporter (or a maintainer with an account) to verify. If a real
round-trip surfaces a mismatch, drop it in #647 and I'll iterate.
2026-06-21 20:51:20 +02:00
Paul Nothaft 4fd7709596 fix(whatsapp): admin-pinned template language + Arabic locale support (#647)
Reporter @Rekoo-PS hit three independent gaps trying to deliver an
Arabic Meta template. Bundled here because they fan out from the same
root cause (no first-class language config on the WhatsApp tab) and the
review surfaces are tightly coupled.

**1. Test send hardcoded `en_US` (`adminWhatsapp.js:141`).** Smoking gun
for "I can't make it work" — Meta returned template_not_found_in_language
(132001) on every test send for non-English templates, no matter what
else the admin configured. Replaced with `config.template_language ||
'en_US'`.

**2. No `template_language` field on `whatsapp_configs`.** The only
priors were per-message `data.language` (always null from our callers in
`adminEvents.js:854,1188`) and `app_settings.general_default_language`
(the *system UI* language, not the *template's* language registered with
Meta). Migration 137 adds the column; GET + PUT surface it; the
processor uses it as the highest-priority default when message_data
doesn't override.

Resolution order in `whatsappProcessor.processWhatsAppQueue` is now:
  1. message_data.language (per-event override — caller path TBD)
  2. config.template_language (admin-pinned template language)
  3. app_settings.general_default_language (system fallback)
  4. en_US (hardcoded last resort)

**3. `LANGUAGE_MAP` + `PASSWORD_LABELS` didn't cover Arabic.** Added
`ar` (Meta's single-code form per RFC; no region variant). For any
language we don't enumerate (e.g. Turkish `tr_TR`, Chinese `zh_CN`,
Hebrew `he_IL`), `resolveLanguageCode` now pass-throughs valid-shape
codes (lowercase-language + optional underscore + uppercase-region) and
forwards them to Meta as-is. If they don't match a registered template
Meta returns 132001, which the test route already surfaces back to the
admin via `error.message` — fail-loud, no silent fallback.

Validation:
- Unit smoke on `resolveLanguageCode` across 18 representative inputs
  (in-map, pass-through, canonicalization, rejection) — all behaviours
  correct.
- Lint clean on all 7 changed files.
- Frontend `tsc --noEmit` clean.
- Migration `node -c` syntax-checked; additive + `hasColumn`-guarded so
  re-running is safe.

Frontend: free-text input on the WhatsApp tab with EN + DE i18n.
Pointing at Meta's supported-languages docs via the hint text — Meta's
list grows; a hardcoded dropdown would rot.

Closes #647.
2026-06-20 23:29:08 +02:00
Paul Nothaft 7cf26795ec fix(branding): preserve customCss through preset switches + theme changes (#645)
Reporter @aemisrogers nailed the root cause: same #317 class of bug as
logoUrl. None of `GALLERY_THEME_PRESETS` (`theme.types.ts:125`) include
`customCss` in their `config` object, so any path that REPLACES
`currentTheme` with `preset.config` (or with a sparse `newTheme` that
came from `preset.config` upstream) silently dropped `customCss` from
React state. The persisted value in `theme_config` stayed correct (the
public gallery still rendered it), but the admin textarea showed
empty on reload — admin-UI display drift, not data loss.

Three surgical fixes, mirroring the #317 logoUrl pattern:

1. `BrandingPage.tsx` `handleThemeChange` — `customCss: newTheme.customCss
   ?? currentTheme.customCss` alongside the existing `logoUrl` fallback.
   Closes the propagation hole where the customizer's `handlePresetSelect`
   fires `onChange(preset.config)` (no customCss) and the parent wipes
   it from currentTheme.

2. `BrandingPage.tsx` `handlePresetChange` — preserve `customCss` from
   prev/currentTheme on preset switch, same shape as the existing
   `logoUrl: prev.logoUrl` preservation. Touches both the `setCurrentTheme`
   and the preview-mode `setTheme` paths.

3. `ThemeCustomizerEnhanced.tsx` `handlePresetSelect` — remove the
   `setCustomCss('')` that wiped the local textarea state on preset
   pick. The previous comment ("Clear custom CSS when selecting a preset")
   described the original intent but produced data drift across the
   preset round-trip. The sibling `ThemeCustomizer.tsx` already never
   cleared it; this aligns the two.

Verified against `v3.44.0` and `origin/beta`: identical code on both
branches, so the bug exists on stable + beta. Lint + tsc clean on the
two changed files.

Closes #645.
2026-06-20 23:17:34 +02:00
Paul Nothaft d705059d3c fix(deps): bump qs/brace-expansion overrides + add uuid override for node-cron
Code-scanning Trivy alerts on the open beta (PR #641). Of the 10 open
alerts, 6 are stale (lockfile already past the fix) or live in
floating-tag base images (`nginx:1.28-alpine`, `node:22-alpine`) which
auto-update on the next CI rebuild — no code change needed for those.

The 3 actually present in the current `backend/package-lock.json`:

- `qs 6.15.0 → 6.15.2` (CVE-2026-8723, alert #266). Bump override from
  `>=6.14.2` to `>=6.15.2`.
- `brace-expansion 5.0.5 → 5.0.6` (CVE-2026-45149, alert #264). Bump
  override from `>=5.0.5` to `>=5.0.6`.
- `uuid 8.3.2` transitively via `[email protected]` (CVE-2026-41907,
  alert #265). Add top-level `uuid: ^11.1.1` override so node-cron's
  nested resolution collapses into our root uuid version. node-cron
  uses only `uuid.v4()` — API-stable across v8 → v11. Verified the
  scheduler still constructs tasks under the override.

Lockfile regenerated; net -9 lines (one fewer uuid copy).

Stale alerts that will close on next code-scan rebuild:
- #205 postcss (frontend lockfile already at 8.5.14)
- #221 i18next-http-backend (backend lockfile already at 3.0.6)

Auto-resolved on next image rebuild (no Dockerfile change — floating
tags):
- #267 nginx (frontend `nginx:1.28-alpine`)
- #223 ip-address, #156/#155 picomatch, #140 brace-expansion (all in
  the npm CLI shipped inside `node:22-alpine`)

Refs: code-scanning alerts #264, #265, #266
2026-06-19 09:46:19 +02:00
Paul Nothaft b8211e9944 fix(security): close BOLA on photo-export + NAT64 SSRF in URL guard
Two security advisories landed against the open #641 branch — bundling
both because they touch independent surfaces and PR #641 is the next
beta ship vehicle.

**GHSA-9v4w-jrhx-g5wr (BOLA on /admin/photo-export/:eventId/*)** —
the three /:eventId-scoped routes in `adminPhotoExport.js` (filtered,
filter-summary, export) ran `adminAuth + requirePermission(...)` but
not `requireEventOwnership`, so any non-super-admin admin/editor with
photos.view (or photos.download) could enumerate + export the photos
of events created by other admins — leaking `original_filename`,
which routinely encodes client identity. Sibling `adminPhotos.js`
applies the middleware on every :eventId route; this file was the
single drift. Reporter: Wernerina.

**GHSA-wmjx-pc37-272r (NAT64 SSRF in `isPrivateIPv6`)** — the old
implementation did naive string-prefix checks (`startsWith('fc')`,
`startsWith('fe80')`) and had zero coverage for NAT64
(`64:ff9b::/96` per RFC 6052, `64:ff9b:1::/48` per RFC 8215). On
instances with NAT64/DNS64 egress, a webhook URL like
`http://[64:ff9b:1::a9fe:a9fe]/` translated through the gateway and
reached 169.254.169.254 — exfiltrating cloud metadata (IAM creds)
into `webhook_deliveries.response_body`. Rewrote `isPrivateIPv6` to
expand the address to its canonical 8-group form, block both NAT64
prefixes, decode embedded IPv4 from IPv4-mapped (`::ffff:0:0/96`) and
deprecated IPv4-compatible (`::/96`) forms and re-check via
`isPrivateIPv4`, and fail closed on any parse failure. Reporter:
tonghuaroot.

Added 34 unit tests covering: both NAT64 prefixes in hex + mixed
dotted-quad notation, IPv4-mapped IPv6 hex + mixed, deprecated
::IPv4 form, legacy fc00::/fd00::/fe80::/::1/:: cases stay blocked,
and public IPv6 (Google/Cloudflare/Google IPv6) negative controls
stay allowed.

Refs: GHSA-9v4w-jrhx-g5wr, GHSA-wmjx-pc37-272r
2026-06-19 09:46:19 +02:00
Paul Nothaft a8bb7b439f fix(i18n): wrap WhatsApp token show/hide aria-label through t()
i18n audit caught one straggler — the eye-icon toggle on the access-token
input had a bare `aria-label={showToken ? 'Hide' : 'Show'}` that wouldn't
translate for screen readers on non-English locales. Switched to
`t('common.hide')` / `t('common.show')`; added the matching `common.show`
key in EN + DE (common.hide already existed).

The two remaining `placeholder=` literals in the WhatsApp tab are sample
ID strings (`123456789012345`, `gallery_ready`, `+49123456789`) — those
are identifier/value examples, not translatable English.

Other PR-touched UI surfaces passed the audit clean: 30 new i18n keys
across categories (5), settings.whatsapp (16), settings.features.whatsapp
(2), feedback (3), and the activity-log + bell entries (4) all exist in
both EN and DE.
2026-06-19 08:42:22 +02:00
Paul Nothaft 49bfb45332 fix(settings): hoist tab-visibility useEffect above isLoading early return
Surfaced while exercising Part D (WhatsApp) end-to-end. Navigating to
Settings → WhatsApp triggered React error #310 ("Rendered more hooks
than during the previous render"). Root cause is pre-existing: the
SettingsPage redirect-to-visible-tab `useEffect` lived AFTER the
`if (isLoading) return <Loading />` early return, so on the
isLoading=true→false transition the hook count grew by one and React's
rules-of-hooks invariant blew up.

Move the effect above the early return so the hook count is stable
across renders. While here, switch the gating logic from "is the key in
the currently-visible nav list" (which the bundle couldn't reference
yet because the nav array is built lower down) to a small lookup keyed
by activeTab → matching dependency flag. That's an equivalent decision
for the four tabs we already gated (crm, contracts, reminderTemplates,
accounting) plus the new whatsapp tab.

Add `flagsLoading` from the FeatureFlags context to the deps so the
snap-back only fires once the server's actual flag values have arrived.
Without this, the initial render with the placeholder DEFAULT_FLAGS
would falsely snap away from any tab whose flag is "on" on the server
but absent from the placeholder.

Also add `whatsapp: false` to `DEFAULT_FLAGS` in FeatureFlagsContext
(was missing — TypeScript should have caught the Record<FeatureKey,
boolean> violation but the build pipeline didn't surface it). Without
this, `flags.whatsapp` is undefined on the placeholder, which had
secondary effects on tab visibility and the snap-back logic.

Verified via Chrome DevTools: Settings → WhatsApp now loads cleanly
with all 5 form fields, the saved config values prefilled, the Save
button, and the Send-test card.
2026-06-18 23:55:47 +02:00
Paul Nothaft fabd67aecd feat(feedback): export shape toggle — per-action vs per-guest pivot (#640 part E)
Ports 8digit/picpeak@ed7943b as a TOGGLE rather than a replacement. The
current per-action shape (one row per favourite/like/rating/comment) stays
the default for backward compat with any external scripts consuming the
export; the new pivot shape (one row per (photo, guest_identifier) with
boolean is_favorited/is_liked + star_rating + comment) is opt-in via a
?shape=pivot query param and a dropdown in the admin feedback page.

Pivot wins for "which guests engaged with which photos" analysis in
Sheets / Excel pivot tables. Long wins for engagement timeline analysis
and re-importing into another tool. Different products, both valid.

### Backend

- `feedbackService.exportEventFeedbackPivoted(eventId)`: new method.
  LEFT-of-Map approach, pure JS pivot so PG / SQLite behave identically.
  Key is `(filename, guest_identifier)` — anonymous guests with no
  identifier get a synthetic per-row key so two anonymous comments on the
  same photo don't collapse. Comments: most recent wins (history dropped
  in exchange for "current state" semantics). Hidden-by-moderator rows
  excluded — the pivot represents what we want to surface, not the raw
  event log.
- `adminFeedback.js` export route: accepts `?shape=pivot|long` (default
  `long`). CSV filename now carries the shape (e.g.
  `feedback-pivot-{id}.csv`) so repeated exports don't overwrite.
- `convertToCSV` helper in `adminFeedback.js` gains the three escaping
  improvements that 8digit's commit also shipped: booleans → `yes`/`no`,
  null/undefined → empty, escape strings containing newlines (\n/\r) as
  well as commas/quotes. Comments with line breaks were silently breaking
  CSV row counts before this. Improvements are pure wins regardless of
  shape; archives' own `convertToCSV` copy left untouched (separate
  surface, no behaviour drift risk).

### Frontend

- `feedback.service.ts` `exportEventFeedback()` gains optional `shape`
  parameter, default 'long'.
- `EventFeedbackPage.tsx`: new shape dropdown next to the CSV / JSON
  buttons (defaults to 'long'). Selected shape flows through to the API
  request AND the downloaded filename.

### i18n

3 new EN + DE entries (`feedback.exportShapeLabel`,
`feedback.exportShapeLong`, `feedback.exportShapePivot`).

### Notes

- Pivot shape is **per-guest current state**, not history. A guest who
  rated a photo, then changed their mind and removed the rating, would
  show the final state in the pivot but BOTH actions in the long form.
  Acceptable trade-off: pivot users care about the snapshot, long users
  want the trail.
- `latest_at` column in pivot gives a "most recent activity" timestamp
  per row, useful for sorting/filtering recent engagement.

### Test plan

- [x] Backend syntax + TS check + lint clean (no new warnings; existing
      `catch (error)` warning was pre-existing)
- [ ] Manual: feedback page → select Per-guest (pivot) → Export CSV →
      verify one row per (filename, guest) with is_favorited='yes'/'no',
      latest_at column populated
- [ ] Manual: long shape default still produces the same per-action
      output as before (no regression for existing consumers)
- [ ] Manual: comment containing a newline → pivot CSV escapes correctly,
      row count matches data length + 1 header
- [ ] Manual: archive a published event with feedback → archive's
      `feedback_data.csv` still uses the long shape (archive surface
      unchanged on purpose)
2026-06-18 23:08:55 +02:00
Paul Nothaft 78c8e9d9f9 feat(whatsapp): WhatsApp Business API notification channel (#640 part D)
Ports filpgame/picpeak's WhatsApp integration with substantial adaptation
to fit our codebase patterns. Deliver the gallery-ready notification via
Meta Graph API in addition to (or instead of) email — useful where the
customer base expects WhatsApp by default. Strictly opt-in behind the new
`whatsapp` feature flag.

### Backend

- **Migration 136** (`whatsapp_configs` + `whatsapp_queue`). Loose-FK on
  `event_id` matching our `inbound_documents` / `expenses` pattern (NOT
  filpgame's hard FK — deleting an event shouldn't RESTRICT on stale queue
  rows). Composite index on `(status, retry_count, created_at)` covers the
  poll path.
- **`whatsappService.js`**: thin Meta Graph client. Meta API version bumped
  v19 → v20 (filpgame's v19 deprecates Q3 2026); configurable via
  `WHATSAPP_META_API_VERSION` env var. Timeout dropped 10s → 8s for
  processor budget. Errors surface the Meta `error.code` so the processor
  can tell retryable from permanent.
- **`whatsappProcessor.js`**: queue processor polling every 30s (configurable
  via `WHATSAPP_QUEUE_POLL_MS`), 10 messages per cycle, 3 retries before
  marking `failed`. Default language sourced from
  `app_settings.general_default_language` (matches our email-language
  resolution pattern); replaces filpgame's hardcoded `pt_BR` fallback.
  Falls back to `en_US` if nothing is configured. No-ops gracefully when
  the `whatsapp` flag is off, the config row is missing, or the access
  token isn't set.
- **`adminWhatsapp.js`**: three routes (GET/PUT config, POST test). Gated
  by `requireFeatureFlag('whatsapp')` so operators who haven't enabled it
  can't see the surface. Access token masked as `'********'` on GET;
  masked values silently preserve the stored token on PUT. Enabling with
  no Phone Number ID, template name, or token (and none stored) fails at
  the validator.
- **Two hook points** in `adminEvents.js`:
  - **Create-and-publish-in-one-step**: queues immediately after the
    `gallery_created` email when `!isDraft && customerPhone &&
    waConfig.enabled`. Password from `req.body` is still in scope.
  - **Publish-from-draft** (`POST /:id/publish`): queues with the password
    the admin re-typed via PR #627's `PublishGalleryDialog`. When no
    password was typed (legacy API consumers without dialog), passes empty
    string so the password line renders blank rather than leaking the
    `(set at creation)` sentinel.
- **`server.js`**: starts `whatsappQueueProcessor` at boot. Non-fatal if it
  fails to start (logged as warning).
- **`feature_flags`**: new `whatsapp` flag in `KNOWN_FLAGS` and
  `DEFAULT_FLAGS` (default false).

### Frontend

- **`featureFlags.service.ts`**: `'whatsapp'` added to `FeatureKey` union.
- **`FeaturesTab.tsx`**: WhatsApp card in the Communication section
  (between Incoming mail and Messaging). Smartphone icon, "new" status,
  sidebar-hidden (no sidebar entry — config lives under Settings).
- **`whatsapp.service.ts`** (new): typed client for the three admin routes.
- **`WhatsAppTab.tsx`** (new): Settings tab. Form for Phone Number ID,
  WABA ID, access token (masked toggle), template name, and enabled flag.
  Separate card below for a static test send. Token masking matches the
  server's `'********'` sentinel — admin can edit other fields without
  re-entering the token.
- **`SettingsPage.tsx`**: WhatsApp tab nav item gated on `flags.whatsapp`
  (so it shows only when the feature is enabled); render block wires
  `<WhatsAppTab />`.

### i18n

22 new EN + 22 new DE entries covering the Settings tab form, the
Features-tab card, plus `admin.activities.whatsapp_config_updated` +
`admin.notificationMessages.whatsappConfigUpdated` for the bell /
dashboard surfaces from PR #637.

### Deliberately NOT included

- filpgame's **password-encryption-at-rest** layer
  (`password_encrypted`/`password_iv`/`password_key_version` columns).
  Our publish-from-draft password recovery uses the admin re-type flow
  from #627 (PublishGalleryDialog) — no plaintext at rest.

### Setup notes for operators

1. Create a Meta Business Account + WhatsApp Business App.
2. Register a phone number and obtain `phone_number_id` + `waba_id`.
3. Create a system-user access token (long-lived recommended).
4. Submit a message template for approval. The default `gallery_ready`
   expects 5 body parameters: customer name, event name, gallery link,
   password line, expiry date.
5. Enable the `whatsapp` feature flag.
6. Enter credentials under Settings → WhatsApp, send a test, then enable
   delivery.

### Test plan

- [x] Backend `node -c` on all new/changed files clean
- [x] `tsc --noEmit` on frontend clean
- [x] Backend dev container restart picks up new files, /health OK
- [ ] Manual: enable `whatsapp` flag → Settings → WhatsApp tab appears
- [ ] Manual: save config with masked-only token (existing token preserved)
- [ ] Manual: enable=true without phone_number_id rejected at PUT
- [ ] Manual: enable=true without stored or new token rejected at PUT
- [ ] Manual: create-and-publish event with customer_phone → queue row
      inserts with message_type='gallery_created'
- [ ] Manual: publish-from-draft via PublishGalleryDialog with password →
      queue row uses the admin-typed password in the {{4}} line
- [ ] Manual: test send to a real phone with valid Meta config + approved
      template → Meta returns messages[0].id, toast shows the id
- [ ] Manual: bell renders "WhatsApp configuration updated" in DE when
      the config_updated activity fires (via PR #637 smart default)
2026-06-18 22:57:22 +02:00
Paul Nothaft a3fcb5bc9e feat(common): generic Promise-based ConfirmDialog primitive (#640 part C)
Ports 8digit/picpeak@88bfde1 — replaces `window.confirm()` with a styled,
themed, accessible in-app modal. Usage:

    const confirm = useConfirm();
    const ok = await confirm({
      title: 'Delete event?',
      message: 'This will permanently remove the gallery and all photos.',
      variant: 'danger',
      confirmLabel: 'Delete',
    });
    if (ok) doDelete();

Three variants: 'primary' (default, no icon), 'danger' (red AlertCircle +
red confirm button), 'warning' (amber AlertTriangle). Keyboard support:
Escape cancels, Enter confirms (unless focus is in an input/textarea/select
so an open form doesn't get hijacked), backdrop click cancels. Cancel button
is focused by default — a stray Enter cannot accidentally confirm a
destructive action.

Wraps at App.tsx level, inside GlobalThemeProvider so the modal respects
the theme tokens, above the toast container so a confirm appearing under a
toast still gets the click. Provider exports through components/common
alongside the rest of the shared primitives.

This PR only lands the primitive. Existing window.confirm() call-sites are
left untouched — sweeping them is follow-up work that can land in any
cadence (each sweep is one component, no architectural risk). Existing
structured-input flows (PublishGalleryDialog, DuplicateEventDialog,
PasswordResetModal, etc.) stay as-is — they collect data, not yes/no.

No new i18n entries — uses common.cancel / common.confirm / common.close
which already exist in EN + DE.

### Test plan

- [x] tsc --noEmit clean
- [x] eslint clean on changed files
- [ ] Manual: pick any existing window.confirm() site (e.g. EventDetailsPage
      delete button), swap to useConfirm(), verify the modal renders with
      theme tokens, Escape cancels, Enter confirms, backdrop click cancels,
      focus lands on Cancel
- [ ] Manual: variant='danger' renders red confirm button + AlertCircle icon
- [ ] Manual: open the dialog from inside another modal (e.g. a settings
      panel) — z-[9999] keeps the confirm on top of any other overlay
2026-06-18 22:36:40 +02:00
Paul Nothaft 820f4835f1 feat(categories): per-category download permissions (#640 part B)
Adds an `allow_downloads` boolean to `photo_categories` so admins can
have different download policies per category — e.g. preview categories
public, originals client-only. AND's with the event-level `allow_downloads`,
so disabling at either level blocks downloads for that category's photos.

Defaults to true so categories created before migration 135 keep working
without admin intervention.

Credit: 8digit/picpeak@928164b + @751ec75.

### Backend

- **Migration 135**: additive `allow_downloads BOOLEAN NOT NULL DEFAULT true`
  on `photo_categories`, hasColumn-guarded + sane down.
- **`adminCategories.js`**: PUT /:id accepts optional `allow_downloads` patch.
- **`gallery.js`**:
  - `GET /:slug/photos` returns `allow_downloads` per category AND
    `category_allow_downloads` per photo.
  - `GET /:slug/download/:photoId` returns 403 when the photo's category
    disables downloads.
  - `GET /:slug/download-all` LEFT JOINs `photo_categories` and filters
    `whereNull(category_id) OR allow_downloads=true OR allow_downloads IS NULL`.
    The null check covers pre-migration-135 rows during the upgrade window.
  - `POST /:slug/download-selected` same filter pattern.

### Frontend

- **`categories.service.ts`**: `updateCategory()` gains an optional `patch`
  argument carrying `{ allow_downloads }`. PhotoCategory interface gains the
  optional field.
- **`EventCategoryManager.tsx`**: new toggle button next to the delete X.
  Green DownloadCloud icon when downloads are on, plain Download icon when
  off. Click toggles via the new mutation; toast confirms.
- **`PhotoLightbox.tsx`**: `photoAllowsDownload = allowDownloads && currentPhoto?.category_allow_downloads !== false`. Hides the download button +
  blocks the 'D' keyboard shortcut + early-returns from handleDownload.
- **Types**: Photo interface gains `category_allow_downloads`.
- **i18n**: 5 new EN + DE entries for the toggle button toast + tooltip.

No global-category surface change yet — global categories don't currently
have a UI for the toggle. Admins can still flip the column directly via SQL
or via a future global-categories editor.

### Test plan

- [x] Backend syntax + TS check clean
- [x] ESLint: no new warnings
- [ ] Manual: admin → event detail → categories panel → click DownloadCloud
      icon → category flips, toast confirms
- [ ] Manual: gallery (guest) → photo in disabled category → lightbox shows
      no download button, 'D' shortcut is a no-op
- [ ] Manual: download-all on a gallery with one disabled category →
      ZIP excludes that category's photos
- [ ] Manual: download-selected including a disabled-category photo → 404
      (filtered out) and the response carries only the allowed selection
- [ ] Manual: pre-migration-135 category (legacy row with NULL allow_downloads)
      → downloads still work (defaults true via fallback)
2026-06-18 22:29:35 +02:00
Paul Nothaft e4e79a0b3a fix(archives): stream-extract restore for >2 GiB + preserve original_filename via manifest (#640)
Two related backup-integrity fixes from 8digit's fork (issue #640 items
#3 + #4), bundled because they touch the same two files and ship better
together than apart.

### Stream-extract restore for >2 GiB archives

`adminArchives.js:170` was using `adm-zip`, which loads the entire ZIP
into a Node Buffer before extracting. Node has a hard 2 GiB Buffer cap,
so any restore over that limit fails with `ERR_FS_FILE_TOO_LARGE` — and
since the frontend `onError` toast is the generic "Something went wrong",
the cause stays invisible. Real-world wedding archives routinely cross
2 GiB; affected restores have likely been silent failures.

Swapped `adm-zip` for `node-stream-zip` which streams each entry to disk
as it's processed — no full-file Buffer, no 2 GiB ceiling. API shape:

```js
const zip = new StreamZip.async({ file: archivePath });
const entries = Object.values(await zip.entries());
await zip.extract(null, eventDir);
await zip.close();
```

Re-import logic (photos, categories, sizes) unchanged; only field rename
`entry.entryName` → `entry.name`. Credit: 8digit/picpeak@69033c6.

### Preserve `original_filename` via photos manifest

Archive → restore round-trip currently loses `original_filename` (the
post-#508 column tracking the camera-side name) because the gallery
filenames are renamed on upload and can't be derived from the extracted
files. This matters now that the Lightroom export (#623) depends on
`original_filename` — a restored event lost that signal.

- **`archiveService.js`**: writes `photos_manifest.json` into the archive
  containing per-photo `{filename, original_filename, type, uploaded_at,
  category_name}`. Non-fatal: a manifest write failure falls through to
  legacy behaviour (filename used as original_filename, same as before).
- **`adminArchives.js`**: reads the manifest on restore, builds a
  `Map<filename → manifest>`, and assigns
  `original_filename = manifest?.original_filename || filename`.
  Archives produced before this lands have no manifest — restore logs a
  one-shot notice and falls back to filename, preserving backward compat.

Credit: 8digit/picpeak@eb018aa.

### Deps

- Removed `adm-zip ^0.5.16`
- Added `node-stream-zip ^1.15.0`

### What's NOT in this PR

8digit's commit also fixed the production compose healthcheck (`curl`
isn't in our Alpine image); that's already been addressed upstream in
the meantime. The frontend `onError` swallow on the restore toast is a
separate small follow-up.

### Test plan

- [x] `node -c` on both files clean
- [x] `node-stream-zip` async API verified at load time
- [ ] Manual: archive a multi-GB event → restore → confirm photos
      re-import with original_filename preserved
- [ ] Manual: restore an archive produced before this lands → confirm
      fallback to filename works (no manifest path crashes)
- [ ] Manual: confirm the new photos_manifest.json is inside the
      generated archive (`unzip -l <archive>.zip | grep manifest`)
2026-06-18 22:18:54 +02:00
Paul Nothaft eaed00fcca Merge remote-tracking branch 'origin/beta' into fix/i18n-activity-types-comprehensive
# Conflicts:
#	frontend/src/i18n/locales/de.json
#	frontend/src/i18n/locales/en.json
2026-06-18 21:28:18 +02:00
Paul Nothaft 997a41293e fix(i18n): sweep Events / API Tokens / Webhooks settings tabs
Continuing the activity-type i18n sweep from this PR: three settings
tabs still had hardcoded English strings (or referenced i18n keys that
didn't exist in either locale).

EventsTab (Settings → Event Creation):
- defaultFeedbackEnabled + defaultFeedbackEnabledHelp were referenced
  by the component but missing from both locales. The inline-default
  English text leaked through to German users.

ApiTokensTab (Settings → API Tokens):
- "Preview" table-header column was a bare string literal; now wraps
  through t('settings.apiTokens.preview').
- confirmRevoke called t() with a backtick template-literal default
  ("Revoke \"${token.name}\"…"). The interpolation happened at the
  default-string level, so the actual translated string never received
  the name and shipped without it. Switched to the i18next {{name}}
  parameter pattern with the matching value in en+de.

WebhooksTab (Settings → Webhooks):
- Half the tab was still hardcoded English. Wired everything through
  t(): toast messages (createError, updateError, deletedToast,
  deleteError, copied, copyFailed), Just-Created Secret card buttons
  (Copy, Dismiss), form placeholders (name, URL, template), advanced
  toggle label, filter and template help paragraphs, the filterError
  setter, all six table headers, the eventsSubscribed count (with
  proper {{count}} pluralisation), the status badge (Active/Disabled),
  the active/inactive title tooltips, the Deliveries link, the Delete
  button, and the delete-confirm dialog (proper {{name}} interpolation
  instead of the broken template-literal-in-default-string pattern).

Added 34 new key/value pairs to each locale; counts now symmetric at
events=28, apiTokens=23, webhooks=43 in both EN and DE.

DE wording authored natively; tone matches the existing maintainer-
voice style.
2026-06-18 21:16:30 +02:00
Paul Nothaft bc8d3330bb fix(i18n): sweep activity-type translations + smart notification fallback
The admin notification bell and dashboard "Recent Activities" panel were
showing raw snake_case keys ("event_published") or the generic
"Systemaktivität: <type>" fallback for ~65 activity types — most of them
from the CRM and Accounting modules added since #555. Users with German
locale saw the gap most visibly because the English placeholder leaked
through.

Three pieces:

1. notifications.service.ts — smart `default:` branch. Instead of falling
   straight to the systemActivity template, derive the camelCase i18n key
   from the snake_case type, try resolving `admin.notificationMessages.<camelCase>`
   directly with the full metadata spread as params, and only drop to the
   legacy template when no specific translation exists. This means every
   future activity type just needs an i18n entry — no per-type switch
   case to add.

2. en.json + de.json — added 65 missing `admin.notificationMessages.*`
   bell entries and 58 missing `admin.activities.*` dashboard entries
   across both locales. Covers Contracts (13), Quotes (7), Invoices /
   Storno (12), Monthly billing (5), Expenses (4), Hours (5), Incoming
   invoices (6), Customers (1), Admin user mgmt (3), and 9 misc /
   legacy types (bulk_archive_completed, email_resent, email_queue_flushed,
   email_template_created, event_duplicated, feedback_deleted,
   feedback_moderated, feedback_settings_updated, word_filter_added).
   Both locales finish symmetrical (149 activities / 136 notifications
   each, vs. 91 / 71 before).

3. admin.service.ts `formatActivityMessage` messages dict — added the
   same 58 English-only entries as a last-resort fallback for the
   dashboard when i18n itself fails to load. Keeps the surface
   resilient against bundle-load issues.

Metadata field names in the new translations match what the backend
writes via `logActivity()` — `{{contractNumber}}`, `{{quoteNumber}}`,
`{{invoiceNumber}}`, `{{username}}`, `{{template_key}}`,
`{{source_event_name}}`, `{{word}}` — verified against the call sites
in contractService, quoteService, invoiceService, userManagementService,
expenseService, adminEvents, adminEmail, adminFeedback.

DE wording authored natively; tone matches the existing terse,
maintainer-voice style of the rest of the file.
2026-06-18 21:10:12 +02:00
Paul Nothaft 27b5f7e4b6 feat(admin/exports): inline preview modal with copy-to-clipboard (#631)
Follow-up to #623. The Lightroom TXT export now shows the filename list
in a modal with a "Copy to clipboard" button instead of triggering a
.txt file download — saves the "open file → select all → copy" dance
admins were doing anyway. CSV export takes the same path (paste straight
into Sheets / Excel).

The modal keeps a "Download as file" button so admins who want the file
(sharing with colleagues, archiving, post-processing tooling) aren't
worse off than before — fully additive.

XMP (ZIP archive) and JSON exports keep their direct download path. A
textarea preview is the wrong UI for a binary archive, and JSON is
structured tool input where the file form is the natural mode.

Implementation:

- ExportPreviewModal — readonly textarea, copy + download buttons,
  monospace font for filename lists, click-to-select-all on the textarea
  for browsers that block clipboard writes (older Safari, hardened
  sandboxes — the catch falls through to a "select and copy manually"
  toast instead of silent failure).
- photosService.exportPhotosAsText — same backend endpoint as
  exportPhotos but resolves the blob.text() and returns
  { content, filename } instead of triggering a download. Preserves
  the existing exportPhotos for the XMP / JSON paths.
- PhotoExportMenu — PREVIEW_FORMATS = ['txt', 'csv']; non-preview
  formats keep the direct-download flow unchanged.
- EN + DE i18n entries.

No backend changes. No new endpoints. No breaking changes for callers
of photosService.exportPhotos.
2026-06-17 23:35:19 +02:00
Paul Nothaft e985d25207 feat(events): duplicate-gallery action (#626)
Daniel asked for a way to re-use a good gallery configuration without
re-entering every setting. Two of his three suggested workflows are
covered by this PR; the third (per-event-type behaviour defaults) is
partially shipped already via event_types.theme_preset + theme_config
and is left as a follow-up if the duplicate workflow doesn't cover it.

Backend — POST /admin/events/:id/duplicate. Validates a new event_name
(required) + event_date (optional) + customer_name/email (optional);
copies branding (color_theme, css_template_id, header/hero/divider/anchor),
behaviour toggles (allow_downloads, watermark_*, allow_user_uploads,
require_password, etc.), photo_cap, welcome_message, default_photo_sort,
admin_email, and feedback settings + per-event photo categories. Mints a
fresh slug + share_token + random-placeholder password_hash (admin sets
the real one via the publish dialog shipped in #627). Recomputes
expires_at = new_event_date + (source.expires_at - source.event_date) so
the duplicate keeps the same active window; defaults to 30 days if either
source field was null. is_draft is always true.

Deliberately NOT carried over: photos, hero_photo_id, client_access
secrets, og_image_share opt-in, customer_phone, sent_at flags, archive
state, customer-account assignments.

Frontend — new DuplicateEventDialog (matches the PublishGalleryDialog
pattern), wired into the Actions card on EventDetailsPage. Visible in
both draft and live mode since admins typically duplicate from a
published gallery. On success the page navigates to the new draft so the
admin can finish customising + publish.

I18n: EN + DE entries for the dialog + button label. Backend logs an
event_duplicated activity with the source event id/name so the trail is
auditable.

Frontend service: eventsService.duplicateEvent(eventId, data).
2026-06-17 23:16:12 +02:00
Paul Nothaft 714a9f6fb1 fix(upload): auto-throttle on low-memory hosts + correct documented RAM minimum (#628)
The README claimed 2GB RAM as the minimum, but two background-processor
worker loops × sharp.concurrency(2) means up to four libvips threads can
decode full-resolution images in parallel — peak RSS lands at 1.5GB+ on
a batch of 20MP+ photos. Add Postgres + Redis + Node baseline and one
heavy batch on a 2GB VPS OOM-kills the backend, surfacing as 503s on
thumbnails until restart:unless-stopped brings it back. Reported in #602,
filed as #628.

Three changes, smallest-surface-area each:

1. backgroundProcessor.js — on startup, when UPLOAD_PROCESSOR_CONCURRENCY
   is NOT set and os.totalmem() reports < 3GB, default to 1 instead of 2
   and log a one-shot warning naming the override env var. Explicit env-var
   setters keep their value. os.totalmem() reports container memory under
   cgroup v2 so this works in Docker / k8s as well as bare metal.

2. README.md — bumped the documented minimum from 2GB to 4GB, kept 2GB
   only as a "Low-memory hosts" recipe pointing at UPLOAD_PROCESSOR_CONCURRENCY=1
   with the throughput trade-off spelled out. Added the 503-on-OOM symptom
   so the next reporter finds it via search.

3. docker-compose.production.yml — commented mem_limit / memswap_limit
   example on the backend service. Off by default (don't surprise existing
   deployments) but visible to operators thinking about shared/multi-tenant
   hosts. restart:unless-stopped already on every service.

No code path for memory-aware runtime throttling (Luca's option 4) — out
of scope for a bug fix; tracked separately if #1-#3 don't close the case.
2026-06-17 23:04:30 +02:00
Paul Nothaft 83b568ee2d fix(events): publish-from-draft email carries the real password (#627)
Previously, publishing a password-protected DRAFT gallery sent the
gallery_created email with the literal sentinel "(set at creation)",
which the email processor localised to "The password you set when
creating the gallery" / "Das bei der Erstellung der Galerie gesetzte
Passwort". Root cause: at draft creation only the bcrypt hash is stored
(no plaintext column, by design); the publish endpoint had nowhere to
pull the actual password from. Create-and-publish-in-one-step worked
because the plaintext is still in memory at email-queue time.

Fix: the Publish action now opens a small PublishGalleryDialog that
prompts the admin to (re-)type the gallery password. The publish
endpoint accepts an optional `password` body, re-hashes + writes
`password_hash` so the stored hash matches what was just emailed (admins
who mistype at creation get a self-healing publish flow), and puts the
plaintext into the gallery_password email field. When the publish call
is made without a password (API-only consumers), behaviour falls back
to the legacy sentinel — no breaking change.

The window.confirm() publish flow is gone; the dialog handles the no-
password case too (plain confirm + Publish button).

I18n: EN + DE entries for the dialog. Other locales fall through to
the EN defaults via the t() default-value pattern.

No schema changes. No plaintext at rest.
2026-06-17 22:58:17 +02:00
Paul Nothaft ea6245cfde fix(gallery): admin edits to welcome_message land for returning guests (#625)
GalleryAuthContext cached the event in sessionStorage on first visit and
then SKIPPED the server fetch on returning visits (`if (!storedEvent)`),
so a guest who'd already opened the gallery would never see admin edits
to welcome_message / event_name / hero_logo / colour theme — sessionStorage
survives Cmd+Shift+R, so the only escape was closing the tab or wiping
site data manually.

The cached event is still shown above as an instant placeholder for
perceived perf, but the server fetch is no longer gated: on every mount
the fresh row overwrites both React state and the sessionStorage entry.
Cost is one extra /gallery/:slug/photos request per gallery navigation
when the session is already authenticated; benefit is admin edits
propagating on next page load for everyone.
2026-06-17 22:47:48 +02:00
Paul Nothaft 178d6dafb1 fix(gallery): leave a visible gap between filter bar and hero header (#624)
When a gallery uses the 'hero' header_style AND the admin enables the
filter bar (search + sort), the search/sort row glued itself to the top
of the hero image. Root cause: HeroHeader carries a decorative `-mt-6`
on its outer div (so it can bleed flush against the page header when
nothing else is above), and that exactly cancelled the wrapper's `mt-6`
between PhotoFilterBar and PhotoGridWithLayouts.

Fix: when the filter bar is shown above a hero header, the grid wrapper
uses `mt-12` instead of `mt-6` so the hero's bleed leaves a 24px net gap
rather than zero. The no-filter-bar case keeps the original flush bleed.

Also tidied up: extract the filter-bar-shown predicate to a named const
so the two reads (conditional render + wrapper class) can't drift apart.
2026-06-17 22:31:04 +02:00
Paul Nothaft a239fec9d7 fix(admin/exports): Lightroom TXT export joins with comma + drops extension (#623)
The PhotoExportMenu's TXT format advertises "Simple text list for Lightroom
search" but emitted newline-separated filenames WITH `.jpg`. Lightroom's
filename search wants a comma-separated one-liner, and the gallery JPEGs may
correspond to RAW files in the catalog — so the search has to match on the
stem only.

The frontend now passes `separator: 'comma'` + `include_extension: false` for
the TXT format specifically. The backend gains an `include_extension` option
(defaulting to true so direct API consumers don't break), and the comma case
joins without a trailing space (the form Lightroom expects). Unit test pins
the Lightroom-mode output AND the backward-compatible default for any direct
API caller.

CSV / XMP / JSON exports are unchanged.
2026-06-17 22:25:12 +02:00
Paul Nothaft 69b5186582 fix(gallery): guest upload honours general_max_files_per_upload + i18n placeholder interpolates (#613)
Zszywany reported on v3.44.0 (Ubuntu, Postgres 15): with Settings →
General → "Max Files per Upload" set to 10, an event configured with
allow_user_uploads + a guest selecting 16 files in the gallery's
"Upload Photos" modal succeeded silently — admin uploads to the same
event correctly refused with "Upload limit reached". On top of that,
the gallery modal's "fileRequirements" hint literally rendered
`{{limit}}` instead of the configured number.

Two separate misses for the guest path, both fixed here:

1. **Backend enforcement** — `backend/src/routes/gallery.js:1641` had
   `limits: { fileSize: 50MB, files: 10 }` and `.array('photos', 10)`
   hardcoded. The admin path at adminPhotos.js:131 has always resolved
   files-per-batch via `getMaxFilesPerUpload()` (cached 60s read of
   `general_max_files_per_upload`); guest path just never used it.
   Mirror the admin: `const maxFilesPerUpload = await getMaxFilesPerUpload()`
   and feed multer both `limits.files` AND the `.array(...)` cap. The
   50MB per-file size is a separate concern from this issue and stays
   as-is for now.

2. **i18n interpolation missing on the guest modal** —
   `UserPhotoUpload.tsx:203` called `t('upload.fileRequirements')` with
   no arguments. The translation string at `en.json:160` is
   "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)"
   — `{{limit}}` is unbound, so i18next emits it literally. The admin
   variant `PhotoUpload.tsx:414` correctly passes
   `{ limit: maxFilesPerUpload }`.

   Also wired up the same client-side count guard the admin component
   uses: addFiles refuses additions past the limit (`upload.limitReached`)
   and warns on partial-truncate (`upload.someFilesSkipped`). Backend
   enforces too, but the client guard saves a 4MB+ multipart POST when
   the user is clearly over.

To surface the setting on the guest side, `general_max_files_per_upload`
joins the public-settings whitelist + projection (publicSettings.js)
and the `PublicSettings` TS interface gets the new field. Default
fallback (500, matching `uploadSettings.js` DEFAULT_MAX_FILES_PER_UPLOAD)
in both backend projection and frontend reader so an install that's
never set the value renders a sensible number rather than "undefined".
2026-06-10 18:22:34 +02:00
Paul Nothaft 457c956386 fix(admin/events): delete cascade orphaned photo folders because it read a non-existent column (#608)
jodrmx reported on v3.44.0 (Pi Lite, Docker compose): admin-UI event
delete removes the DB row but leaves `storage/events/active/<event>/`
intact on disk.

Root cause: `deleteEventCascade` in adminEvents.js read
`event.folder_path` and gated the `fs.rm` on it. That column is NEVER
WRITTEN anywhere in the codebase — grep confirms two reads in this one
function, zero writes elsewhere. So `event.folder_path` was always
undefined, `if (event.folder_path)` always false, and the per-folder
cleanup silently no-op'd for every delete. The DB-cascade transaction
ran fine, so the symptom was always "row gone, files stay" — exactly
what jodrmx hit.

The actual on-disk location is `events/active/{slug}` everywhere else
in the codebase:
  - adminPhotos.js:260 — `path.posix.join('events/active', event.slug)`
  - adminEvents.js:610, events.js:155, adminThumbnails.js:153 — read
    from `events/active/{slug}`
  - adminArchives.js:171 — reads from same root
  - photoResolver.js:14-15 — documents the layout
The delete cascade was the only path looking at the non-existent column.

Cure: drop the `if (event.folder_path)` guard, read `event.slug`
instead, and remove from both `events/active/{slug}` (active gallery
folder) and `events/archived/{slug}` (the post-archive copy that
survives the archive flow). `event.slug` is NOT NULL and slugify-
sanitized (lower-case ASCII + dashes only via utils/slug.js), so the
path is well-formed and path-traversal-safe. Best-effort `fs.rm`
semantics + try/catch unchanged — failures still log a warning rather
than unwinding the DB transaction, since orphan files are recoverable
noise compared to a half-deleted DB row.

Forward fix only — does not retroactively clean up the orphans that
have accumulated on existing installs. Admins can `rm -rf
storage/events/active/<old-slug>` manually for those; not worth a
migration script for a one-time deploy ritual.
2026-06-08 23:01:32 +02:00
Paul Nothaft 620163f2db fix(downloads): transliterate accented characters in filename via NFD instead of dropping them (#607)
patchingfailed reported on v3.44 stable: a gallery named `Ägypten` with
photo `Ägypten_individual_0050.jpg` downloads as `gypten_...` —
the leading umlaut is dropped entirely. Their hypothesis was a
Content-Disposition encoding issue, but the actual root cause sits
one layer earlier: at UPLOAD time when `generatePhotoFilename` calls
`sanitizeFilename`.

`sanitizeFilename` did:
  String(str).trim()
    .replace(/\s+/g, '_')
    .replace(/[^a-zA-Z0-9_\-\.]/g, '')   // ← drops `Ä` outright
    .replace(/[_\-]{2,}/g, '_')
    .replace(/^[_\-]+|[_\-]+$/g, '');    // ← would strip a leading _ too

For `Ägypten`: alphanumeric-strip → `gypten` (Ä gone, no underscore
left behind because the regex used '' as the replacement, not '_'). The
result is stored in `photos.filename` and that's what downloads serve.
By that point `buildContentDisposition` is doing the right thing
(emits both `filename="..."` ASCII fallback AND RFC 5987
`filename*=UTF-8''…` — Chrome correctly picks the UTF-8 form), but the
string it's encoding has already lost the umlaut at the DB layer.

Cure: NFD-normalize + strip combining marks BEFORE the alphanumeric
strip. Same pipeline `utils/slug.js` (#525) already uses for URL
slugs:

  sanitized = sanitized
    .normalize('NFD')
    .replace(/[̀-ͯ]/g, '');

Now `Ägypten` → NFD-decomposed `A` + combining diaeresis → strip
combining mark → `Agypten` survives the alphanumeric pass. Filename
and URL slug stay in sync (the URL was already `Agypten`, per
patchingfailed's report — the filename now matches).

Test surface: new `filenameSanitizer.test.js` pins:
- the headline #607 contract for German / Portuguese / French / Spanish
  accented inputs (with a counter-example using the pre-fix pipeline so
  a future edit can't quietly regress it)
- ASCII-input parity — pre-#607 byte-identical output for every
  pre-existing ASCII case
- `generatePhotoFilename` composed round-trip
- `sanitizeForContentDisposition` + `buildContentDisposition` RFC 6266
  dual-form output (since the helper sits next to this function and is
  the next thing to break if a refactor goes sideways)
- `sanitizeForZipEntry` path-traversal blocking

31 cases total, all pass.

Bundled into PR #609 since it's a small targeted fix and that PR is
already an admin-UI polish branch with low review weight.
2026-06-08 18:05:46 +02:00
Paul Nothaft f51b9cf8df fix(admin): graceful logo-img fallback + show sidebar widgets during perm hydration (#523 follow-up 2)
Two issues Rekoo-PS hit immediately after upgrading to v3.60.3-beta.0:

1. **Broken logo URL rendered the browser's broken-image icon + alt
   text.** Their `<img src={resolvedLogoUrl}>` had no `onError` handler,
   so a 404 / slow logo URL produced the default broken-image rendering
   — which uses the `alt` attribute (`companyName`) as text. Visually it
   looked like the wordmark span had unexpectedly re-appeared on phone,
   even though the actual `<span>` was correctly hidden by the existing
   `wordmarkVisibilityClass` logic.

   Fix:
   - `useState` tracks `logoLoadError` (first failure) and
     `fallbackLoadError` (second failure). On a configured-URL miss the
     `<img>` swaps to the bundled `/picpeak-kamera-transparent.png`; on
     a second miss the `<img>` is removed from the DOM entirely.
   - `useEffect([resolvedLogoUrl])` resets both flags when the URL
     changes, so a dark-mode toggle that flips `lightLogo ↔ darkLogo`
     gets a fresh attempt instead of being permanently sad.
   - `wordmarkVisibilityClass` now derives from `logoEffectivelyVisible`
     (showLogo && !fallbackLoadError) — when both the configured URL
     AND the bundled fallback have failed, the wordmark un-hides on <sm
     so the phone header isn't completely empty.

2. **Sidebar VersionInfo + StorageInfo vanished during the
   permission-hydration window.** The bottom block was gated on
   `hasPermission('settings.view')` directly, which returns `false`
   while `PermissionsContext.isLoading` is still resolving (a few
   hundred ms right after a deploy when the auth context bootstraps).
   Net effect: the whole "Version / Storage" block was absent on first
   paint, then re-appeared once permissions hydrated — Rekoo-PS read
   that flash as "backend version + storage missing".

   Fix: gate on `permissionsLoading || hasPermission('settings.view')`.
   Optimistic render during hydration; permitted users see the widgets
   immediately (with each widget's own internal loading state), denied
   users still see nothing once the permission state lands as `false`.

Side benefit: the `<img>` fallback chain also covers the broader "logo
hosted on a flaky CDN" case for self-hosters, not just the one-time
post-upgrade asset-cache hiccup. Pure resilience polish — no behaviour
change when everything works.
2026-06-08 17:38:01 +02:00
Paul Nothaft fe10191b82 fix(admin-header): skeleton brand block + move LanguageSelector into profile menu on <sm (#523 follow-up)
Two complaints in Rekoo-PS's 3.60.1-beta.0 follow-up screenshots:

1. "Logo took some time to load" — header appeared empty for the
   ~hundreds-of-ms window between admin mount and `usePublicSettings()`
   resolving. The previous code rendered the static fallback
   `/picpeak-kamera-transparent.png` during that window, which often
   either 404'd or loaded after the rest of the chrome, and because the
   wordmark is `hidden sm:inline` whenever a logo is intended to be
   shown, phone-width admins saw an empty left cluster instead of
   anything.

   Cure: render a small pulsing skeleton block (h-8 w-8 on <sm, w-32
   on sm+) while `brandingLoading === true`. Same h-8 footprint as the
   real logo image so there's no layout shift when the real payload
   arrives. Once the public-settings query settles, the normal brand
   block renders against known state.

2. "Moving the languages inside the profile tab" — Rekoo-PS argues
   language is set-once and shouldn't occupy permanent header real
   estate on mobile (4 widgets in the right cluster on phone is
   crowded). I agree.

   On <sm: header LanguageSelector is hidden (`hidden sm:block` wrapper
   around the existing component). A collapsible Language section is
   added at the top of the user-menu dropdown showing the current
   flag/name + chevron-down. Expanding shows the 8 supported languages
   as inline rows highlighting the active one. Picking a language fires
   i18n.changeLanguage and closes the menu.

   On sm+: header LanguageSelector stays where it was. The user-menu
   Language section is suppressed (`sm:hidden`) so the same control
   isn't surfaced twice.

Also: `useOnClickOutside(userMenuRef, …)` and the in-menu action
handlers now route through a shared `closeUserMenu()` helper that
also resets the lang sub-section state, so re-opening the menu
doesn't surprise the user with the language list still expanded.
`SUPPORTED_LANGUAGES` re-exported from `components/common` so
AdminHeader doesn't reach into `LanguageSelector.tsx` directly.

No behaviour change on `sm+` — pure phone-view layout fix +
loading-state polish. Locales unaffected (uses the already-existing
language names from SUPPORTED_LANGUAGES).
2026-06-04 21:18:51 +02:00
Paul Nothaft 29e63e5ce5 fix(notifications): restore /clear-all route the frontend already calls (#597)
The AdminHeader "Clear All" notifications button has been 404'ing for
a while: frontend `notifications.service.ts` calls
`DELETE /admin/notifications/clear-all`, backend only defined
`DELETE /admin/notifications/clear-old`.

The /clear-old route was misleadingly named anyway — it tried to
delete read OR >30-days-old rows, then had a fallback that nuked
EVERY row when nothing matched. Both the frontend and the existing
test expect a simple Clear All shape, so just rename to /clear-all,
drop the tiered logic, and return the plain
`{ message, deletedCount }` payload the test asserts on.

The test (adminNotifications.test.js) was hiding the breakage —
it was on CI's --testPathIgnorePatterns ignore list and so never
ran. Two reasons it failed locally before this fix:
  1. Route path mismatch (the actual #597 bug).
  2. The mock only stubbed adminAuth — requirePermission lives in
     its own middleware module and ran for real, 403'ing before
     the handler. Add a passthrough mock for that too.

With both fixed, the test passes. Drop adminNotifications from the
CI ignore list so future regressions in this route fail loudly
instead of going to ground.
2026-06-01 19:31:44 +02:00
Paul Nothaft c246fd3cc8 fix(admin-header): hide wordmark on <sm when logo also shows (#523)
Rekoo-PS's v3.59.0-beta.0 screenshot showed a different shape than
the truncate fix in e7cf834 addressed. Their company name ("Arkan
Studio") isn't unusually long, but with logo_and_text display mode
on a phone-width viewport the wordmark wrapped to two lines and the
LanguageSelector button — sitting in the right action cluster —
landed visually on top of the wrapped second line.

Truncate alone left "Arkan Studio" rendered as "Ar..." after the
logo image. Functional but ugly, and on accounts where the wordmark
reaches the right cluster the visual overlap returns. Match what
LanguageSelector does for its language name in #527: hide the
wordmark on <sm when a logo is also showing (the logo carries the
identity), keep it on sm+. text_only mode is unchanged — wordmark
shows on every width, otherwise nothing would render.

Truncate stays in place as defensive depth for the text_only path.
2026-05-31 23:07:19 +02:00
Paul Nothaft 8c6525af01 test(v1/events): update mock chains to cover new app_settings probes
The #592 fix added a devtools-detection probe, and the #592 follow-up
added a require_password probe + a branding-defaults whereIn().select().
Both shift the db() call indices the existing #550 test relied on, and
the branding probe needed `.select()` to resolve to an array (the mock
chain wasn't thenable, so `for..of` on the result threw → 500 on every
test that hit BASE_BODY).

Add `whereIn` + `selectResult` to buildChain so the branding probe
yields an iterable. Factor the three pre-slug app_settings chains into
a baseSettingsChains() helper and update each test's queued sequence
and toHaveBeenNthCalledWith / toHaveBeenCalledTimes expectations to
match the new shape. No behaviour change in v1/events.js — only the
test scaffolding moves.
2026-05-31 23:02:51 +02:00
Paul Nothaft 791e9974eb fix(gallery): preserve per-viewer is_liked across hard refresh (#590 follow-up)
The in-session toggle fix in d292b9f handles click 2 correctly, but
on a hard refresh likedPhotoIds was always initialized to an empty
Set — so previously-liked photos rendered un-filled until the user
opened the lightbox.

Backend: gallery.js GET /:slug/photos now mounts resolveGuest and
emits a per-viewer is_liked boolean per photo. Prefers req.guest.id
when a verified guest token is present (per-person identity), falls
back to the IP+UA hash that generateGuestIdentifier produces — same
identity model galleryFeedback.js uses for /my-feedback. Skipped
when feedback is hidden from guests.

Frontend: Photo type gains optional is_liked. Each of the 7 grid
layouts (Masonry / Grid / Justified / Timeline / Carousel / Mosaic /
Premium) seeds its lifted likedPhotoIds Set from photos.filter(is_liked)
on the first non-empty payload, gated by a seededRef so subsequent
React Query refetches don't clobber in-session optimistic toggles.
Mosaic uses photo.is_liked ?? false in its per-card useState initializer.

GalleryPremium also drops the buggy `|| like_count > 0` fallback at
line 521 that treated "anyone liked this" as "I liked it" — the
per-viewer seed is now the correct source.

GalleryStory had the same shape of bug in two places — same #590 fix:
- Seed switched from like_count > 0 (global) to is_liked (per-viewer),
  with the same mount-only seededRef guard.
- handleToggleFavorite now calls submitFeedback on EVERY click, not
  only when adding. The previous code skipped the unlike submit, so
  the UI removed the heart while the server kept the like row.
2026-05-31 22:47:16 +02:00
Paul Nothaft 2d44b1ab2d fix(api/v1/events): also honour require_password + branding defaults (#592 follow-up)
Same class of bug as the devtools-detection gap landed in 2304b25.
v1 POST /events was hardcoding require_password=true in the destructure
default and skipping getBrandingDefaults entirely, so:

- Admins who disabled "require password by default" globally still
  got password-required galleries through the API.
- API-created events ignored the global branding_logo_display_hero
  and branding_logo_size toggles, defaulting to visible/medium
  regardless of the admin's preferred branding chrome.

Mirror the readBooleanSetting + getBrandingDefaults pattern from
adminEvents.js inline (helpers aren't exported, and pulling them out
is out-of-scope for this fix). Adds validators, fallback resolution,
and the three resolved values to the events insert. hero_logo_position
stays at 'top' since #357 / migration 084 explicitly disconnected it
from the header-bar branding_logo_position setting. OpenAPI updated.
2026-05-31 22:47:01 +02:00
Paul Nothaft 2304b25624 fix(api/v1/events): honour global devtools-detection default on create (#592)
Same class of bug as #550 part 2 (feedback default ignored on API
events): the events table column default for enable_devtools_protection
is true, so an admin who disabled detection globally still got it ON
for every API-created gallery.

Mirror the feedback fallback that landed in 1b521e7 — accept an
optional enable_devtools_protection body field, fall back to the
app_settings entry of the same name, and write the resolved value
explicitly on insert so the column default doesn't shadow it.
OpenAPI doc updated to match.
2026-05-31 22:35:23 +02:00
Paul Nothaft c83e88348f fix(nginx): defensive large_client_header_buffers bump (#591)
Default nginx is 4 8k — too tight when an outer Cloudflare /
corp-proxy injects long Set-Cookie / X-Forwarded-* headers, or when
a power-user accumulates many per-gallery gallery_token_<slug>
cookies over the 24h maxAge in tokenUtils.js. Either way users hit
"400 Request Header Or Cookie Too Large" and clearing cookies is
the only workaround.

4×32k is cheap RAM, matches what most reverse proxies do upstream,
and means PicPeak doesn't fail the request before the upstream even
sees it.
2026-05-31 22:35:19 +02:00