Commit Graph

442 Commits

Author SHA1 Message Date
Paul Nothaft 6b6191a426 fix(security): scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage
Triage of an external SAST/SCA scan run on 2026-05-06. Most loud findings
were already resolved by PR #412 (the 18-CVE backport); this PR addresses
the residual real items:

* Drop unused `handlebars` from backend deps. The runtime require was
  removed in PR #367 (#367) but the package.json line stayed. handlebars
  was the source of two flagged criticals (CVE-2026-33937 RCE,
  GHSA-2w6w-674q-4c4q AST injection) plus 8 highs — all now gone.

* `npm audit fix` on backend + frontend. Bumps transitive picomatch,
  flatted, postcss, brace-expansion via lockfile, and direct dompurify,
  lodash, vite, i18next-http-backend within their existing semver ranges.
  Both audits now report 0 vulnerabilities.

* Add `event.origin === window.location.origin` check to the THEME_PREVIEW
  message listener in PreviewPage. The branding page posts from the same
  origin, so nothing legitimate is rejected; without the check, any third
  party that window.open()'d the preview could push arbitrary
  branding/theme payloads (semgrep
  insufficient-postmessage-origin-validation).

* nginx: `proxy_hide_header` for X-Frame-Options, X-Content-Type-Options,
  Referrer-Policy, Content-Security-Policy, Permissions-Policy,
  Strict-Transport-Security at server level. nginx adds these itself, but
  helmet on the backend was also emitting them — clients were seeing
  duplicates (testssl flagged "Multiple X-Frame-Options / CSP /
  Permissions-Policy / Referrer-Policy headers" on the live origin).
  Single source of truth now.

* Dockerfile hardening (checkov):
  - HEALTHCHECK on backend/Dockerfile, backend/Dockerfile.dev,
    frontend/Dockerfile.dev. Frontend production Dockerfile already had
    one.
  - USER node in frontend/Dockerfile.dev (was running as root).

* GitHub Actions docker-build.yml: explicit top-level
  `permissions: contents: read`. Per-job blocks already declare
  `packages: write` where needed; this stops future steps from
  inheriting unintended privileges (CKV2_GHA_1).

Backend npm audit: 4 vulns -> 0.
Frontend npm audit: 6 vulns -> 0.
Backend unit tests: 13 suites, 131/132 passing (1 pre-existing skip).
Frontend type-check + lint: clean.

The pre-existing integration-test failures (live DB / S3 required) and
the ThemeCustomizerEnhanced QueryClientProvider failures are unrelated
and reproduce on origin/beta without these changes.
2026-05-07 22:31:51 +02:00
github-actions[bot] 8ab6fc6bbd chore(beta): release 3.42.2-beta.0 2026-05-07 12:14:07 +00:00
Paul Nothaft b7d6ca0b65 fix(security): patch 18 dependency CVEs (axios + transitives + nodemailer + i18next-http-backend)
Closes the open Trivy code-scanning alerts for app-side dependencies.
The npm-bundled CVEs in /usr/local/lib/node_modules/npm (picomatch,
brace-expansion, ip-address inside the Node image itself) are deferred
to a separate Node-base-image PR — they're build-environment-side and
need their own compatibility testing.

## Direct dependency bumps

| Package | From | To | CVEs cleared |
|---|---|---|---|
| axios (backend + frontend) | 1.14.0 | 1.15.2 | CVE-2026-42264 (HIGH), CVE-2026-42043 (HIGH), CVE-2026-42035 (HIGH), CVE-2026-42033 (HIGH), CVE-2026-42044, CVE-2026-42042, CVE-2026-42041, CVE-2026-42040, CVE-2026-42039, CVE-2026-42038, CVE-2026-42037, CVE-2026-42036, CVE-2026-42034, CVE-2026-40175, CVE-2025-62718 |
| nodemailer (backend) | ^7.0.13 | ^8.0.5 | GHSA-vvjj-xcjg-gr5g, GHSA-c7w3-x93f-qmm8 |
| i18next-http-backend (frontend) | ^3.0.2 | ^3.0.5 | CVE-2026-41691 |
| uuid (backend) | ^11.1.0 | ^11.1.1 | CVE-2026-41907 |
| postcss (frontend, devDep) | ^8.4.21 | ^8.5.10 | CVE-2026-41305 |

## Transitive bumps (npm overrides)

For transitives whose direct parents haven't released a version that
picks up the patched range, pinned via npm overrides:

| Package | Min | CVE |
|---|---|---|
| follow-redirects (backend + frontend) | >=1.16.0 | GHSA-r4q5-vmmm-2653 |
| fast-xml-parser (backend) | >=5.7.0 | CVE-2026-41650 |
| @tootallnate/once (backend) | >=3.0.1 | CVE-2026-3449 |
| ip-address (backend) | >=10.1.1 | CVE-2026-42338 |

## Why axios is now safe to bump past 1.14.0

PR #268 originally pinned axios to 1.14.0 to avoid a supply-chain
attack on a specific compromised version range. The 1.15.x series
are post-incident upstream releases — clean. Confirmed with the
maintainer before bumping.

## Verified

* `npx tsc --noEmit` (frontend) — clean
* `npx vite build` (frontend) — clean (~4s, existing bundle-size
  warning, not new)
* Backend module-load smoke test — all critical modules load
  (`auth`, `adminAuth` middleware, `emailProcessor`, `recaptcha`,
  `storage`) with the new axios + nodemailer
* Lockfile re-verification — every targeted CVE now resolves to
  the patched version range

## Remaining out of scope

* npm-bundled CVEs inside `/usr/local/lib/node_modules/npm/` —
  picomatch CVE-2026-33671 (HIGH), CVE-2026-33672, brace-expansion
  CVE-2026-33750, ip-address (npm-internal) CVE-2026-42338. These
  live in the Node base image and require a Node base image bump
  with its own compatibility testing — separate PR.

Targeting `beta` so the bumps go through the normal release-please
flow before promotion to `main`.
2026-05-07 13:51:55 +02:00
github-actions[bot] ba405c1dd2 chore(beta): release 3.42.1-beta.0 2026-05-07 10:36:15 +00:00
github-actions[bot] 9cf92f3e8e chore(beta): release 3.42.0-beta.0 2026-05-07 09:34:05 +00:00
github-actions[bot] 4a5395b3ac chore(beta): release 3.41.0-beta.0 2026-05-06 18:26:41 +00:00
Luca 47b6b39f3a feat(email): expand email palette to 8 tokens + Sync from Branding button 2026-05-06 01:41:45 +02:00
Luca 5a162fc8be feat(branding): force color mode (dark or light) site-wide 2026-05-05 16:06:38 +02:00
Luca 114aab5777 feat(theme): expand color settings to 8-token CI palette + alt button 2026-05-05 16:04:43 +02:00
github-actions[bot] 01f5e44353 chore(beta): release 3.40.1-beta.0 2026-05-04 22:19:57 +00:00
Paul Nothaft b106da1ede fix(auth): /auth/session must enforce session timeout symmetrically (#350 recurrence)
Third loop fix in the same /admin/login → /admin/dashboard → /admin/login
pattern as #355 and #363. Reported on v3.39.1-beta.0 — the loop returns
after a server restart or after an idle gap longer than the configured
session timeout.

## Root cause (server)

`sessionTimeoutMiddleware` is mounted on `/api/admin` (server.js:411). It
rejects with `401 SESSION_TIMEOUT` when either:
  - the in-memory `lastActivity` for the token is older than the timeout, or
  - this is the first request with this token AND the token's `iat` is
    older than the timeout (post-restart guard).

`/auth/session` lives under `/api/auth/session`, NOT under `/api/admin`,
so the middleware never runs for it. Result: an idle/old-iat admin token
returns `valid: true` from `/auth/session` while every protected
endpoint immediately rejects it with `401 SESSION_TIMEOUT`. Frontend's
401 interceptor hard-redirects to `/admin/login`, `/auth/session` says
valid again, loop closes — exact same shape as the previous two
asymmetries the symmetry pass missed.

Fix: add a non-mutating `isSessionExpired(token, decoded)` helper to
`middleware/sessionTimeout.js` that reads the same in-memory map and
applies the same lastActivity / iat-vs-timeout logic as the middleware,
without updating the map (the middleware is the only place that records
activity; `/auth/session` is read-only by design). `/auth/session`
calls the helper for `decoded.type === 'admin'` after the existing
admin-existence and password-change checks. Same try/catch fall-through
pattern as the prior fixes so a missing/broken helper doesn't fail-closed
during early bootstrap or in test stubs.

## Root cause (client race amplifying the loop)

Even with the server fix, the previous `useSessionTimeout` hook called
`AdminAuthContext.logout()` which dispatches `POST /auth/logout`
fire-and-forget AND has its own `finally { window.location.href }`,
then immediately set `window.location.href = '/admin/login?session=expired'`
on top. Two consequences:
  - The cookie wasn't reliably cleared before the new page loaded —
    if any /auth/session asymmetry slipped through, the loop replayed
    inside the same tab. New-tab and "refresh several times" "fixes"
    were just the logout request eventually completing.
  - Two redirects raced; sometimes the `?session=expired` query was
    dropped, breaking the login-page toast.

Fix: rewrite the hook to (a) await `POST /auth/logout` so the cookie
is guaranteed cleared, (b) clear `sessionStorage.admin_user` directly
instead of going through AdminAuthContext.logout (which has the
side-effect redirect we don't want), and (c) navigate exactly once
with the `?session=expired` query.

## Tests

- `__tests__/routes/authSession.symmetry.test.js` — 4 new cases under
  a `session-timeout symmetry` describe block: helper says expired →
  valid:false; helper says active → valid:true; helper not called for
  gallery tokens; helper throws → fall through to valid:true (defensive).
  Existing 9 tests still pass (mock now includes
  `isSessionExpired: jest.fn(() => Promise.resolve(false))` as the
  default).
- `__tests__/middleware/sessionTimeout.isSessionExpired.test.js` — 7
  new unit tests for the helper itself: fresh token / old-iat /
  recently-active / null-input / no-mutation / 60-min default
  boundary cases.

20 cases total, all green. Lint clean on every touched file.
2026-05-05 00:12:01 +02:00
Paul Nothaft 045620e999 Merge pull request #396 from the-luap/fix/fonts-test-mock
test(fonts): fix mock bypass and case-insensitive FS skip (#390 follow-up)
2026-05-04 23:30:44 +02:00
Paul Nothaft 51f28d7330 test(fonts): fix mock bypass and case-insensitive FS skip (#390 follow-up)
Two issues in the fonts service test suite added by #390 — the behaviour
assertions all passed, but 5 of 24 tests had assertions that silently
no-op'd, so any regression in those code paths would not have been
caught.

## Issue 1: jest.resetModules() bypassed the logger mock

`beforeEach` called `jest.resetModules()` then re-required `fontsService`.
After resetModules, the `jest.mock('../../src/utils/logger', ...)` factory
at the top of the file no longer applied to subsequent requires — so the
freshly-required `fontsService` captured the REAL logger while the test
file's `logger` variable still pointed at the mocked one. The 4
"warning logged" / "info logged" assertions resolved as 0 calls and
silently passed-as-noop.

The resetModules call wasn't necessary in the first place — module-level
state in fontsService is just the cache, which clearFontsCache() already
resets. And both getBundledFontsRoot() and getUserFontsRoot() read
process.env at call-time, not at module load, so the env vars set in
beforeEach are picked up without needing a fresh require.

Fix: require fontsService once at module top (inside the jest.mock
hoisting scope) and drop resetModules + the per-test re-require.

## Issue 2: case-insensitive filesystem (macOS / Windows)

The "case-insensitive duplicate within the same root" test created
`Inter/` and `INTER/` to trigger the dedup warning. On a case-sensitive
FS (Linux ext4) both directory entries exist and the dedup branch fires;
on macOS APFS or Windows NTFS the second mkdir resolves to the same
folder as the first, so only one ever exists and the dedup is
unreachable from this test setup. Test failed on macOS dev, passed on
Linux CI.

Fix: probe at load time by creating a lowercase file and checking if
its uppercase variant resolves to the same inode, then conditionally
test.skip the affected test on case-insensitive hosts. Comment in the
test body explains why.

## Result

23 of 24 tests now pass on macOS; the case-sensitive-only test runs on
Linux CI. All previously-no-op'd assertions now exercise their code
paths.
2026-05-04 23:26:25 +02:00
github-actions[bot] 3d8fe2ec6e chore(beta): release 3.40.0-beta.0 2026-05-04 21:23:32 +00:00
Paul Nothaft d04bf28808 Merge pull request #390 from Luca-Timo/feat/self-hosted-fonts
feat(branding): self-hosted webfonts with filesystem scanner
2026-05-04 23:23:04 +02:00
Luca b609a7a88c test(fonts): unit-test scanner edge cases and meta.json handling 2026-05-04 22:29:57 +02:00
Luca 5703fcb806 fix(fonts): drop immutable Cache-Control to allow font replacement rollout 2026-05-04 22:28:17 +02:00
Luca dcff451572 feat(branding): per-family generic fallback via meta.json 2026-05-04 22:27:52 +02:00
github-actions[bot] ac05230867 chore(beta): release 3.39.1-beta.0 2026-05-04 19:37:59 +00:00
github-actions[bot] 6fe1d8de9e chore(beta): release 3.39.0-beta.0 2026-05-04 19:16:41 +00:00
Paul Nothaft 1f1a856083 Merge pull request #385 from Luca-Timo/beta
feat(gallery): decouple header style from layout, add banner option
2026-05-04 21:16:13 +02:00
github-actions[bot] 0a48646529 chore(beta): release 3.38.0-beta.0 2026-05-04 19:00:37 +00:00
Paul Nothaft 647aea21ae Merge pull request #389 from the-luap/feat/events-bulk-delete
feat(events): bulk delete with password confirmation (#384)
2026-05-04 21:00:06 +02:00
Paul Nothaft 48d538f94f feat(events): bulk delete with password confirmation (#384)
Adds the bulk-delete half of #384 — admins can select multiple
events from the list and delete them in one batch, gated by
re-entering their password.

## Why password confirmation

Bulk delete is destructive and irreversible (cascades across 5 DB
tables and 3 filesystem paths per event). Re-entering the password
matches the pattern already used by /auth/admin/change-password and
makes accidental clicks much harder than a plain "type DELETE to
confirm" — the muscle-memory required to type your real password is
a stronger gate than typing a literal word.

## Changes

### Backend (adminEvents.js)

- Extracted the per-event cascade-delete logic into a module-private
  `deleteEventCascade(eventId, adminContext)` helper. The DELETE /:id
  route now calls it instead of inlining 60 lines of cascade — same
  behaviour, no drift between the per-event and bulk paths.
- New `POST /admin/events/bulk-delete`. Body: `{ eventIds, password }`.
  Permission: `events.delete`.
  - Validates `eventIds` array length (1–100) and that each id is an
    integer. The 100-cap keeps request time bounded; the per-event
    cascade touches DB + filesystem so 1000 events at once would risk
    timing out the request.
  - Verifies `password` against the calling admin's bcrypt hash via
    `bcrypt.compare()` (same as /auth/admin/change-password). Wrong
    password → 401 `{ error, code: 'INVALID_PASSWORD' }` and no
    events are touched.
  - Loops via `deleteEventCascade`, returns
    `{ results: { successful, failed } }` with the same shape as
    /bulk-archive so the frontend can show partial-failure feedback.
  - Logs `bulk_delete_completed` activity with totals.

### Frontend

- `events.service.ts`: `bulkDeleteEvents(eventIds, password)`.
- New `BulkDeleteModal.tsx`. Red/destructive variant of the
  bulk-archive modal:
  - Lists the events to be deleted (so the admin can verify).
  - Password input with show/hide toggle, autofocus, Enter-to-submit.
  - Inline `passwordError` prop surfaces the 401 INVALID_PASSWORD
    response without losing the modal state — admin can retry
    without re-typing the event list.
  - "Processing" state replaces the form with a spinner + "Deleting
    N events. This may take a few minutes — please don't close this
    window." (i18n) so admins know not to abandon the page during
    a slow operation.
- `EventsListPage.tsx`: "Delete Selected" button next to "Archive
  Selected" in the bulk-actions bar (red-styled to signal danger),
  bulkDeleteMutation that maps the 401 to the modal's inline error
  and any other failure to a generic toast.

### i18n

12 new keys under `events.bulkDelete.*` in all 5 locales
(en/de/nl/pt/ru): title, warning, password label/placeholder/help,
submit, processing, incorrectPassword, successAll, successPartial,
errorGeneric, plus `events.deleteSelected` for the button. Hand-
written for de; nl/pt/ru should get a native-speaker pass at some
point but read naturally.

### Verified

- `npx tsc --noEmit` clean
- `npx eslint` clean on every touched file (4 pre-existing errors in
  adminEvents.js for unused vars unrelated to this PR)
- All 5 locale JSON files parse cleanly
- `node -e "require('./src/routes/adminEvents')"` loads the module

Closes the bulk-delete half of #384. The Photos-column half lands
separately in PR #387.
2026-05-04 20:56:00 +02:00
github-actions[bot] 67999999d8 chore(beta): release 3.37.0-beta.0 2026-05-04 18:52:09 +00:00
Luca bac51fe69a feat(branding): self-hosted webfonts with filesystem scanner 2026-05-04 19:15:47 +02:00
Luca 05dadff493 fix(gallery): preserve sidebar controlsStyle on banner migration 2026-05-04 16:58:17 +02:00
Luca aff29c91bb feat(gallery): decouple header style from layout, add banner option 2026-05-04 14:34:42 +02:00
github-actions[bot] 66281f9790 chore(beta): release 3.36.0-beta.0 2026-05-04 11:33:17 +00:00
github-actions[bot] 7e4f1acb88 chore(beta): release 3.35.0-beta.0 2026-05-04 10:21:13 +00:00
github-actions[bot] 13e3a05386 chore(beta): release 3.34.2-beta.0 2026-05-04 07:14:40 +00:00
Paul Nothaft 96818c7ae8 fix(docker): install system ffmpeg on Alpine, drop broken bundled binary
Video uploads on production fail with "missing ffmpeg" because the
backend container ships nothing usable for the video pipeline.

Two compounding causes:

1. **Alpine + glibc mismatch.** The npm `@ffmpeg-installer/ffmpeg`
   dependency added with the video-support PR (commit 68a9dc5)
   ships per-platform binaries via optionalDependencies. The Linux
   binaries are built against glibc, but the backend image runs on
   `node:22-alpine` (musl libc) — known to either fail to execute
   or fail on shared-library lookups on Alpine.

2. **`ffprobe` missing entirely.** `@ffmpeg-installer/ffmpeg`
   bundles only the `ffmpeg` binary. There's a separate
   `@ffprobe-installer/ffprobe` package that the codebase never
   depended on. But `videoProcessor.js:21` calls
   `ffmpeg.ffprobe(videoPath, …)` — the very first step of the
   video pipeline shells out to a `ffprobe` binary that doesn't
   exist in the image. Even if (1) worked, every video upload
   would 500 here.

The fix is to install Alpine's `ffmpeg` package via apk. It ships
both `ffmpeg` and `ffprobe` built natively against musl, ~70MB
extra image size, single line in the Dockerfile, no per-arch
handling needed (apk pulls the right binary for both linux/amd64
and linux/arm64 — works with the multi-arch infra from #349).

- `backend/Dockerfile`: add `ffmpeg` to the apk install line.
- `backend/Dockerfile.dev`: same for dev parity.
- `backend/src/services/videoProcessor.js`: remove the
  `setFfmpegPath(require('@ffmpeg-installer/ffmpeg').path)` line
  — without removing it, fluent-ffmpeg would prefer the broken
  bundled binary over the working apk one. Letting fluent-ffmpeg
  fall back to PATH lookup picks up the apk binary in the
  container and the developer's locally-installed binary on dev
  hosts (Homebrew on macOS, apt on Debian).
- `backend/package.json`: drop the now-unused
  `@ffmpeg-installer/ffmpeg` dependency. `npm install` removes
  2 packages from the lockfile.

Verified: `videoProcessor.js` still loads cleanly (`node -e
"require('./src/services/videoProcessor')"`); lint clean.
2026-05-04 09:04:38 +02:00
github-actions[bot] eb6791792e chore(beta): release 3.34.1-beta.0 2026-05-03 22:18:02 +00:00
Paul Nothaft bce5c1f725 fix(cms): nl/pt/ru i18n + gate external_url in public response
Two follow-ups to PR #372 (external-URL toggle for imprint /
privacy CMS pages):

1. **i18n.** PR #372 added 6 new `cms.*` keys to the en + de
   locales but the project ships 5 locales total. Adds the missing
   nl / pt / ru translations so the admin CMS page renders in the
   active language for those users instead of falling back to
   English literals next to the German/Dutch/Portuguese/Russian
   surrounding strings.

2. **API shape.** `publicCMS.js` returned `external_url`
   unconditionally — even when `use_external_url` is false the URL
   value was still emitted in the public response. The frontend
   correctly gated on both flags so it worked, but the API surface
   was leaking a value the admin had explicitly disabled. The
   value still lives in the DB (so the toggle can be flipped back
   on without losing it), but the public endpoint now returns
   `null` whenever the toggle is off.

   Note: kept the existing `logo_url` shape unchanged. Its semantics
   are different — null means "fall back to global branding" and
   consumers rely on always having the field, so emitting it
   unconditionally is intentional there.

No frontend change needed: both `GalleryLayout` and `LegalPage`
already gate on `use_external_url && external_url`, so the
short-circuit handles `external_url: null` correctly.
2026-05-04 00:14:07 +02:00
github-actions[bot] cf04c0b2dc chore(beta): release 3.34.0-beta.0 2026-05-03 22:11:01 +00:00
Paul Nothaft b2c8161a43 Merge pull request #372 from Luca-Timo/beta
feat(cms): add external URL toggle for imprint and privacy pages
2026-05-04 00:10:44 +02:00
Luca 66423bb65e feat(cms): add per-page external URL override — backend 2026-05-03 23:05:18 +02:00
github-actions[bot] d552f45b20 chore(beta): release 3.33.2-beta.0 2026-05-03 20:48:36 +00:00
Paul Nothaft ff50c74e19 fix(events): admin-set password on reset, full-URL gallery_link in all emails
Two related defects on the same gallery-email surface that PR #367
opened, addressed together:

1. Reset-password endpoint was a one-way auto-generate.
   `POST /admin/events/:id/reset-password` always called
   `generateReadablePassword()` and ignored any client-supplied value;
   the modal only offered a confirm + a forced auto-generated result.
   Admins who wanted to set a memorable customer-supplied password
   had no way to do it.

   Backend: route now reads optional `password` from the body. If
   present, validates with `validatePasswordInContext('gallery', …)`
   (same rules as create-event) and uses it; if absent, falls back to
   the existing generator, so old callers / cron stay functional.
   Switched the bcrypt rounds from a hard-coded `10` to
   `getBcryptRounds()` to match the create flow.

   Frontend: rebuilt `PasswordResetModal.tsx`. Typed input with
   show/hide, confirm-password field that appears on type, the same
   `<PasswordGenerator>` used by `CreateEventPage` (event-context-
   aware, fills both fields when used), send-email checkbox,
   client-side validation, server-side validation feedback inline.
   Submit empty → server auto-generates and the success screen shows
   the value with a copy button (legacy one-click flow preserved);
   submit with a typed password → success toast + close (no need to
   re-show what the admin already typed).

   Service layer: `events.service.resetPassword(id, sendEmail,
   password?)` only sends `password` in the body when set.

   Caller: `EventDetailsPage` now passes `eventDate` + `eventType`
   into the modal so the generator has event context.

2. `gallery_link` was the path-only `event.share_link` in three
   email-queue sites, so customer mail showed
   `/gallery/<slug>/<token>` instead of the full
   `https://example.com/gallery/<slug>/<token>` URL.

   - `adminEvents.js` reset-password queue (#1437)
   - `adminEvents.js` resend-creation-email queue (#1502)
   - `expirationChecker.js` expiration_warning queue (#82)

   All three now derive `shareUrl` from `buildShareLinkVariants`
   (the same helper already used by create-event, publish-from-
   draft, and event-rename). The other 4 callers
   (`adminEvents.js:651/913`, `events.js:187`,
   `eventRenameService.js:231`) already used the full URL — this
   closes the gap.

Verified: TypeScript clean (`npx tsc --noEmit`), ESLint clean on
every touched file (the 4 lint errors that remain in
`adminEvents.js` are pre-existing and predate this branch).
2026-05-03 22:44:22 +02:00
github-actions[bot] 65d8032960 chore(beta): release 3.33.1-beta.0 2026-05-03 20:10:16 +00:00
Paul Nothaft e8052adf1d fix(email): render conditionals, localise password placeholders, fix caller/template variable drift
Bundle of email-renderer and email-caller fixes triggered by a
reproducer on picpeak.nothaft.cloud (gallery_created mail showing
literal `{{#if welcome_message}}` markers and `Passwort: (set at
creation)`). The audit that followed surfaced six more user-visible
defects in the same surface; all are fixed here so customer-facing
mail renders cleanly.

Renderer (`backend/src/services/emailProcessor.js`)

- `safeTemplateReplace` now resolves `{{#if VAR}}…{{/if}}` blocks
  before flat `{{var}}` substitution. The shipped templates have used
  Handlebars-style conditionals since migration 026; the renderer
  ignored them, so the markers leaked verbatim into every mail with
  an empty welcome_message. Lifted to module scope and exported so
  the conditional contract is unit-testable. Single-pass, non-nested
  (commented).

- Added `passwordSetAtCreationI18n` next to the existing two i18n
  password sentinels so `(set at creation)` (sent by the publish-
  from-draft flow when only the bcrypt hash remains) is localised
  to "Das bei der Erstellung der Galerie gesetzte Passwort" /
  equivalent in EN/DE/NL/PT/RU instead of the raw English string.

- Added an opt-in `{ escapeHtml: true }` mode to `safeTemplateReplace`
  so admin-supplied free text (`event_name`, `host_name`, …) is
  HTML-escaped on substitution into the HTML body. Allowlist of
  passthrough keys (`welcome_message` already-HTML, server-generated
  URLs `gallery_link` / `client_link`). Subject and text body keep
  the legacy unescaped behaviour. `formatWelcomeMessage` now escapes
  before nl2br so the welcome_message allowlist is safe.

- New `htmlToText()` strips `<style>` and `<script>` blocks (and
  their content) before tag-stripping, decodes common entities, and
  collapses whitespace. Used by the textBody fallback in
  `sendTemplateEmail` — without this, every template missing a
  `body_text` produced a "plain-text" mail starting with the 100+
  lines of CSS embedded by `wrapEmailHtml()`.

- The client-access section (#172) now mirrors its HTML block into
  `textBody` using the same per-language strings, so plain-text
  recipients see the link / PIN / warning. `pinLabel = 'PIN'` moved
  into `clientAccessI18n` (RU uses ПИН-код).

- Added `getSupportEmail()` exported helper that reads
  `branding_support_email` from `app_settings` (JSON-decoded), with
  the SMTP from-address as fallback. Used by the gallery_expired and
  archive_complete callers below.

- Removed dead `require('handlebars')` (unused since the regex
  renderer landed; pre-existing lint error in this file).

Callers (data the templates already reference)

- `expirationChecker.js queueExpirationWarning`: send `expiry_date`
  (templates use this, the old code sent `expiration_date` —
  typo'd key, never read), drop the hard-coded `.de`/`en` sniff
  (the processor formats with the recipient's resolved language),
  add the `{{password_security_message}}` sentinel for
  `gallery_password` (plaintext is gone by warning time, so
  customers used to see literal `{{gallery_password}}` in the mail).

- `expirationChecker.js handleExpiredEvent`: both queueEmail calls
  now supply `host_name`, `event_date`, `expiry_date`,
  `support_email` so the EN/DE/NL/PT/RU `gallery_expired` template
  doesn't render literal `{{host_name}}, your gallery expired on
  {{expiry_date}}`. Skip the duplicate admin send when
  admin_email == customer_email.

- `archiveService.js`: `archive_complete` queue now supplies
  `host_name`, `photo_count` (from `photoEntries.length`),
  `archive_date`, `support_email` — the previous payload had only
  `event_name` and `archive_size`, so most of the mail was
  unfilled placeholders.

Tests

- `__tests__/services/emailProcessor.safeTemplateReplace.test.js`:
  16 cases — flat substitution, conditional truthy/falsy/missing/
  multi-line/sibling/numeric-0, plus 5 cases for the new
  `escapeHtml` option (default off, escape on, allowlist
  passthrough for welcome_message and gallery_link).

- `__tests__/services/emailProcessor.htmlToText.test.js`: 7 cases —
  the regression scenario (full wrapped body with embedded `<style>`
  block), tag-stripping, entity decoding, paragraph spacing.

- `__tests__/utils/formatters.test.js`: 12 cases for `escapeHtml`,
  `nl2br`, and the now-escaping `formatWelcomeMessage`.

35 cases total, all green. Lint clean on every touched file
(also fixes a pre-existing `no-prototype-builtins` warning in the
process). Pre-existing failures in
`__tests__/services/backupService.enhanced.test.js` are unrelated
and pre-date this branch.
2026-05-03 22:06:06 +02:00
github-actions[bot] 4207207c5e chore(beta): release 3.33.0-beta.0 2026-05-02 21:02:15 +00:00
Paul Nothaft 4765804645 Merge pull request #364 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m57s
Build and Push Docker Images / build-frontend (push) Failing after 3m57s
Build and Push Docker Images / summary (push) Successful in 3s
chore(beta): release 3.32.5-beta.0
2026-05-02 22:59:17 +02:00
github-actions[bot] 214d8a1899 chore(beta): release 3.32.5-beta.0 2026-05-02 20:59:02 +00:00
Paul Nothaft b30010eb6f test(upload): unit tests for backgroundProcessor + processPhoto
Cover the two new pieces of the async pipeline:

backgroundProcessor.claimNextPhoto
  - returns null when no pending rows
  - returns the row + flips status under postgres FOR UPDATE SKIP LOCKED
  - returns null when SQLite UPDATE-with-guard loses the race
  - returns the row when the SQLite guard wins

photoProcessor.processPhoto
  - happy path: writes thumbnail / dimensions / EXIF capture date and
    marks 'complete'; fires watermark queue + photo.uploaded webhook
    with the right payload
  - video path: writes ffmpeg duration / codec / dimensions; does NOT
    queue watermark (image-only)
  - throws cleanly when the photo row no longer exists

Mocks db / imageProcessor / videoProcessor / storage / sharp /
watermarkGeneratorService / webhookService / logger so the tests run
without a real DB or any image library calls — fast and deterministic.
2026-05-02 22:56:04 +02:00
Paul Nothaft 851744c3c4 feat(upload): async photo processing — backend (PR-B part 1)
Move thumbnail / EXIF / dimensions / watermark / webhook work off the
upload request thread and into a background worker pool. Upload
requests now return 202 in seconds even on NFS-backed storage; the
worker(s) drain the pending queue independently and update each
photo's processing_status to 'complete' or 'failed' on its own.

Schema (migration 085_async_photo_processing.js):
  - photos.processing_status     enum default 'complete' (existing
                                 rows are already done)
  - photos.processing_error      populated on 'failed'
  - photos.processing_started_at timestamp for janitor recovery
  - photos.upload_id             groups all photos from one upload
                                 request so the frontend can poll
                                 status by group
  - indexes on processing_status and upload_id for queue lookups

services/photoProcessor.js
  - queueFilesForProcessing(files, options) — shared helper used by
    the admin and gallery upload routes. Moves files to final storage
    + inserts pending rows; returns { uploadId, photos, errors }.
  - processPhoto(photoId) — worker-mode: reads original from storage
    via withLocalCopy (transparent local/S3), generates thumbnail and
    EXIF/dimensions or video metadata, queues watermark, fires
    photo.uploaded webhook, marks 'complete'. Throws => caller marks
    'failed' with the error message.
  - processUploadedPhotos kept untouched — chunkedUploadService still
    uses the synchronous path.

services/backgroundProcessor.js (new)
  - N independent worker loops per backend instance (default 2,
    UPLOAD_PROCESSOR_CONCURRENCY env override).
  - Multi-pod safe: postgres SELECT FOR UPDATE SKIP LOCKED, sqlite
    UPDATE-with-status-guard. Pods race on rows, exactly one wins.
  - Janitor every minute resets photos stuck in 'processing' for >10
    minutes (worker died, pod restarted) back to 'pending'.
  - UPLOAD_PROCESSOR_DISABLED=true opt-out for CI/test.
  - Started from server.js after the other long-running workers.

routes/adminPhotos.js — POST /:eventId/upload
  - Replaced batch-of-25 sync processing loop with per-file
    move-to-storage + insert-pending. Response is now 202 with
    upload_id, count, photo_ids in addition to the legacy
    successCount / replacedCount fields the existing frontend reads.
  - Per-request temp directory cleanup is now a single idempotent
    handler on res.finish/res.close (was three inline blocks for
    error paths only, leaking dirs on success — original bug from
    contributor analysis).
  - GET /uploads/:upload_id/status — JSON snapshot of pending /
    processing / complete / failed counts plus per-photo state.
  - GET /uploads/:upload_id/stream — SSE upgrade. Polls internally
    every 1.5s, emits on snapshot change, ends when all photos
    reach a terminal state.
  - POST /photos/:photoId/retry — flips a 'failed' photo back to
    'pending' so the worker picks it up again.
  - GET /:eventId/thumbnail/:photoId now returns 503 with Retry-After
    while the photo is still pending/processing, and 422 on 'failed'.
    The admin grid renders placeholders accordingly.

routes/gallery.js — POST /:eventId/upload (guest)
  - Refactored to use queueFilesForProcessing instead of the synchronous
    processUploadedPhotos. Same 202 + upload_id shape.
  - GET /:slug/photos now filters processing_status to 'complete' (or
    NULL for pre-migration rows) so guests never see in-flight photos.

Side-effect timing change:
  - photo.uploaded webhook now fires from the worker after the photo
    is actually processed (thumbnail + dimensions populated) instead
    of from inside the upload request. Same payload fields. Worth a
    one-line note in the changelog.
2026-05-02 22:56:04 +02:00
Paul Nothaft 86dfcc4f11 feat(upload): two-state UI + temp dir cleanup (PR-A of async processing)
Phase 1 of the upload-progress redesign. Two changes that ship UX wins
without any architectural surgery — they're a stepping stone for the
full async-processing rework that follows in subsequent commits.

1. Two-state progress bar (PhotoUpload.tsx, UserPhotoUpload.tsx)

   When axios.onUploadProgress reports loaded === total, the request is
   on the server and the bytes have left the browser. Today the bar sits
   at 100% for the chunk while the backend runs sharp/ffmpeg/EXIF (often
   minutes on NFS-backed storage) and users assume the upload froze.

   The component now distinguishes two phases:
   - 'transferring' — bytes-on-wire, determinate progress bar.
   - 'processing'   — bytes done, waiting for response. Indeterminate
                      spinner + an explanatory hint that the backend is
                      generating thumbnails / reading metadata and the
                      user can leave the page.

   Same pattern in UserPhotoUpload (gallery): the per-file checkmark
   icon is replaced by a Loader2 spinner while the request is in flight
   after bytes-on-wire finished.

2. Temp directory cleanup (adminPhotos.js)

   Multer creates temp/upload_<ts>_<rand>/ per request. Files inside it
   are individually unlinked after they're moved to storage on the
   success path, but the empty directory was never removed. On error
   paths three different inline blocks each tried to clean up; the
   success path was missed entirely. Result: the orphan-empty-dirs
   accumulation reported in the issue (70+ on the affected instance).

   Replace the inline cleanup blocks with a single idempotent
   cleanupTempDir() registered on res.finish + res.close, so it fires
   exactly once on every exit path (validation 4xx, server 5xx, multer
   error, success).

New translation keys (en/de): upload.transferring, upload.processing,
upload.processingHint, upload.processingProgress, upload.processingFailed,
upload.retryFailed.
2026-05-02 22:56:04 +02:00
Paul Nothaft f905f7e733 fix(auth): /auth/session must reject tokens that adminAuth/galleryAuth would reject
Second loop fix in the same /admin/login → /admin/dashboard → /admin/login
pattern as #355. The frontend trusts /auth/session as the source of
truth for "is the user authenticated?". When that endpoint is more
lenient than the protected middleware, every admin endpoint 401s
right after /auth/session said valid:true, the response interceptor
hard-redirects to /admin/login, /auth/session says valid again, and
the cycle closes — exactly the loop reported on v3.32.4-beta.0.

#355 fixed the issuer-claim asymmetry. This commit fixes the
remaining asymmetries: /auth/session was missing the admin-existence,
admin-active, password-change-after-iat, and gallery-existence /
gallery-archived / gallery-expired checks that adminAuth and
galleryAuth perform on every protected request.

The fix is to mirror those checks in /auth/session, scoped by token
type, and degrade gracefully when the underlying tables aren't
present (test fixtures, early bootstrap) so the endpoint never
fails-closed because of a missing table.

Reproducer that the new test covers:
  1. Admin logs in (token issued at T).
  2. Admin (or another admin) changes their own password at T+1.
  3. Browser still has the cookie from T.
  4. /auth/session says valid:true (no password-change check).
  5. /admin/dashboard fires queries; adminAuth rejects with
     PASSWORD_CHANGED 401.
  6. Frontend redirects to /admin/login.
  7. /auth/session says valid:true again. → loop.

Other surfaces this also covers:
  - admin user deactivated (admin_users.is_active = false)
  - admin user deleted
  - gallery token whose event is archived
  - gallery token whose event has expired

Tests live in __tests__/routes/authSession.symmetry.test.js — 9 cases,
mocking db / tokenRevocation / tokenUtils / recaptcha / sessionTimeout
so the suite runs without a real database.
2026-05-02 22:56:04 +02:00
github-actions[bot] 1fae9b6099 chore(beta): release 3.32.4-beta.0 2026-05-01 22:07:34 +00:00
Paul Nothaft ef1c875f6e fix(events): stop mapping branding_logo_position onto hero_logo_position
Two settings with overlapping names but different value sets were being
conflated:

- branding_logo_position (header bar, horizontal): 'left'|'center'|'right'
- hero_logo_position    (hero block, vertical):   'top'|'center'|'bottom'

getBrandingDefaults() copied the global branding value over the per-event
hero value when seeding new events. Any admin with branding logo set to
'left' (the most common choice) created events with hero_logo_position
= 'left' written to the DB. Subsequent PUTs to /admin/events/:id then
failed validation with "Invalid value (field: hero_logo_position)" — the
validator only accepts top/center/bottom.

Fix:

1. Drop the bogus mapping. branding_logo_position is no longer read by
   getBrandingDefaults — it doesn't belong there. The fallback default
   ('top') is used unless the request body explicitly provides
   hero_logo_position, which is independently validated.

2. Migration 084_fix_hero_logo_position normalises any existing rows
   whose hero_logo_position is outside ('top','center','bottom') back
   to 'top'. Without this, affected events would continue to 400 on
   every save until the admin manually picks a valid option.

Reproduction: admin sets branding logo position to 'left' under global
branding, creates an event, opens the event detail page, clicks Save
without changing anything → 400. After this fix, save succeeds and new
events default to 'top' regardless of branding-bar position.
2026-05-02 00:04:06 +02:00