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.
| 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 |
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 |
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.
* `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
* 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`.
Stable release promoting the entire `beta` channel to `main`. Brings
~300 commits of features, fixes, and infrastructure improvements that
have been baked on the beta channel since v2.6.5.
## Major themes since v2.6.5
* Multi-administrator support with RBAC (super admin / admin / editor)
* Async upload pipeline (background worker pool for sharp/ffmpeg/EXIF/
watermark/webhooks; bytes-on-wire returns 202)
* Self-hosted webfonts (filesystem-driven scanner; replaces Google Fonts
CDN; GDPR-compliant)
* 8-token CI palette + force color mode (full theming across admin and
public site, with WCAG-safe contrast helpers)
* Native multi-arch Docker images (Apple Silicon + ARM64 Linux native)
* Native S3 storage backend (S3 + S3-compatible providers)
* Comprehensive video support (MP4/WebM/MOV upload, stream, play)
* Outbound webhooks for event/photo lifecycle (HMAC-signed)
* Gallery layout overhaul (decoupled header style, banner option,
theme-aware skeletons, lazy-loaded folder tree picker)
* Multilingual email templates (EN/DE/NL/PT/RU translations table)
* Bulk operations (delete with password gate, archive)
* Photo dimensions backfill (true masonry layout)
* Customer client access (review area before guest share)
* Image security (devtools detection, watermarking, right-click,
secure thumbnails)
## Notable bug fixes from beta
* `/auth/session` symmetry — three rounds of fixes (#350, #355, #363,
#398) for the admin-login redirect-loop family
* Email template renderer: handle {{#if}} conditionals, fix CSS leak in
plain-text fallback, gate publish-from-draft password placeholder,
gate external_url in public response
* Caller/template variable drift across gallery_created,
expiration_warning, archive_complete, gallery_expired
* Full-URL gallery_link in all email types (was path-only in 3 sites)
* ffmpeg/ffprobe via apk for Alpine compatibility (was glibc-bundled)
* Admin events search and counters not bounded to first 100 (#346)
## Conflict resolution notes
* `README.md` — kept main's leaner v2.6.5 rewrite (#281); added a
Contributors section adapted from PR #393.
* `DEPLOYMENT_GUIDE.md` — beta version (more recent, includes External
Media docs already backported to main).
* `CHANGELOG.md` — new 3.42.1 entry leads, beta's 3.x history follows,
main's 2.x entries appended below a divider so the historical chain
is preserved.
* `package.json` (backend + frontend) — beta's structure with version
bumped from `3.42.1-beta.0` → `3.42.1`.
* `package-lock.json` (backend + frontend) — regenerated via
`npm install --package-lock-only`.
* `.release-please-manifest.json` — bumped from `2.6.5` → `3.42.1` so
the next release-please run on main starts from the correct base.
## Pre-flight checks
* Frontend `tsc --noEmit` — clean
* Frontend `vite build` — clean (~3.5s, 2.6 MB main chunk; existing
warning about chunking, not new)
* Backend `npm test` — pre-existing failures in 6 integration suites
(DB-fixture-dependent, not regressions)
* Frontend `vitest` — pre-existing failures in
ThemeCustomizerEnhanced.test.tsx (missing QueryClientProvider after
PR #390 added useQuery; not a regression of this merge)
The pre-existing test failures are tracked as separate follow-ups and
do not block this release promotion.
Two follow-ups from PR #401's review:
1. Download button text was hardcoded `color: '#ffffff'`. Once admins
start picking palettes via #400's expanded customizer, a pale accent
(yellow, pastel blue, etc.) leaves the button unreadable — white
text on near-white background.
Fix: derive the foreground colour from the accent's WCAG relative
luminance and expose it as the new `--color-accent-fg` CSS variable
in ThemeContext.applyTheme. Light backgrounds (L >= 0.5) get black
text; dark backgrounds get white. Same treatment applied to
`--color-accent-dark-fg` for the filled-CTA token.
The Download button now reads `var(--color-accent-fg, #ffffff)` so
any future component that paints on accent gets the same treatment
for free, and legacy deployments before the variable is set fall
back to the previous hardcoded white.
Threshold-based (rather than "highest contrast ratio") to preserve
how saturated mid-tone accents have always rendered. The Picpeak
default green (#5C8762, L≈0.20) keeps white text — same visual
identity as before. Only genuinely pale accents flip to black,
which is the actual scenario the review flagged.
2. The Download button JSX was duplicated three times in
GalleryLayout.tsx (standard/banner, minimal, hero — ~15 lines
each). Extracted into a small inline `HeaderDownloadButton`
component above the GalleryLayout export. Three call sites now
collapse to a 5-line component invocation each. Markup,
accessibility, and styling live in one place — future tweaks
only need to happen once.
## Files
- `frontend/src/utils/contrast.ts` — new helper module:
`relativeLuminance(hex)` (WCAG 2.x sRGB luminance) and
`getReadableForeground(hex)` (white-or-black picker).
- `frontend/src/utils/__tests__/contrast.test.ts` — 10 cases:
fallbacks, saturated mid-tones, pale accents, near-black,
shorthand `#RGB`, no-leading-`#`, case-insensitive, anchors
(black/white luminance).
- `frontend/src/contexts/ThemeContext.tsx` — wire the helper into
`applyTheme`: set `--color-accent-fg` from `accentColor` and
`--color-accent-dark-fg` from `accentDarkColor`/`primaryColor`.
- `frontend/src/components/gallery/GalleryLayout.tsx` — extract
`HeaderDownloadButton` component above `GalleryLayout`, replace
three inline button blocks with the component, update its inline
style to read `--color-accent-fg` (with the legacy `#ffffff` as
the CSS-variable fallback).
## Verified
- `npx vitest run src/utils/__tests__/contrast.test.ts` — 10/10 pass
- `npx tsc --noEmit` — clean
- `npx eslint` clean on every touched file
- Default PicPeak green still renders white text (no regression)
- Pale accent (#fef9c3 yellow-100) now correctly renders black text
Addresses the-luap/picpeak#386 — gallery header layout cleanup.
- Drop the redundant "Menu" text label; menu button is icon-only with
tight padding (p-2).
- Absolute-position the menu icon at the very left of the header so it
no longer pushes the logo right with every other action. Logo wrapper
picks up pl-12 sm:pl-14 only when a menu button is rendered, so the
icon and logo don't overlap. When no menu button (controlsStyle:
classic), logo is flush with .container.
- New accent-coloured "Download" CTA placed immediately left of Logout.
Always visible when downloads are allowed; replaces the previous
primary-coloured "Download All" header button. Same CTA appears in
standard, hero, and minimal headers. Intentionally NOT shown in the
no-header variant (chromeless by design).
- Coloured via var(--color-accent) inline so the button automatically
tracks whatever palette the admin has chosen — works on plain beta
today (#22c55e) and auto-upgrades to the CI accent when #400 lands.
The sidebar's own Download All is untouched. Old showDownloadAll prop
stays on GalleryLayout for back-compat; GalleryView now passes
showDownloadAll={false} so only the new accent button renders in the
header.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
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.
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.