Commit Graph

1883 Commits

Author SHA1 Message Date
Paul Nothaft 84f370f4c2 Merge remote-tracking branch 'origin/main' into pr-834
# Conflicts:
#	frontend/src/components/gallery/UserPhotoUpload.tsx
#	frontend/src/i18n/locales/de.json
#	frontend/src/i18n/locales/en.json
#	frontend/src/i18n/locales/es.json
#	frontend/src/i18n/locales/fr.json
#	frontend/src/i18n/locales/nl.json
#	frontend/src/i18n/locales/pt.json
#	frontend/src/i18n/locales/ru.json
#	frontend/src/i18n/locales/sl.json
#	frontend/src/services/publicSettings.service.ts
#	frontend/src/utils/__tests__/fileTypes.test.ts
2026-07-18 21:02:10 +02:00
Paul Nothaft 8c260c4eeb Merge pull request #833 from PicPeak/feat/guest-upload-dng-raw
feat(uploads): DNG / camera-RAW support via embedded-preview extraction (#821)
2026-07-18 20:58:44 +02:00
Paul Nothaft ec69ad84f2 chore(main): release 3.91.0-beta.0 (#835)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.91.0-beta.0
2026-07-18 18:52:51 +00:00
Paul Nothaft d7ba781c0f Merge remote-tracking branch 'origin/main' into feat/guest-upload-dng-raw
# Conflicts:
#	backend/src/services/uploadSettings.js
#	backend/src/utils/fileSecurityUtils.js
#	frontend/src/utils/fileTypes.ts
2026-07-18 20:52:08 +02:00
Paul Nothaft ee9d2f70d3 Merge pull request #832 from PicPeak/feat/guest-upload-heic-dynamic-hint
feat(uploads): HEIC/HEIF support + dynamic format hint on guest upload (#821)
2026-07-18 20:47:36 +02:00
Paul Nothaft d0ccadbc99 fix(uploads): RAW derivative key collision, watermark skip, dev exiftool (codex review of #833 round 2)
- Derivative key collision: processUploadedPhotos/replacePhoto passed the
  client-supplied original filename as the RAW output basename, but thumbnails/
  heroes/previews are global keys — two galleries uploading IMG_0001.dng would
  overwrite each other's derivative. Use the unique stored newFilename instead.
  (processPhoto already used the unique photo.filename.)
- Watermark: the watermark path opens the original with sharp, which can't decode
  RAW, so it fell back to the original bytes and recorded the copy as watermarked.
  Skip RAW in generateForPhoto (like videos) so the watermark state stays honest
  until RAW watermarking is properly supported.
- exiftool added to Dockerfile.dev so dev/native runtimes don't accept a DNG then
  fail it with ENOENT.
2026-07-17 22:50:24 +02:00
Paul Nothaft b743ea0398 fix(uploads): apply RAW extraction in the actual async ingest path (codex review of #833)
The RAW/DNG extraction was only wired into processUploadedPhotos() (the
synchronous path), but real uploads queue to 'pending' and are handled by the
background worker → processPhoto(), which generated the thumbnail + dimensions
directly from the DNG (both fail) and then marked the photo 'complete' — success
with no thumbnail. Wire withProcessableImage() into processPhoto() (the live
path) and into photoReplacementService.replacePhoto() (replace-by-name), so all
three ingest paths extract the embedded JPEG preview for RAW.

Updates the processPhoto test's imageProcessor mock with the new
withProcessableImage dependency (pass-through for ordinary images).
2026-07-17 22:35:11 +02:00
Paul Nothaft 808d305549 fix(gallery): serve JPEG preview for non-displayable originals in lightbox (codex review of #832)
The lightbox falls back to photo.url (the ORIGINAL) when preview_url is null,
which happens by default (lightbox_preview_enabled=false). For HEIC/HEIF/DNG the
original bytes aren't renderable in an <img>, so the lightbox showed a broken
image. Now force preview_url for those formats (by MIME or extension) regardless
of the toggle, so the browser always gets the generated JPEG preview. Covers DNG
too (forward-compatible with #833).

EXPERIMENTAL caveat unchanged: whether the preview actually renders still depends
on the backend decoding the source — HEVC-in-HEIC on the prod Alpine image is
unverified, DNG needs exiftool (#833). Documented on the PR.
2026-07-17 22:21:43 +02:00
Paul Nothaft e732e13f24 fix(uploads): DNG magic must be a single entry (.every validation)
The magic-number check in validateFileContent uses .every(), so the two
endianness entries (II + MM) could never both match — an admin DNG upload would
be rejected at content validation. Use the little-endian II magic only (Apple
ProRAW / camera DNGs); a rare big-endian DNG fails the check and is rejected,
which is safe since the embedded-preview extraction validates real content.
2026-07-17 22:07:32 +02:00
Paul Nothaft c9b64d9c1a fix(uploads): register HEIC/HEIF with the file validator + fix admin format hint (codex review of #832)
Two findings from the Codex review:

- validateFileType requires an ALLOWED_MEDIA_TYPES entry, which had neither
  image/heic nor image/heif — so HEIC was rejected before sharp ever saw it,
  despite the EXTENSION_TO_MIME additions. Added both with a single 'ftyp'
  (offset 4) magic number (the check is .every, so alternatives can't be
  separate entries).

- Changing the shared upload.fileRequirements string to interpolate {{formats}}
  left the admin PhotoUpload caller passing only { limit }, rendering the
  placeholder literally (it was also already dropping {{sizeLimit}} from #823).
  The admin caller now passes formats + sizeLimit + limit, from the admin
  settings it already loads.
2026-07-17 22:06:40 +02:00
Paul Nothaft be2ec0a4a1 feat(uploads): DNG / camera-RAW support via embedded-preview extraction (#821)
Sharp's bundled libvips has no raw loader, so a DNG can't be thumbnailed
directly. This adds a preview-extraction step so RAW/DNG uploads get a proper
thumbnail + gallery preview while the original RAW is kept for download.

- imageProcessor: isRawFilename() + extractRawPreview() (exiftool extracts the
  embedded full-res JPEG — JpgFromRaw → PreviewImage → ThumbnailImage, validated
  with sharp) + withProcessableImage() which is a pass-through for ordinary
  images and swaps in the extracted JPEG for RAW. Wired into ingest
  (photoProcessor) and all three on-demand generators (ensureThumbnail/Hero/
  Preview). generateHeroImage/generatePreviewImage gained outputBasename so
  RAW-derived outputs stay named after the source.
- Dockerfile: add exiftool (confirmed present in Alpine v3.24 community).
- Format maps: dng → image/x-adobe-dng in uploadSettings.js and fileTypes.ts;
  ALLOWED_MEDIA_TYPES gains a DNG entry (TIFF magic numbers) so it passes the
  security file-validator.

Strictly gated by extension: nothing in this path runs for jpg/png/webp/etc, so
existing photos are unaffected. If extraction fails (corrupt RAW, no embedded
preview), the photo is marked 'failed' with a clear error — same as any
unreadable upload.

Verification boundary (please validate on a real DNG after the image rebuilds):
the exiftool extraction itself couldn't be exercised in the dev sandbox
(exiftool isn't a dev dependency and there's no DNG fixture). Unit tests cover
the gating (RAW detection + non-RAW pass-through + clean failure without
exiftool); existing processPhoto tests still pass. Known limitation: a DNG is
only accepted when the browser reports its MIME as image/x-adobe-dng (Chrome
does); browsers that send an empty type reject it client- and server-side —
a follow-up can add extension-based acceptance for the RAW set.

Companion to the HEIC/dynamic-hint PR; targets main only.
2026-07-17 21:51:21 +02:00
Dodothereal f4b685a5ab fix(uploads): allow configured raw formats
Assisted-by: Claude Code
2026-07-17 21:50:32 +02:00
Paul Nothaft 8e0005e170 chore(main): release 3.90.2-beta.0 (#826)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.90.2-beta.0
2026-07-17 19:45:29 +00:00
Paul Nothaft 43c6d22bdd Merge pull request #830 from PicPeak/fix/guest-upload-size-followup
fix(uploads): tighten guest max-file-size setting (codex review of #823)
2026-07-17 21:39:38 +02:00
Paul Nothaft 2b5b23b96f feat(uploads): HEIC/HEIF support + dynamic format hint on guest upload (#821)
Two of the three things from #821:

- HEIC/HEIF (iPhone) can now be enabled. Sharp's bundled libvips decodes `heif`
  input (verified: sharp.format.heif.input.file === true on 0.34.3 / libvips
  8.17.1), so thumbnails generate. Added heic/heif to EXTENSION_TO_MIME in both
  the backend (uploadSettings.js) and the frontend (fileTypes.ts) maps, which
  are kept in sync. (iOS Safari usually transcodes HEIC→JPEG at file selection,
  but a genuine .heic upload is now handled when it arrives.)

- The upload requirements hint no longer hardcodes "JPEG, PNG or WebP". New
  extensionsToLabel() renders the actually-configured, supported formats (e.g.
  "JPG, PNG, WEBP, MOV"), and upload.fileRequirements interpolates {{formats}}
  across all 8 locales. Unsupported extensions are dropped from the label so it
  never advertises a format the backend would reject.

DNG / camera RAW is deliberately NOT included: Sharp's libvips has no raw loader,
so a DNG would upload then fail thumbnailing (photo → 'failed', no preview).
Proper RAW support (embedded-preview extraction) is a separate PR.

Adds vitest coverage for extensionsToLabel + the HEIC mapping.
2026-07-17 21:39:34 +02:00
Paul Nothaft 0245e445ca Merge pull request #828 from PicPeak/fix/hero-logo-visible-null-validation
fix(events): accept hero_logo_visible: null on create/update (#822)
2026-07-17 21:39:12 +02:00
Dodothereal 433fb9a989 fix(uploads): show configured guest file types
Assisted-by: Claude Code
2026-07-17 21:35:53 +02:00
Paul Nothaft e03d13efde fix(uploads): tighten guest max-file-size setting (codex review of #823)
Three follow-ups from the Codex review of #823:

1. PublicSettings TypeScript interface was missing general_max_file_size_mb,
   so UserPhotoUpload's access produced TS2339 under `tsc -b` (build:check). CI
   didn't catch it because the pipeline runs `build` (esbuild, no typecheck),
   but it's a real type gap — the #614 count field is declared, this one wasn't.
   Added the optional numeric field.

2. The general-settings update endpoint validated general_max_files_per_upload
   but not general_max_file_size_mb, so an out-of-range value (0, -1, huge)
   could persist. publicSettings then advertised the raw value while
   getMaxFileSizeMb() normalised it — the guest UI would reject files the
   backend accepts. Added the same validate-and-clamp block (1..MAX_ALLOWED_FILE_SIZE_MB).

3. The update route cleared the file-count cache but not the new file-size
   cache, so for up to 60s the public endpoint could advertise a new limit
   while multer still enforced the old one. Now clears both under the same
   uploadLimitTouched guard.

Follow-up on the merged #823 (main-only), so this targets main only.
2026-07-17 21:30:48 +02:00
Paul Nothaft b97b130cad fix(events): accept hero_logo_visible: null on create/update (#822)
hero_logo_visible is nullable — null means "inherit the global
branding_logo_display_hero toggle" (#756, migration 152). But the create and
update validators used `.optional()` without `{ nullable: true }`, which only
skips `undefined`; an explicit `null` still ran `.isBoolean()` and failed with
HTTP 400 "Invalid value". Saving an event with `hero_logo_visible: null` (the
inherit state the frontend sends) was rejected on v3.45.2.

- Both routes: `body('hero_logo_visible').optional({ nullable: true }).isBoolean()`,
  matching the already-correct `hero_logo_size` rule next to it.
- Create handler: guard on `!= null` instead of `!== undefined` so an explicit
  null stores NULL (inherit) rather than being coerced to 0/false by
  formatBoolean on SQLite. The update handler already did `=== null ? null`.

Left hero_logo_position on plain `.optional()` on purpose: its column is NOT
NULL (no inherit migration) and its handler always resolves to a concrete value
via `|| brandingDefaults`, so null is genuinely invalid there — allowing it
would trade the 400 for a 500.

Adds smoke tests: PUT accepts hero_logo_visible: null and stores NULL; a
non-boolean value is still rejected.
2026-07-17 21:13:39 +02:00
Paul Nothaft 2a0361a83b Merge pull request #824 from PicPeak/fix/update-instructions-production-compose
fix(update): target docker-compose.production.yml in dashboard update steps + gate mailhog
2026-07-17 21:03:06 +02:00
Paul Nothaft 29f1d23a0a Merge pull request #823 from PicPeak/fix/guest-upload-max-file-size
fix(uploads): apply configured max file size to guest uploads (#613 follow-up)
2026-07-17 21:02:35 +02:00
Paul Nothaft 51a505e379 fix(update): target docker-compose.production.yml in dashboard update steps
Production installs use docker-compose.production.yml (the README's documented
path, pinned GHCR images, no dev services), but the dashboard's update
instructions emitted bare `docker compose pull` / `up -d`. Bare `docker compose`
operates on docker-compose.yml — a different, build-based stack — so a
production user who followed the steps:
  - never pulled/recreated their real containers (stayed on the old version,
    e.g. stuck on 3.44.0 after "updating" to 3.45.2), and
  - started the dev-only mailhog service that docker-compose.yml defines
    (reported restart-looping).

The backend runs inside a container and can't stat the host's compose files, but
docker-compose.production.yml passes PICPEAK_RELEASE_CHANNEL into the backend env
and docker-compose.yml does not. detectEnvironment() now derives
isProductionCompose from it, and the Docker update steps prepend
`-f docker-compose.production.yml` when set. The non-production branch keeps the
bare commands but the warning now tells users to add `-f docker-compose.production.yml`
if they installed with it.

Also gates the mailhog service in docker-compose.yml behind a `dev` compose
profile so a plain `docker compose up -d` never starts it (opt in with
`docker compose --profile dev up -d`). Nothing depends on it (SMTP_HOST comes
from .env), so gating is safe. Verified: `docker compose config` lists mailhog
only with `--profile dev`; production compose is unchanged.

Adds unit tests for the production-vs-default command generation.
2026-07-17 20:55:49 +02:00
Paul Nothaft 1e38d84808 fix(uploads): apply configured max file size to guest uploads (#613 follow-up)
The admin's Settings → General → "Max File Size (MB)" value
(general_max_file_size_mb) never applied to guest gallery uploads — the guest
route hardcoded multer's per-file cap at 50MB (gallery.js) and the guest UI
hardcoded the same 50MB client-side guard and "max 50MB" hint text. So a guest
could not upload a large video even when the admin raised the limit (reported by
mat1990dj on #613). Same class as the file-count miss fixed in #614, for size.

- uploadSettings.js: new getMaxFileSizeMb()/getMaxFileSizeBytes() reading
  general_max_file_size_mb (default 50MB, cached 60s, clamped to a 10GB ceiling),
  mirroring getMaxFilesPerUpload.
- gallery.js (guest upload): multer limits.fileSize now resolves from the
  setting; a LIMIT_FILE_SIZE error returns an actionable "max N MB" message.
- publicSettings.js: exposes general_max_file_size_mb (default 50) so the gallery
  UI can render the real limit and guard client-side before an oversized POST.
- UserPhotoUpload.tsx: reads the limit, uses it for the client-side size guard,
  and passes it to the requirements hint. The "max 50MB" literal in
  upload.fileRequirements is now interpolated ({{sizeLimit}}) across all 8
  locales; adds upload.fileTooLarge (en/de; others fall back to en).

Scope: guest path only (the reported gap). The admin path keeps its generous
10GB cap — admins are trusted and default 50MB would otherwise regress large
admin video uploads. Format and batch-size limits already work correctly and are
untouched. Adds SQLite-backed unit tests for the new getter.

Verified end-to-end on a booted instance: admin sets 500MB → persisted → public
settings exposes 500 → guest multer sources its cap from it.
2026-07-17 20:41:27 +02:00
Paul Nothaft 1b32d4691e chore(main): release 3.90.1-beta.0 (#820)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.90.1-beta.0
2026-07-17 07:35:38 +00:00
Paul Nothaft e7ca8bdb7f Merge pull request #817 from PicPeak/fix/legacy-events-router-bola
fix(security): remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq)
2026-07-17 09:29:20 +02:00
Paul Nothaft 6cd546e86a fix(security): remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq)
The legacy gallery router mounted at /api/events exposed create/list/update/
delete/extend guarded by adminAuth ALONE — no requirePermission, no
requireEventOwnership. adminAuth only checks the token is a valid type:'admin'
session, which every back-office role holds, down to read-only `viewer`. So any
non-super-admin account could:
  - GET /api/events → every gallery's bcrypt password_hash, share_token, and
    client name/email (the list handler selects * and mapEventForApi keeps
    those columns),
  - PUT /api/events/:id → reset any gallery's password (full takeover),
  - DELETE /api/events/:id → delete any gallery,
all bypassing the per-photographer ownership isolation the canonical
/api/admin/events router enforces. Affects any instance with more than the
single super_admin.

Fix: remove the legacy router entirely (mount + require + src/routes/events.js).
It was a superseded duplicate of /api/admin/events and unused by the frontend
EXCEPT for one live route — POST /:id/extend (the "Extend expiration" UI action,
which hit /api/events/:id/extend via the api client's /api base). That route is
migrated to the canonical mount as POST /api/admin/events/:id/extend with the
same guards as every other gallery mutation (adminAuth + requirePermission
('events.edit') + requireEventOwnership), and the frontend is repointed to it.
Behaviour of the extend itself is unchanged (expires_at + reactivate).

Verified end-to-end on a booted instance: /api/events (all methods) now 404;
/api/admin/events/:id/extend returns 401 unauth, 200 for the owner, 403 for a
non-owning editor; the full login→create→extend flow works. Adds a regression
test pinning the router removal and the extend ownership check.
2026-07-17 09:16:51 +02:00
Paul Nothaft 7f22a9ee3d chore(main): release 3.90.0-beta.0 (#816)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.90.0-beta.0
2026-07-16 12:08:11 +00:00
Paul Nothaft f12606b4e0 Merge pull request #806 from PicPeak/feat/oidc-sso-phase1
feat(auth): OIDC SSO for admin users — phase 1
2026-07-16 14:04:39 +02:00
Paul Nothaft cbde7636aa Merge remote-tracking branch 'origin/main' into feat/oidc-sso-phase1
# Conflicts:
#	backend/src/middleware/maintenance.js
2026-07-16 13:53:28 +02:00
Paul Nothaft b5ac24ea46 chore(main): release 3.89.0-beta.0 (#814)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.89.0-beta.0
2026-07-16 11:43:50 +00:00
Paul Nothaft a77c2c2c57 Merge pull request #813 from PicPeak/feat/harden-picpeak-restore-robustness
feat(security): harden .picpeak restore robustness — sessions, roles, sequences
2026-07-16 13:39:17 +02:00
Paul Nothaft 7ebc232620 Merge pull request #811 from PicPeak/fix/security-advisories-backend
fix(security): close 4 open security advisories (backup takeover, share-login bypass, ZIP slip, chunked-upload traversal)
2026-07-16 13:37:28 +02:00
Paul Nothaft 199dab82ae Merge pull request #808 from PicPeak/fix/docker-image-os-cves
chore(security): close 21 frontend image CVEs — nginx 1.30 base + apk cache-bust
2026-07-16 13:37:25 +02:00
Paul Nothaft 340d91bdd5 feat(security): harden .picpeak restore robustness — sessions, roles, sequences
Implements the three restore-hardening items deferred from the #811 Codex
review (all validated against a real Postgres, see __tests__/integration/
picpeakRestorePg.test.js). Backend-only; targets main (feature, not a backport).

1. Global session cutoff (utils/sessionCutoff.js). A restore reassigns admin/
   customer/event ids, so ANY pre-restore JWT can rebind to a different restored
   principal. Revoking just the importing token wasn't enough. importFromPicpeak
   now stamps a unix-second cutoff in app_settings after the restore commits, and
   adminAuth / galleryAuth / verifyGalleryAccess / customerAuth reject any token
   whose iat predates it (cached 30s → one in-memory compare on the hot path).
   The operator's forced re-login mints a token past the cutoff, so it passes.

2. Role preservation across an RBAC replace (captureOperatorRole /
   preserveOperatorRole). The operator's role + granted permission NAMES are
   captured before the wipe; after roles/role_permissions are replaced the role
   is resolved by NAME against the restored data, and re-created with its grants
   if the backup omits it — so a crafted or cross-instance backup can't silently
   downgrade or lock out the operator. reinjectCurrentAdmin now returns the
   operator's id so the row can be re-pointed at the resolved role.

3. Postgres identity-sequence resync (resyncSequences). batchInsert writes
   explicit ids without advancing the sequences, so the next natural insert into
   any restored table collided on the PK. Runs AFTER commit (setval isn't
   transactional) and guards every table with a column-existence check —
   pg_get_serial_sequence RAISES on id-less tables like role_permissions.
   No-op on SQLite.

Tests: SQLite unit tests for the cutoff and role preservation; a gated Postgres
integration suite (npm run test:pg with PICPEAK_PG_TEST_URL) covering sequence
resync, the id-less-table guard, explicit-id reinject, role re-creation, and a
full cross-instance replaceAllTables run asserting operator preservation, role
re-establishment, FK integrity, and collision-free post-restore inserts.

Stacks on #811 (shares the reinject hardening); merge after it.
2026-07-16 12:56:34 +02:00
Paul Nothaft 38fd41aad3 fix(security): harden .picpeak restore operator-preservation (GHSA-qxfx follow-up)
The req.admin.id fix activated reinjectCurrentAdmin(); hardening its preservation
logic (found across Codex review rounds of #811):

- MFA hijack: reinject wrote back only password_hash/is_active/
  must_change_password, leaving a crafted backup's two_factor_* on the
  operator's row — it could strip or replace their second factor. The email-
  matched row is now updated with the operator's full AUTH set (login identity,
  password, and all two_factor_* columns). Relationship/audit FKs (role_id,
  created_by) are deliberately NOT forced from the snapshot: on a cross-instance
  restore those pre-restore ids may be absent from the backup and would dangle
  the FK (SQLite rolls back at commit); the restored row keeps its own valid
  values.

- Cross-instance restore rollback / FK safety: reinject matched only by email,
  so a backup shipping a different admin with the default `admin` username hit
  UNIQUE(username) and rolled the whole restore back; email and username could
  even collide on two different rows. Reconciliation is now non-destructive:
  the email-matching row is updated in place (id preserved → restored FKs like
  events.created_by stay valid); any different row holding the operator's
  username is RENAMED, not deleted (deletion would fire ON DELETE actions /
  dangle references); only when no row has the operator's email is a fresh row
  inserted, with created_by nulled and an explicit max(id)+1 id (batchInsert
  left the Postgres identity sequence unadvanced, so a sequence-based insert
  could collide).

- Stale session after restore: admin_users ids shift on restore, but the
  operator's live JWT is bound only to decoded.id (IP logged not enforced; the
  backup controls password_changed_at). The route now revokes the token (result
  checked and logged) and clears the admin cookie; the client redirects to a
  fresh login via a sessionInvalidated flag. Cookie clear is the unconditional
  guarantee.

Adds SQLite-backed reinject regression tests (in-place login/MFA restore with id
and FK columns preserved, username-only rename, email+username on different rows,
clean insert with created_by nulled) and the frontend redirect on
sessionInvalidated.

Deferred (design decisions / pre-existing, need a Postgres test env — see PR
discussion): global "invalidate all pre-restore sessions" cutoff; preserving the
operator's ROLE semantics across an RBAC-table replace; and resyncing Postgres
identity sequences after any restore (batchInsert leaves them behind max(id) —
pre-existing, affects every restored table).
2026-07-16 12:27:59 +02:00
Paul Nothaft 31bc01cb4b fix(security): sanitize chunked-upload filename (GHSA-pc72-jf53-w28j)
The chunked video upload stored req.body.filename unmodified and later built
the merged path as path.join(tempDir, uploadMeta.filename). path.join does not
neutralise '../', so a filename like '../../uploads/logos/evil.svg' escaped the
temp dir on merge and overwrote arbitrary files. Requires admin with
photos.upload.

Fix: path.basename() the client filename in initializeUpload() and reject
names that collapse to nothing. Adds a regression test.
2026-07-16 10:55:10 +02:00
Paul Nothaft 9cd6b08441 fix(security): reject ZIP-slip entries in archive/backup restore (GHSA-jfhw-fj23-fx6x)
node-stream-zip's extract(null, root) writes each entry to path.join(root,
entry.name) without neutralising '../', so a crafted archive entry named
'../../uploads/logos/evil.svg' escaped the target dir and overwrote arbitrary
files (logos, .env, route files → RCE on source deploys). Requires admin with
archives.restore.

Adds assertZipEntriesWithin() to utils/safePath.js — a lexical containment
check run on the entry list BEFORE extract() — and guards both extract sinks:
adminArchives.js (the reported route) and picpeakImportService.js (the sibling
.picpeak import, same sink). Adds unit tests for traversal, absolute-path, and
sibling-prefix entries.
2026-07-16 10:55:10 +02:00
Paul Nothaft 7dace044dc fix(security): share-login must not bypass gallery password (GHSA-9hmx-68vc-qpqw)
POST /auth/gallery/share-login validated only the 128-bit share token and then
minted a full type:'gallery' access token regardless of require_password —
computing requiresPassword at the end only to echo it, never enforce it. Anyone
holding a gallery's share link could read and download every photo in a
password-protected gallery via a direct API call, no password needed.

Fix: compute requiresPassword before minting; for a password-protected gallery
return { requires_password: true } with NO token and NO cookie. The client then
goes through /gallery/verify, which does bcrypt.compare the password. The public
(no-password) auto-login path is unchanged. The frontend already falls through
to the password prompt when share-login returns no token/event.

Adds route regression test covering the bypass, the public path, and bad tokens.
2026-07-16 10:55:10 +02:00
Paul Nothaft 348894efef fix(security): preserve current admin on .picpeak restore (GHSA-qxfx-4493-4v8f)
adminAuth populates req.admin, not req.user, so currentAdminId was always
undefined in the /api/admin/picpeak/import handler. reinjectCurrentAdmin()
then had no account to preserve and the admin_users table was fully replaced
by the uploaded backup — a crafted .picpeak let any admin with backup.restore
take over every admin account (critical). One-line fix: pass req.admin.id.

Closes GHSA-qxfx-4493-4v8f and its duplicate GHSA-pjp6-jcrj-3cr5.
2026-07-16 10:55:10 +02:00
Paul Nothaft efccecb3d8 chore(main): release 3.88.1-beta.0 (#810)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.88.1-beta.0
2026-07-16 08:36:29 +00:00
Paul Nothaft eadf282755 Merge pull request #807 from PicPeak/fix/settings-secret-exposure-mfa-maintenance
fix(security): mask backup credentials on read + unblock MFA login during maintenance
2026-07-16 10:32:19 +02:00
Paul Nothaft eb03b61268 chore(security): close 21 frontend image CVEs — nginx 1.30 base + apk cache-bust
The frontend image kept shipping vulnerable OS packages (nginx 1.28.3-r1,
curl/libcurl 8.19.0, c-ares 1.34.6) despite the apk upgrade line, for two
independent reasons:

1. The runtime stage's apk upgrade layer was cached indefinitely — the
   CACHEBUST build-arg CI passes (github.run_number) was only declared in
   the builder stage, and ARGs don't cross stage boundaries. Both
   Dockerfiles now redeclare CACHEBUST in the runtime stage and consume it
   in the apk RUN, so every build re-runs the upgrade and picks up current
   Alpine security updates.

2. nginx itself can never upgrade via apk on the nginx.org-based image:
   the bundled nginx-module-* packages pin the exact nginx version, so
   Alpine's patched 1.28.3-r4 is unreachable (verified empirically —
   apk add --upgrade nginx is a silent no-op). nginx fixes must come via
   the base tag, so bump to nginx:1.30-alpine (current stable, 1.30.4 on
   Alpine 3.24, same nginx.org conf.d layout — drop-in).

Verified: local image build scans clean with Trivy (0 OS findings, was 21);
container serves /health, SPA fallback, and BRAND_TITLE envsubst as non-root
nginx user.

Closes code-scanning alerts 371-374, 376-392 (nginx HTTP/2 & module CVEs,
curl CVE-2026-5773/-6276 + 6 medium, c-ares CVE-2026-33630).
2026-07-16 10:29:31 +02:00
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 e91c7deaa4 fix(oidc): local-credential lockout, session hydration, split-origin gaps (codex round 3)
- OIDC-owned accounts can never authenticate locally: the password
  login rejects auth_provider='oidc' rows outright (generic 401), and
  the super-admin password reset refuses them with a clear message —
  previously a reset would have minted a local password bypassing the
  IdP's MFA/access policies
- /auth/session now returns a full adminUser payload (role join) and
  AdminAuthContext hydrates user state from it: an SSO redirect
  establishes the session without any login JSON, which left the header
  identity blank and current-admin form defaults empty
- the /sso/login error path redirects absolute to the frontend base
  (same split-origin reasoning as the callback)
- docker-compose.yml passes API_URL through to the backend (production
  compose uses env_file and needs nothing; dev compose is gitignored)
- authSession.symmetry test mock taught the joined admin lookup
  (leftJoin, prefixed columns, aliases) — the route change made the old
  mock throw, which read as "table missing, trust token"

Tests: new case pins that a known-good password on an OIDC-owned row
still gets 401. 14/14 OIDC, 13/13 symmetry.
2026-07-16 10:07:59 +02:00
Paul Nothaft 7f7d38a57f fix(oidc): security + robustness hardening from codex review rounds 1-2
Round 1:
- bind SSO identities to (external_issuer, external_subject): OIDC only
  guarantees sub uniqueness within an issuer, so a sub-only lookup let a
  newly configured IdP's user inherit an old IdP's admin account on
  subject collision; migration 162 gains external_issuer + composite
  unique index (unmerged migration, edited in place)
- fetch UserInfo (with sub cross-check) when the ID token carries no
  email — spec-compliant providers may serve email/profile claims only
  there; ID-token claims win on merge
- allowlist /admin/sso/login + /callback in maintenance mode, or
  SSO-only (JIT) admins are locked out exactly when they need in
- strip reserved keys (oidc_client_secret, setup_token) from BOTH
  generic settings reads (GET / and GET /:type)

Round 2:
- redirect_uri prefers API_URL (the API's public origin — where the
  state cookie lives); final redirects absolute to the frontend base;
  login button builds its URL via buildResourceUrl — split-origin
  deployments (absolute VITE_API_URL) work end to end
- PUT /sso validates the MERGED resulting state (partial update cannot
  blank issuer/client while enabled=true survives; enabling requires a
  derivable redirect URI)
- openid scope forced into oidc_scopes on save
- discovery-cache key includes a secret fingerprint (multi-worker
  secret rotation)
- email→admin linking claims the row atomically (conditional update on
  external_subject IS NULL) — concurrent first-time callbacks with the
  same verified email but different subjects can't both authenticate

Tests: mock IdP gains userinfo endpoint + email-via-userinfo-only mode;
new cases pin the userinfo merge and issuer-collision non-inheritance;
redirect assertions updated for absolute URLs. 13/13.
2026-07-16 09:41:48 +02:00
Paul Nothaft ac1838fbd7 fix(oidc): fail clearly when no public base URL is configured
CI exposed that getFrontendBaseUrl() returns '' without FRONTEND_URL or
the general_site_url setting (local runs were masked by backend/.env):
the flow then sent a RELATIVE redirect_uri to the IdP, which surfaced
as an opaque IdP-side error. getRedirectUri now throws OIDC_BAD_CONFIG
with an actionable message (login route maps it to sso_error=config);
the settings GET degrades to an empty redirect_uri instead of 500ing.
The test pins FRONTEND_URL explicitly so it runs identically with and
without a local .env.
2026-07-16 08:59:48 +02:00
Paul Nothaft ed5fc5ad5c feat(auth): OIDC SSO for admin users — phase 1 (#798)
Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.

Backend:
- migration 162: admin_users.auth_provider ('local' default) +
  external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
  rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
  cached discovery, sub-based identity binding — email linking of
  existing admins only with email_verified=true; JIT behind
  oidc_autoprovision with configurable default role and an unusable
  random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
  cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
  the callback reuses the local login's session establishment
  (completeAdminLogin split into establishAdminSession + JSON wrapper)
  so SSO sessions are identical downstream; every failure lands on
  /admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
  write-only, redacted to a set-flag; registered ABOVE the generic
  /:type matcher which would shadow them); oidc_client_secret added to
  the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
  login page

Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
  autoprovision + default role, button label, enable toggle, redirect
  URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
  param surfaced as translated toasts; EN+DE i18n

Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.

MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
2026-07-16 08:54:26 +02:00
Paul Nothaft f0cdcddb92 chore(main): release 3.88.0-beta.0 (#805)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.88.0-beta.0
2026-07-15 21:07:21 +00:00
Paul Nothaft 0751a08aa6 Merge pull request #804 from PicPeak/fix/gallery-feedback-filter-chips
fix(gallery): show feedback filter chips on desktop for galleries without categories
2026-07-15 23:02:48 +02:00
Paul Nothaft d64eef8abf Merge pull request #803 from PicPeak/fix/event-type-hardcoded-deps
fix(event-types): un-hardcode event type dependencies in v1 API and CRM
2026-07-15 23:02:35 +02:00