Commit Graph

472 Commits

Author SHA1 Message Date
Luca 087ef45942 feat(customers): customer portal (#354) on top of feature-flags reorg
Implements the recurring-customer login surface from
the-luap/picpeak#354 plugged into the maintainer's
new feature-flag infrastructure (PR #443) instead of
a parallel toggle.

* New `customerPortal` feature flag (foundation flag for the
  not-yet-built calendar/quotes/bills/messaging customer
  surfaces). Defaults FALSE on fresh installs, TRUE on existing
  installs (events > 0) via migration 095 so live customer
  accounts don't disappear mid-deployment.
* Foundation schema: customer_accounts, customer_invitations,
  event_customer_assignments, customer_password_resets, plus
  RBAC permissions customers.view / .create / .delete granted
  to super_admin + admin system roles.
* Backend: /api/admin/customers (invite, list, search, assign,
  deactivate, reset password) + /api/customer/auth/* +
  /api/customer/* (login, dashboard, accept-invite, reset).
  Customer JWT bypass minted via
  /api/customer/events/:slug/access-token so existing gallery
  middleware stays untouched.
* Frontend: /customer/* route tree gated by RequireFeature flag
  customerPortal, with login / dashboard / accept-invite /
  reset pages and a customer-side sidebar layout.
  /admin/customers and /admin/customers/:id gated identically.
* Settings → Features grows a "Customers" section with a
  Customer portal card. The maintainer's Features tab stays the
  single source of truth — no parallel Advanced features tab.
* CustomerAccountPicker on event create/edit forms hides itself
  when the flag is off; backend ignores customer_account_ids in
  that case instead of erroring the whole event save.

Translations: en + de hand-translated. nl/pt/ru fall through to
en — flagged here as needing native review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 00:05:20 +02:00
github-actions[bot] 55e6395e9e chore(beta): release 3.45.1-beta.0 2026-05-10 20:09:09 +00:00
github-actions[bot] 421354bc5a chore(beta): release 3.45.0-beta.0 2026-05-10 19:57:54 +00:00
Paul Nothaft f3505c2631 Merge pull request #450 from the-luap/feat/footer-overhaul-441-440
feat(footer): hideable legal links + socials + promo banner (#441 + #440)
2026-05-10 21:57:35 +02:00
github-actions[bot] d2864854e7 chore(beta): release 3.44.2-beta.0 2026-05-10 19:51:03 +00:00
Paul Nothaft 3a731e7c95 feat(footer): hideable legal links + socials + promo banner (#441 + #440)
Combined footer overhaul:

- Per-CMS-page show_in_footer toggle (#441) — admins can hide
  Impressum / Datenschutz from the gallery footer when an external
  privacy / imprint URL is enough.
- Five social-media URL fields in branding settings (#441) — Facebook,
  Instagram, WhatsApp, X/Twitter, YouTube. Empty string hides each
  icon individually; the row is omitted when none are set.
- Promotional banner slot above or below the gallery footer (#440) —
  global default authored as markdown in branding settings, plus a
  three-way per-event override on the Edit Event form
  (inherit / custom / off). Backend nulls promo_markdown automatically
  when mode != 'custom' so stale text never persists.

Sanitization: marked with gfm/breaks → DOMPurify with a tight
allowlist (no img, no tables, no inline html). Post-process forces
target=_blank rel="noopener noreferrer nofollow" on every link so
admin-set URLs can't tab-nap the gallery context.

i18n covers all six locales (en/de/nl/pt/ru/fr).

Targets the beta branch.
2026-05-10 21:46:20 +02:00
github-actions[bot] 481f8c2f9f chore(beta): release 3.44.1-beta.0 2026-05-10 19:01:51 +00:00
Paul Nothaft e54456135c fix(events): admins can clear expiration on edit even when "Require expiration" is ON (#426)
iSchumi6210 reported that with the global "Require expiration date"
toggle ON, an admin couldn't clear the expiration on an existing event
via the Edit Event form. The PUT returned 400 "Expiration date is
required."

The cause was intentional in the original code: the global setting was
enforced on both create AND edit, so once flipped ON, no event could
ever be cleared of its expiration — not even by admins editing one-by-
one. Reproduced the exact scenario byte-for-byte against beta:

  Toggle ON → POST /admin/events {expiration_days: 30} → 200 created
  Toggle ON → PUT /admin/events/:id {expires_at: null} → 400 rejected

The setting now controls only the create-time default. On edit, an
admin can clear the field and the value persists as NULL ("never
expires"). Matches CMS-style admin tool conventions where field-
required-by-default doesn't lock the field after creation.

Backend: drop the `getEventFieldRequirements()` enforcement on the
expires_at branch in PUT /admin/events/:id. Empty/null on edit
normalizes to NULL.

Frontend: drop the matching `requireExpiration && !editForm.expires_at`
toast in EventDetailsPage. The variable is no longer referenced, so
remove its declaration too.

Verified end-to-end with toggle ON:
  STEP 1: create with expiration → ok (unchanged)
  STEP 2: create without expiration → backend auto-applies default 30d
          (create-time enforcement intact)
  STEP 3: PUT {expires_at: null} on existing → "Event updated
          successfully" (was 400)
  STEP 4: DB column expires_at is NULL
  STEP 5: PUT {expires_at: ''} also accepted (matches what an HTML
          date input sends when cleared)

Smoke 13/13 green; no regressions.
2026-05-10 20:54:36 +02:00
github-actions[bot] f6be07ed4b chore(beta): release 3.44.0-beta.0 2026-05-10 18:47:55 +00:00
Paul Nothaft 15e333681f feat(settings): Features tab + sidebar reorg with feature-flag gating
Reorganises the admin sidebar around what users actually do, and adds a
single Features page that gates which feature surfaces appear in the
nav. Shrinks the main sidebar from 11 items to 4-6 (depending on
feature flags) and groups configuration screens into a single Settings
home with six logical sections.

Why
---
The current sidebar mixes three concerns: workspaces (Dashboard, Events,
Archives), feature surfaces (Analytics, Users), and configuration
screens that get touched maybe once a month (Email Settings, Branding,
Event Types, Backup, CMS Pages). That's 11 items, half of them config.

Backend
-------
- New `feature_flags` table (key, value, updated_at, updated_by).
  Migration 088 detects existing-vs-fresh installs from the events
  table:
    * Existing install (events>0)  → all 9 flags TRUE so nothing
      vanishes from an admin's UI on upgrade.
    * Fresh install      (events=0) → spec defaults: galleries,
      reminderEmails, analytics, userManagement TRUE; calendar,
      calendarBooking, quotes, bills, messaging FALSE.

- New `/api/admin/feature-flags` (GET/PUT) under `settings.view` and
  `settings.edit`. Server enforces the same dependency rules the
  frontend does (galleries always TRUE, quotes=false → bills=false,
  calendar=false → calendarBooking=false). PUT writes one
  `feature_flags_updated` activity log row with the diff.

Frontend
--------
- `FeatureFlagsContext` provides `useFeatureFlags()` (with staged/save/
  reset/isDirty) and `useFeatureEnabled(key)`. Mounted inside
  AdminLayout so flag fetches carry the auth cookie. Source of truth
  is the server response; staged is a local copy that the Features tab
  edits and the Save button PUTs.

- `RequireFeature` route guard for /admin/analytics and /admin/users —
  redirects to /admin/dashboard when the corresponding flag is OFF.

- AdminSidebar dropped from 11 to 6 items. Removed: Email Settings,
  Branding, Event Types, Backup, CMS Pages (now Settings tabs).
  Feature-gated: Analytics, Users.

- Old top-level routes (/admin/email, /admin/branding, /admin/event-
  types, /admin/backup, /admin/cms) kept as <Navigate> redirects to
  /admin/settings?tab=<key> so existing bookmarks don't 404.

- SettingsPage rewritten with a 6-group inner-nav (General /
  Content & Appearance / Communication / Privacy & Security /
  Integrations / System) and 19 tabs. New Features tab is the
  default landing tab. URL ?tab=<key> roundtrips with state — deep
  links and the back button work.

- FeaturesTab renders 9 cards across 5 sections. Toggles enabled for
  Analytics + User Management (the two flags that gate sidebar items
  in this PR). All other toggles disabled with a "Not yet available"
  lockedReason — the cards still render so admins see the roadmap, but
  the flag has no UI effect until the surface ships in its own PR. The
  galleries card is locked TRUE per spec (foundation, can't be off).

- Live SidebarPreview reflects unsaved staged changes — admins see
  what their sidebar will look like before they save.

- New i18n keys across all 5 locales (en, de, nl, pt, ru) for the
  Features tab copy, the new Settings group labels, and the lifted
  tab titles.

Verified end-to-end
-------------------
- Migration on this dev DB (existing install, 977 events): all 9 flags
  set to TRUE.
- Migration on simulated fresh install (events table emptied): spec
  defaults applied (5 OFF, 4 ON).
- Backend round-trip: GET → PUT → audit-log entry written, dependency
  rule enforced (bills forced false when quotes=false even when bills=
  true requested).
- UI Playwright spec: sidebar dropped 5 items, old top-level routes
  redirect, Features tab is default, Galleries+Calendar+Quotes+Bills+
  Messaging+ReminderEmails toggles disabled, Analytics+Users toggles
  enabled, toggling Analytics off + saving updates the sidebar +
  redirects /admin/analytics to /admin/dashboard.
- Smoke 13/13 still green; no regressions on existing flows.
2026-05-10 20:36:32 +02:00
github-actions[bot] fab0fcdde3 chore(beta): release 3.43.3-beta.0 2026-05-09 19:00:52 +00:00
Paul Nothaft d3007b0dd2 Merge pull request #438 from the-luap/fix/gallery-s3-serving-432
fix(gallery): serve thumbnails / photos / hero via storage abstraction (#432)
2026-05-09 21:00:31 +02:00
Paul Nothaft 83d79f4d39 fix(gallery): serve thumbnails / photos / hero via storage abstraction (#432)
Three gallery serving routes bypassed the getStorage() abstraction and
used fs.* directly against local paths. Worked in local-fs mode, 500'd
in S3 mode because the files only exist in the bucket. Reported by
@w1ll-i-code with a precise root-cause pointer at gallery.js:1138.

The admin photo serving route (adminPhotos.js) had already been
converted to use storage.stat + storage.get; the gallery side hadn't.
This PR brings the gallery routes in line.

Changes:

- Add getRange(relPath, start, end) to the StorageBackend interface +
  LocalFsStorage (fs.createReadStream with start/end) + S3StorageBackend
  (downloadStream with Range header). Needed for video range requests
  on S3 — previously the photo route did fs.createReadStream(filePath,
  {start, end}) which is local-only.

- /:slug/thumbnail/:photoId — read mtime via storage.stat, stream bytes
  via storage.get. Watermark application path materializes the source
  via withLocalCopy (no-op in local mode, downloads to a tmp file then
  cleans up in S3 mode) so applyWatermark's sharp + fs.readFile still
  works.

- /:slug/photo/:photoId — branches on source_origin: external/reference
  photos still use the local fs path (NAS mounts are local), managed
  photos use the storage abstraction. Video range requests pass through
  to storage.getRange. Pre-generated watermarks served via storage too.
  On-the-fly watermark generation uses withLocalCopy for managed photos.

- /:slug/hero/:photoId — hero images are always managed-storage keys
  (imageProcessor.generateHeroImage writes via the storage abstraction),
  so this just switches to storage.stat + storage.get. Watermark via
  withLocalCopy.

Verified end-to-end against minio in dev:
  POST /api/admin/photos/N/upload         → photo + thumbnail land in S3
  GET /api/gallery/<slug>/thumbnail/<id>  → 200, JPEG 300x300 ✓
  GET /api/gallery/<slug>/photo/<id>      → 200, JPEG 1200x800 ✓
  GET /api/gallery/<slug>/hero/<id>       → 200, JPEG 1920x1080 ✓
  ETag round-trip (If-None-Match)         → 304 ✓
  Backend logs                            → no errors

LocalFs regression: 13/13 smoke tests pass.

Closes #432.
2026-05-09 20:56:20 +02:00
github-actions[bot] 197524a918 chore(beta): release 3.43.2-beta.0 2026-05-09 18:36:38 +00:00
github-actions[bot] 9ca9563dbe chore(beta): release 3.43.1-beta.0 2026-05-09 18:34:26 +00:00
PiR1 916580adef fix(event): ensure client share token is generated only when necessary 2026-05-09 20:28:16 +02:00
PiR1 d00f6fa7de fix(event): correct updating client access 2026-05-09 20:26:55 +02:00
github-actions[bot] 967ade7a9a chore(beta): release 3.43.0-beta.0 2026-05-09 18:22:30 +00:00
github-actions[bot] 72a7a43a4a chore(beta): release 3.42.7-beta.0 2026-05-09 14:00:44 +00:00
Paul Nothaft e1c93823c4 Merge pull request #429 from the-luap/fix/cookie-secure-auto-default-427
fix(auth): default COOKIE_SECURE to 'auto' in production + first-install UX (#427)
2026-05-09 16:00:19 +02:00
github-actions[bot] 715a806991 chore(beta): release 3.42.6-beta.0 2026-05-09 13:59:36 +00:00
Paul Nothaft e2ffd9f93d Merge pull request #424 from the-luap/fix/external-thumbnails-423
fix(external-media): pre-generate thumbnails so reference-mode galleries load fast (#423)
2026-05-09 15:59:17 +02:00
Paul Nothaft 5c7de96b7f fix(auth): default COOKIE_SECURE to 'auto' in production + first-install UX (#427)
Two intertwined bugs reported in #427 by @iSchumi6210:

1. Login silently fails over HTTP. Backend defaulted COOKIE_SECURE to true
   when NODE_ENV=production. Over plain HTTP the browser drops the Secure
   cookie → next /auth/session request returns 401 → redirect back to
   /admin/login → no error shown. picpeak-setup.sh writes
   NODE_ENV=production but never writes COOKIE_SECURE, so every first-time
   install without a reverse proxy hits this.

2. Admin password is generated but admins can't find it. The 001_init.js
   migration writes the generated password to data/ADMIN_CREDENTIALS.txt
   inside the backend container, but picpeak-setup.sh only copies it out
   when --reset-admin-password is passed. Default-path users never see it
   and resort to manual bcrypt updates in psql.

Changes:

- tokenUtils.js: production default goes from `true` to `'auto'`. On real
  HTTPS req.secure is true → Secure flag is still emitted (no security
  regression for reverse-proxy deployments). On plain HTTP req.secure is
  false → Secure flag omitted → login works. Users who explicitly want
  the strict HTTPS-only behaviour can still set COOKIE_SECURE=true.

- .env.example: rewrite the COOKIE_SECURE block to make the new default
  obvious and explain when to override (set =true for strict, =false to
  skip the per-request check, leave unset for the auto behaviour).

- picpeak-setup.sh (both Docker and native paths):
  - Write COOKIE_SECURE=auto explicitly to the generated .env (defense in
    depth so the right behaviour is preserved even if the backend default
    flips again later)
  - After migrations, ALWAYS copy ADMIN_CREDENTIALS.txt out of the
    backend container/data dir to the host data dir, chmod 600, and print
    the email + password to the install output. The credentials file
    remains as a backup record that the operator should delete after
    noting the password.

Verified locally with all 4 permutations of NODE_ENV × COOKIE_SECURE:

  production, unset      → HTTPS: secure=true ✓  HTTP: secure=false ✓ (was both true)
  production, =true      → both: secure=true (strict opt-in preserved)
  production, =auto      → HTTPS: secure=true   HTTP: secure=false (already-correct)
  development, unset     → both: secure=false (dev unchanged)
2026-05-09 15:55:09 +02:00
Paul Nothaft f3d0f161c9 fix(external-media): pre-generate thumbnails so reference-mode galleries load fast (#423)
External (source_origin='external') photos always had thumbnail_path=NULL,
so the gallery returned thumbnail_url=null and every layout fell back to
streaming the full original from the NAS via the secure-image route.
With ~100 NAS-mounted photos that meant minutes of wall-clock load time,
sequential per tile.

Two halves:

1. import-external route generates the thumbnail right after each
   successful insert and writes thumbnail_path on the row. Best-effort:
   a single failure logs a warning and leaves thumbnail_path=NULL —
   ensureThumbnail will retry lazily on first view. Synchronous in the
   loop adds ~100-300ms per image; for the worst-case 1000-photo import
   that's still under the typical request timeout.

2. ensureThumbnail() in imageProcessor handles external photos too —
   resolves the local NAS mount path via resolvePhotoFilePath instead of
   the storage-backend key. This covers existing externals already in
   the database that were imported before this fix: first gallery view
   per photo regenerates the thumbnail, subsequent views are fast.

Filename-collision protection: external thumbnails use
`thumb_ext<photoId>_<basename>` so two events both referencing
e.g. `IMG_0001.jpg` on different NAS subtrees can't clobber each other's
thumbnail. generateThumbnail accepts a new options.outputBasename to
support this without changing the managed-photo behaviour.

Verified locally with a 3-photo external dir and a real NAS-style import:
  POST /api/admin/external-media/events/N/import-external
  → {imported:3, thumbnailsGenerated:3, thumbnailsFailed:0}
  /api/gallery/<slug>/photos returns thumbnail_url for every photo
  Lazy-regen path: clearing thumbnail_path + deleting the file, then
  hitting /thumbnail/N regenerates and repopulates the row in 42ms.

Closes #423.
2026-05-08 19:17:15 +02:00
github-actions[bot] 08707eee9e chore(beta): release 3.42.5-beta.0 2026-05-08 08:15:24 +00:00
Paul Nothaft 9326a427b3 Merge pull request #420 from the-luap/fix/update-notification-test-email-418
fix(admin): test email always sends, regardless of update availability (#418)
2026-05-08 10:15:01 +02:00
github-actions[bot] 9ade631e13 chore(beta): release 3.42.4-beta.0 2026-05-08 08:14:16 +00:00
Paul Nothaft c2b1854df6 fix(admin): test email always sends, regardless of update availability (#418)
The "Send Test Email" button on the Update Notifications settings page
called sendUpdateNotificationNow() — which bailed out with "No updates
available" when the instance was already on the latest version. Admins
on a current install had no way to verify their SMTP / recipient list
was working until an update happened to be pending. Reported in #418
by @Rekoo-PS.

Changes:

- Add migration 087: insert a dedicated `version_update_test` email
  template (EN + DE, matching the existing version_update_available
  convention) with copy that reads as a config-check rather than as a
  real update notice. Subject prefixed with [TEST] so it's unambiguous
  in the inbox. Variables: current_version, channel, recipient_email.

- Replace sendUpdateNotificationNow() with sendTestUpdateNotification()
  in updateNotificationService.js. The new path:
    - Always sends — no updateAvailable bail-out.
    - Uses the version_update_test template.
    - Falls back gracefully if checkForUpdates fails (so a transient
      GitHub API hiccup doesn't block a config-check email).
    - Does NOT update last_notified_version — that field stays owned by
      the real-update path so a test send doesn't shadow a future
      genuine notification for the same version.

- Wire /admin/system/updates/notifications/send to the renamed function.
  No frontend change needed (the button already calls this endpoint).

Verified locally with the dev mailhog: clicking Send Test Email on a
3.42.3-beta.0 instance (which has no pending update) delivers 4 emails
to all admin recipients with subject "[TEST] PicPeak Update Notification
— configuration check" and body interpolated correctly. Returns
{success: true, successCount: 4, ...} — previously would have returned
{success: false, message: "No updates available"}.
2026-05-08 09:57:31 +02:00
Paul Nothaft 99e420b1b9 fix(events): typed-DELETE confirmation for bulk delete (#417)
The bulk-delete modal previously used a password input as a confirmation
gate, with an Enter-to-submit handler. Windows Hello / passkey flows
that target password fields were able to autofill and synthesise an
Enter keystroke, which submitted the form and triggered the destructive
delete without an explicit click on the red Delete button (Rekoo's
report in #417).

Replace the password gate with a GitHub-style typed-literal pattern:
the user types the literal "DELETE" (English, case-sensitive) into a
plain text input. The Delete button stays disabled until the input
matches, and there is no Enter-to-submit handler — only an explicit
click on the red button proceeds. Plain text inputs aren't subject to
password autofill or passkey ceremony so the auto-submit class of bug
is gone.

Server side, drop the bcrypt password verify on /admin/events/bulk-delete
and the related INVALID_PASSWORD response. The server's auth boundary
remains adminAuth + requirePermission('events.delete'); this matches
DELETE /admin/events/:id which has never required a re-entered password.
The client-side typed gate is the safeguard against accidental clicks.

i18n: drop password-related keys, add confirmLabel + confirmHelp across
en, de, nl, pt, ru. The literal "DELETE" stays English in all locales
to keep the gesture immune to translation drift and unambiguous.

Verified locally: typed-DELETE sanity spec covers the gate (wrong case
disabled, correct enables, Enter-on-input no-ops, click submits, events
deleted). Existing 03-bulk-archive smoke remains green.
2026-05-08 09:46:24 +02:00
github-actions[bot] e8df3de3fb chore(beta): release 3.42.3-beta.0 2026-05-07 21:04:20 +00:00
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