Compare commits

..

98 Commits

Author SHA1 Message Date
Paul Nothaft 613133c29c chore(main): release 3.92.2-beta.0 (#852)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-19 20:05:52 +00:00
Paul Nothaft 8337a716b1 fix(file-watcher): bound concurrent photo processing (#846)
* fix(file-watcher): bound concurrent photo processing

chokidar fires 'add' once per file — with no ignoreInitial option the
boot scan fires it for every existing file, and a bulk drop into the
watch folder fires it for every new one at once. Each handler runs DB
lookups plus (for new files) a full sharp pipeline; sharp.concurrency(2)
only caps libvips threads WITHIN one operation, not the number of
parallel pipelines, so unbounded handlers can OOM small hosts.

Gate both 'add' and 'unlink' through a shared p-limit
(FILE_WATCHER_CONCURRENCY, default 2, floor 1) — mass deletes otherwise
burst DB work and ZIP-cache invalidation the same way. p-limit is pinned
to ^3.1.0, the last CommonJS release.

Adapted from the filpgame fork (426ca491) — thanks @filpgame; extended
to cover 'unlink', documented in .env.example, plus a lock-in test for
the existing Sharp cache/concurrency caps this bound relies on.

* chore(compose): pass FILE_WATCHER_CONCURRENCY into the backend container (codex review of #846)

The backend service uses an explicit environment list (no env_file), so
the documented override never reached the container in the default
compose deployments. Added to both compose files + root .env.example.
2026-07-19 22:00:46 +02:00
Paul Nothaft 0310c46fdd fix(uploads): keep videos when thumbnail generation fails (#845)
* fix(uploads): keep videos when thumbnail generation fails

processUploadedVideo() (ffmpeg probe + thumbnail) was unguarded in both
pipeline paths, while the image branch next to each already survives its
thumbnail failures:

- processUploadedPhotos (sync): the throw failed the whole upload — the
  video was lost.
- processPhoto (async worker, the path real uploads take): the throw
  marked the row 'failed', and the guest gallery only lists 'complete' —
  the video became permanently invisible despite being fully uploaded.

Both call sites now fall back to extractVideoMetadata() alone and keep
the video without a preview; if even the probe fails, the video is kept
with no metadata. Idea from the munin92 fork (2026-07-02), reimplemented
for both paths + regression test.

* fix(uploads): placeholder thumbnail for rescued videos (codex review of #845)

A completed video with a NULL thumbnail made the gallery grid fetch the
ORIGINAL video file as an <img> blob (thumbnail_url || url) — a
potentially multi-GB download for a broken tile. Both fallback paths now
generate the existing sharp-rendered play-button placeholder
(generateVideoPlaceholder — ffmpeg-free), so rescued videos get a real
tile. Test asserts the placeholder key lands in thumbnail_path.
2026-07-19 22:00:08 +02:00
Paul Nothaft 8060fedf6a fix(security): read the password-complexity key the settings UI writes (#843)
* fix(security): read the password-complexity key the settings UI writes

The settings UI saves the admin's complexity choice as
security_password_complexity (useSettingsState.ts prefixes security_ to
password_complexity), but getPasswordComplexitySettings() queried
security_password_complexity_level — written by nothing — so the setting
was silently ignored and password validation always used the 'moderate'
default. Spotted in the filpgame fork (their main, 2026-07-14).

* fix(security): accept the Postgres json-column shape of the complexity value (codex review of #843)

On SQLite the TEXT column returns the JSON-stringified value
('"very_strong"'), but on Postgres (production default) setting_value
is a json column and arrives already decoded ('very_strong') — the bare
JSON.parse threw and the outer catch silently fell back to 'moderate'
again. Parse with fallback, mirroring getAppSetting's documented
pattern; test now covers both driver shapes + the empty-value default.
2026-07-19 20:04:31 +02:00
Paul Nothaft f891b16503 chore(main): release 3.92.1-beta.0 (#842)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-19 09:22:09 +00:00
Paul Nothaft 216c282542 Merge pull request #841 from PicPeak/chore/storage-ignore-dead-code
chore(backend): ignore runtime storage in git/docker, remove dead getSafeFilename
2026-07-19 11:18:59 +02:00
Paul Nothaft f7fd89387b Merge pull request #834 from Dodothereal/fix/821-bug
fix(uploads): support configured raw formats
2026-07-19 11:18:43 +02:00
Paul Nothaft 2f4b8a64c0 chore(backend): ignore runtime storage in git/docker, remove dead getSafeFilename
Follow-ups from the codex review of #834:

- .gitignore: backend/storage/ is runtime-generated (media, previews,
  thumbnails, business docs) and was only partially ignored — E2E runs
  left it dangling as untracked, which is how ~12 MB of artifacts nearly
  landed in a commit. Ignore the whole directory (nothing under it is
  tracked); replaces the narrower business-docs rule.
- backend/.dockerignore: the granular storage/* rules missed
  storage/previews, so locally generated previews were copied into
  production images. Exclude storage entirely — the Dockerfile creates
  the needed directories itself (RUN mkdir -p, Dockerfile:96).
- fileSecurityUtils.js: remove getSafeFilename — zero callers across the
  repo, and its private extension whitelist silently drifted from the
  real validation paths (see #834), which is exactly the trap dead
  security code sets.
2026-07-19 00:40:53 +02:00
Paul Nothaft c8eb334637 test(uploads): harden frontend map parser, drop dead getSafeFilename edit (codex review of #834)
- getFrontendExtensionMap now tolerates quoted keys and trailing comments
  and throws on any other unparsable map line, so future syntax drift fails
  loudly instead of silently dropping entries from the comparison.
- Revert the .dng/.heic/.heif addition to getSafeFilename: the helper has
  no callers, so the edit was dead code. Live validation paths already
  cover these formats.
2026-07-18 23:46:40 +02:00
Paul Nothaft 14d5fa6ca5 chore(main): release 3.92.0-beta.0 (#840)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-18 19:02:48 +00:00
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) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
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) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
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) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
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) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
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) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
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) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
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) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
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
Paul Nothaft 109aba8598 Merge pull request #801 from PicPeak/feat/setup-wizard-event-types
feat(setup): event-types step in first-run wizard + un-hardcode event type dependencies
2026-07-15 23:01:36 +02:00
Paul Nothaft b9283386a5 fix(gallery): show feedback filter chips on desktop for galleries without categories (#802)
The desktop feedback-filter chips (All/Likes/Saved/Rated/Commented)
were nested inside the categories row conditional, and the standalone
fallback block is lg:hidden — so a gallery without photo categories
(the default) rendered no feedback filter at all on desktop, despite
the docs and a fully working filter implementation behind it.

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

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

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

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

- New wizard step between features and config: edit name/URL prefix,
  remove, or add types; defaults shown as recommendations
- setup_wizard_completed app setting (migration 161): seeded true when
  an admin already exists, false on fresh installs; POST /api/setup/
  complete (adminAuth) flips it when the wizard finishes
- deleteEventType: system types deletable only while the flag is unset;
  in-use check extended to quotes; per-type reminder template
  (event_reminder_<slug>) is deleted with the type
- reminder-template self-heal no longer resurrects templates for slugs
  removed from the catalog
- v1 API event creation validates event_type against the live catalog
  instead of a hardcoded whitelist (custom types were rejected; the
  never-seeded 'family' slug is no longer silently accepted)
- contract→event conversion resolves the event type via
  crm_default_event_type / resolveDefaultEventType instead of
  hardcoding 'wedding' (resolveDefaultEventType moved from quoteService
  to eventTypeService for reuse)
2026-07-15 21:30:23 +02:00
Paul Nothaft aab9e1a937 chore(main): release 3.87.0-beta.0 (#797)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-11 20:24:23 +00:00
Paul Nothaft ffd4a7eee6 Merge pull request #796 from Luca-Timo/feat/invoice-vat-note
feat(invoices): configurable VAT note under MwSt. line + fix multi-page page-number overlap (#794)
2026-07-11 22:21:41 +02:00
Luca 1476884dd0 feat(invoices): configurable VAT/free-text note + fix multi-page page-number overlap (#794)
Two invoice-PDF changes from #794.

1. VAT / free-text note (Benedikt's request, placement A). A new
   `crm_invoices_vat_note_text` setting (Settings → CRM → Invoices) prints a
   free-text line directly under the MwSt. row on every invoice. Data-driven:
   the admin types the exact wording (Austrian Kleinunternehmer § 6 Abs. 1 Z 27
   UStG, German § 19, reverse-charge, …) — no jurisdiction hardcoded. The
   totals-block reserve grows by the measured note height so a long note can't
   push the grand total into the footer. Read in invoice/render.js, threaded
   through normaliseContext, drawn in drawTotals. Empty → row omitted; quotes
   unaffected.

2. Multi-page footer overlap. On a full continuation page the line-item table
   filled to the bottom margin, but the "Seite X von Y" stamp was drawn at
   marginBottom-12 — INSIDE that fill zone — so items overlapped the page
   number. Move the stamp into the bottom margin (below the content edge),
   zeroing that page's bottom margin during the write so it can't trigger
   PDFKit's auto-page-break. Verified: on a full page the lowest item text is
   at pdfkitY ~790 while the page number sits at ~816 — ~26pt clearance.

Tests: render the note on a single page (byte-delta proves it renders) and
paginate a long invoice with the note (2–3 pages, no stray blank page).
2026-07-11 02:01:20 +02:00
Paul Nothaft e3d597b89a Merge pull request #787 from PicPeak/ci/push-images-to-dockerhub
ci(docker): also publish images to Docker Hub (picpeak/backend, picpeak/frontend)
2026-07-10 20:35:58 +02:00
Paul Nothaft ea9caa9c5d chore(main): release 3.86.0-beta.0 (#795)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-10 18:30:28 +00:00
Paul Nothaft d51112e761 Merge pull request #790 from Luca-Timo/feat/category-reorder
feat(categories): per-event category ordering — global default + override (#782)
2026-07-10 20:26:11 +02:00
Luca a4b4485d32 fix(categories): address PR #790 review — event ownership, migration renumber, nits
- 🔴 Event ownership: GET /event/:eventId and DELETE /reorder/:eventId now use
  requireEventOwnership; POST /reorder (event_id in body) gets the equivalent
  inline check (super_admin bypasses; others limited to owned/ownerless events).
  New test covers a settings.edit-holding non-super_admin blocked (403) on all
  three per-event routes.
- 🔴 Migration renumber: 158→159, 159→160 (upstream #788 already took 158);
  headers + the test's require path updated.
- 🟢 Nits: stale inline "Drag the arrows" fallback → "Use the arrows" (matches
  en.json; control is click-only); invalid bg-accent-dark/150 → bg-accent-dark.
2026-07-10 20:10:40 +02:00
Luca 8d0a946478 test(categories): integration tests for layered category ordering (#782)
Real-DB coverage: migration 158 backfill; global default reorder + a
non-customised event following it; per-event override + isolation from other
events; override accepts globals / rejects a foreign event's category; reset
clears the override; create appends.
2026-07-10 16:24:17 +02:00
Luca 4698402b54 feat(categories): per-event category ordering — global default + override (#782)
Order a gallery's categories in the flow of the day instead of A–Z. Two layers,
resolved per event: per-event override > global default > name.

- migration 158: photo_categories.display_order (global default), backfilled
  from the current alphabetical order so existing galleries don't reshuffle.
- migration 159: event_category_order (event_id, category_id, position) — the
  per-event override; no backfill, every event starts on the default.
- utils/categoryOrder: shared resolution used by the admin event view and the
  public gallery; fails safe to the global default if the table is absent.
- adminCategories: POST /reorder sets a per-event override (globals +
  event-specific, interleaved); DELETE /reorder/:eventId resets; POST
  /reorder-global sets the global default. Ordering endpoints + create append.
- gallery renders the resolved order.
- Settings → Photo Categories reorders the global default; an event's Categories
  tab reorders that gallery (one combined list + Reset to default). Up/down
  buttons — no drag-and-drop dependency.
- en/de strings.
2026-07-10 16:24:17 +02:00
Paul Nothaft ed0fa3241b chore(main): release 3.85.0-beta.0 (#793)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-10 13:38:57 +00:00
Paul Nothaft 54676424f2 Merge pull request #788 from PicPeak/feat/slideshow-order-category
feat(slideshow): per-event play order + category filter (#202)
2026-07-10 15:35:45 +02:00
Paul Nothaft b41cb1586d chore(main): release 3.84.1-beta.0 (#792)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-10 12:21:09 +00:00
Paul Nothaft 1f3bc3c343 Merge pull request #791 from PicPeak/fix/docker-v-tag-via-ref
fix(ci): publish v-prefixed image tags via type=ref,event=tag (#668)
2026-07-10 14:17:11 +02:00
Paul Nothaft 39db7bf6cb fix(ci): publish v-prefixed image tags via type=ref,event=tag (#668)
#783 added `type=semver,pattern=v{{version}}` to the merge-job metadata,
but metadata-action silently dropped it on prereleases — the 3.84.0-beta.0
build published only :3.84.0-beta.0 + :sha, not :v3.84.0-beta.0 (verified
in the merge-backend push log + GHCR: :v3.84.0-beta.0 → 404).

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

Takes effect on the next release build. The bare :3.84.0-beta.0 tags stay
(the {{version}} patterns are unchanged), so both forms resolve.
2026-07-10 14:13:17 +02:00
Paul Nothaft aeade94a35 Merge pull request #786 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.84.0-beta.0
2026-07-10 13:04:13 +02:00
Paul Nothaft b768a53c5b feat(slideshow): per-event play order + category filter (#202)
The Live Slideshow already covers the core of #202 (fullscreen kiosk,
live-appending new uploads, timing/transitions/watermark, per-event
opt-in via the share link). This adds the two customization dimensions
the reporter also asked for:

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

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

- Migration 158: show_order (default 'chronological') + show_category_id.
- Admin: Play-order dropdown + category picker in the Live Slideshow card
  (picker hidden for events without categories); EN + DE i18n.
- Verified: migration (SQLite + PG); live API (category filter → 3/2/5
  photos + matching count; order propagates) and the running kiosk
  requests exactly the filtered set; tsc clean, 136 backend tests pass.
2026-07-10 10:46:54 +02:00
Luca 1f19fbb1b2 ci(docker): mirror published images to Docker Hub
Add picpeak/backend + picpeak/frontend on Docker Hub alongside GHCR. The
merge jobs already assemble the multi-arch manifest from the per-arch GHCR
digests via 'imagetools create'; adding Docker Hub to metadata-action's
images list + a Docker Hub login makes the same command push the manifest to
both registries (blobs copied from GHCR). No change to the build-by-digest
jobs.

Full tag parity (main, stable, latest, semver, sha). Gated on
DOCKERHUB_ENABLED (github.repository == PicPeak/picpeak) so forks stay
GHCR-only and keep building. Requires repo secrets DOCKERHUB_USERNAME and
DOCKERHUB_TOKEN.
2026-07-10 10:26:06 +02:00
Paul Nothaft 03ded870bf chore(main): release 3.84.0-beta.0 2026-07-10 10:20:17 +02:00
Paul Nothaft df5aeaba41 Merge pull request #785 from PicPeak/docs/releasing-stable-version-alignment
docs(releasing): align stable version to main on promote (Option A)
2026-07-10 10:20:04 +02:00
Paul Nothaft 279e0472c7 Merge pull request #784 from PicPeak/feat/admin-github-repo-button
feat(admin): GitHub repo button in the sidebar footer (#778)
2026-07-10 10:19:49 +02:00
Paul Nothaft 2ee4146d9a Merge pull request #783 from PicPeak/fix/docker-versioned-tags-v-prefix
fix(ci): publish v-prefixed image tags so :vX.Y.Z resolves (#668)
2026-07-10 10:19:22 +02:00
Paul Nothaft 5dea0c9695 docs(releasing): align stable version to main on promote (Option A)
The two release-please tracks count independently — main bumps on every
merge, stable only on promotion — so they drifted far apart (main
v3.83.x-beta while stable sat at v3.45.0 for the same code). Document
the alignment convention: a promotion pins the stable version to main's
base version via a Release-As commit (new step 5 in the cut procedure),
so stable tracks main instead of lagging.

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

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

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

Applies to future releases; the already-published v3.45.0 only has the
bare :3.45.0 tag (retagging past releases is out of scope).
2026-07-10 09:46:10 +02:00
Paul Nothaft dbe4b588eb chore(main): release 3.83.1-beta.0 (#776)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-09 11:17:23 +00:00
Paul Nothaft 274ef0cd73 Merge pull request #774 from PicPeak/fix/release-please-stable-target
fix(release): target stable in release-please + undo bogus 2.7.0 bump
2026-07-09 13:13:23 +02:00
Paul Nothaft 65ac6eddac fix(release): target stable in release-please.yml + undo the bogus 2.7.0 bump
The stable release-please workflow (release-please.yml, triggered on
push to stable) had no `target-branch`, so it defaulted to the repo
default branch (main) and computed the next version from main's stale
`.release-please-manifest.json` (2.6.1) — cutting a spurious **v2.7.0**
stable release (a version regression from 3.44.0) when #771 landed on
stable, and bumping main's package.json + manifest to 2.7.0.

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

The v2.7.0 tag/release is deleted separately; the real v3.45.0 stable is
cut by re-running release-please on the stable branch after this lands.
2026-07-09 11:39:16 +02:00
Paul Nothaft be710eb1de Merge pull request #773 from PicPeak/release-please--branches--main
chore(main): release 2.7.0
2026-07-08 21:14:07 +02:00
Paul Nothaft 58a86af868 chore(main): release 2.7.0 2026-07-08 20:46:56 +02:00
Paul Nothaft 1250306d11 Merge pull request #772 from PicPeak/ci/run-tests-on-stable
ci: run the Tests workflow on stable-targeted PRs
2026-07-08 20:42:01 +02:00
Paul Nothaft 80503c52b9 ci: run the Tests workflow on stable-targeted PRs
tests.yml (the backend/frontend Jest+Vitest jobs) only triggered on
main/beta, but those two jobs are required status checks on the stable
branch. A beta→stable promote PR therefore hung forever on
'Expected — Waiting for status to be reported' for backend/frontend,
while docker-build / install-smoke / schema-drift (already listing
stable) ran fine. Add stable to the push + pull_request filters so the
Tests suite runs on promote PRs too.
2026-07-08 20:29:19 +02:00
127 changed files with 7813 additions and 1705 deletions
+5
View File
@@ -106,6 +106,11 @@ VITE_API_URL=/api
# DB_PORT=5432
# REDIS_PORT=6379
# File watcher (watch-folder auto-import, local storage only)
# Max photos processed in parallel — raise on hosts with memory headroom,
# lower to 1 on very small hosts. Default: 2
# FILE_WATCHER_CONCURRENCY=2
# Release Channel
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
# 'stable' uses the :stable tag (same as :latest on main)
+97 -4
View File
@@ -95,6 +95,15 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Prepare platform pair
run: |
@@ -233,6 +242,15 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Download digest artifacts
uses: actions/download-artifact@v4
@@ -266,11 +284,24 @@ jobs:
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
if: env.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
# GHCR always; Docker Hub (picpeak/backend) added on the canonical repo so
# the same tag scheme is mirrored to both registries. metadata-action drops
# the blank second line on forks → GHCR-only there.
images: |
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/backend' || '' }}
labels: |
org.opencontainers.image.title=PicPeak Backend
org.opencontainers.image.description=PicPeak photo sharing platform backend service
@@ -282,6 +313,10 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
# so users can pin the same string as the GitHub release. metadata-action's
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
type=ref,event=tag
type=sha,format=short
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
@@ -298,10 +333,15 @@ jobs:
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest
- name: Inspect manifest (GHCR)
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
- name: Inspect manifest (Docker Hub)
if: env.DOCKERHUB_ENABLED == 'true'
run: |
docker buildx imagetools inspect docker.io/picpeak/backend:${{ steps.meta-backend.outputs.version }}
# -----------------------------------------------------------------------------
# Frontend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
@@ -331,6 +371,15 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Prepare platform pair
run: |
@@ -450,6 +499,15 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Download digest artifacts
uses: actions/download-artifact@v4
@@ -483,11 +541,24 @@ jobs:
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
if: env.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
# GHCR always; Docker Hub (picpeak/frontend) added on the canonical repo so
# the same tag scheme is mirrored to both registries. metadata-action drops
# the blank second line on forks → GHCR-only there.
images: |
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/frontend' || '' }}
labels: |
org.opencontainers.image.title=PicPeak Frontend
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
@@ -499,6 +570,10 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
# so users can pin the same string as the GitHub release. metadata-action's
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
type=ref,event=tag
type=sha,format=short
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
@@ -515,10 +590,15 @@ jobs:
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest
- name: Inspect manifest (GHCR)
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
- name: Inspect manifest (Docker Hub)
if: env.DOCKERHUB_ENABLED == 'true'
run: |
docker buildx imagetools inspect docker.io/picpeak/frontend:${{ steps.meta-frontend.outputs.version }}
summary:
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
if: always()
@@ -532,6 +612,15 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Build Summary
run: |
@@ -570,6 +659,10 @@ jobs:
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
echo "- Backend (Docker Hub): \`docker.io/picpeak/backend\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend (Docker Hub): \`docker.io/picpeak/frontend\`" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
+3 -2
View File
@@ -130,5 +130,6 @@ docker-compose.dev.yml
# New layout development files
new-layouts/
# Generated CRM/accounting documents (runtime) — never commit
backend/storage/business-docs/
# Backend runtime storage (generated media, previews, thumbnails,
# CRM/accounting documents) — never commit
backend/storage/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.83.0-beta.0"
".": "3.92.2-beta.0"
}
+1 -1
View File
@@ -1 +1 @@
{".":"3.45.0"}
{".":"3.44.0"}
+1216 -802
View File
File diff suppressed because it is too large Load Diff
+18 -4
View File
@@ -52,13 +52,19 @@ The actual mechanics, in order:
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
5. **Pin the stable version to match `main` (number alignment — see Versioning).** Determine `X.Y.Z` = the `main` tip's **base** version (its `vX.Y.Z-beta.N`, dropping the `-beta.N` suffix), and add an empty commit on the release branch:
```bash
git commit --allow-empty -m "chore: release X.Y.Z" -m "Release-As: X.Y.Z"
```
The `Release-As:` footer forces release-please to cut exactly `X.Y.Z`. Without it, release-please computes the next MINOR from the *previous stable* tag (e.g. `3.45.0` → `3.46.0`) while `main` is already at `3.84.x`, so the stable number drifts ever further behind for the same code.
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
6. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
7. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
8. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
8. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
9. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
## Hotfix path (backport to current stable)
@@ -83,6 +89,14 @@ PicPeak follows [Semantic Versioning](https://semver.org/) with one project-spec
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
### Stable ↔ pre-release number alignment
The two channels run **independent** release-please counters: `main` bumps on every merge (racing ahead), while `stable` only bumps on a promotion. Left to itself, `stable` computes each promotion as the next MINOR from the *previous stable tag*, so the two drift far apart — e.g. `main` at `v3.83.x-beta.0` while `stable` sat at `v3.45.0` for the **same code**, which reads as "stable is 38 versions behind" when it isn't.
To keep the numbers legible, **a promotion sets the stable version to the current `main` base version** (the `X.Y.Z` of the `main` tip's `vX.Y.Z-beta.N`, minus the suffix). Promoting a `main` at `v3.84.2-beta.0` therefore cuts stable `v3.84.2`, and the stable number tracks `main` instead of lagging. This is forced with the `Release-As:` commit in step 5 of the cut procedure — the one-time catch-up jump (e.g. `3.45.0 → 3.84.x`) is expected and happens only on the first aligned promotion.
> **Release-engineering note (2026-07):** `release-please.yml` (the stable workflow) *must* keep `target-branch: stable`. Without it, release-please defaults to the repo's default branch (`main`), reads `main`'s stale `.release-please-manifest.json`, and cuts a wrong/regressed version — this is what produced a bogus `v2.7.0` once. If a promotion ever yields an unexpected version, check that first.
## Things that don't go through this process
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
+1 -3
View File
@@ -1,9 +1,7 @@
node_modules
npm-debug.log
.env
storage/events/active/*
storage/events/archived/*
storage/thumbnails/*
storage
data/*.db
logs/*
coverage
+6
View File
@@ -106,6 +106,12 @@ ARCHIVE_PATH=/app/storage/events/archived
# EVENTS_PATH=./storage/events
# ARCHIVE_PATH=./storage/events/archived
# File watcher (auto-import from the events/active folder, local storage only)
# Max photos processed in parallel by the watcher. The boot scan and bulk
# folder drops fire one handler per file — this bound keeps thumbnail
# generation from exhausting memory on small hosts. Default: 2
# FILE_WATCHER_CONCURRENCY=2
# Analytics Backend Configuration (OPTIONAL)
# Used for server-side tracking only
# Primary configuration should be done through Admin UI > Settings > Analytics
+12 -2
View File
@@ -27,8 +27,15 @@ FROM node:22-alpine
WORKDIR /app
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
# stage's declaration never reached this stage. Consuming it in the RUN below
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
# image always picks up current Alpine security updates instead of reusing a
# stale cached upgrade layer.
ARG CACHEBUST=1
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
# Upgrade the npm CLI in the final image so its bundled deps are patched
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
@@ -60,8 +67,11 @@ RUN npm install -g npm@11
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
# documents (see docs/accounting-inbound-invoices.md).
# exiftool extracts the embedded full-res JPEG preview from RAW/DNG uploads
# (Apple ProRAW etc.) — sharp's libvips has no raw loader, so the pipeline
# thumbnails/displays that preview while keeping the original for download.
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
fontconfig ttf-dejavu ttf-liberation poppler-utils && \
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
fc-cache -f
# Create non-root user
+4 -1
View File
@@ -8,7 +8,10 @@ RUN apk upgrade --no-cache
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
RUN apk add --no-cache dumb-init ffmpeg
# exiftool: extract embedded JPEG previews from RAW/DNG uploads (#821) — kept in
# sync with the production Dockerfile so dev/native runtimes don't accept a DNG
# and then fail it with ENOENT.
RUN apk add --no-cache dumb-init ffmpeg exiftool
# Copy package files
COPY package*.json ./
@@ -0,0 +1,108 @@
/**
* Backup credential exposure regression tests.
*
* The generic settings reads (GET /admin/settings, GET /admin/settings/:type)
* masked the recaptcha/umami/rybbit keys but returned backup_s3_secret_key
* and backup_rsync_ssh_key (an SSH PRIVATE KEY) in plaintext to any
* settings.view holder; GET /admin/backup/config returned them too. Both now
* mask, and PUT /admin/backup/config skips the mask sentinel so the edit
* form round-trips without clobbering stored credentials.
*/
const request = require('supertest');
const express = require('express');
const { bootCrmDb } = require('./helpers/crmDb');
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => {
req.admin = { id: 1, username: 'test-admin' };
next();
},
}));
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
describe('backup credential masking', () => {
let db;
let cleanup;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Upsert: several backup_* keys are pre-seeded by the backup migrations.
const seed = [
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('s3'), setting_type: 'backup' },
{ setting_key: 'backup_s3_endpoint', setting_value: JSON.stringify('https://s3.example.com'), setting_type: 'backup' },
{ setting_key: 'backup_s3_bucket', setting_value: JSON.stringify('backups'), setting_type: 'backup' },
{ setting_key: 'backup_s3_access_key', setting_value: JSON.stringify('AKIAEXAMPLE'), setting_type: 'backup' },
{ setting_key: 'backup_s3_secret_key', setting_value: JSON.stringify('super-secret-s3-key'), setting_type: 'backup' },
{ setting_key: 'backup_rsync_ssh_key', setting_value: JSON.stringify('-----BEGIN OPENSSH PRIVATE KEY-----abc'), setting_type: 'backup' },
];
for (const row of seed) {
await db('app_settings').insert(row).onConflict('setting_key').merge();
}
app = express();
app.use(express.json());
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('masks the credentials in GET /admin/backup/config', async () => {
const res = await request(app).get('/api/admin/backup/config').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
// Non-secret fields stay readable for the form.
expect(res.body.backup_s3_bucket).toBe('backups');
});
it('masks the credentials in the generic GET /admin/settings/:type read', async () => {
const res = await request(app).get('/api/admin/settings/backup').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
});
it('masks the credentials in the generic GET /admin/settings read', async () => {
const res = await request(app).get('/api/admin/settings').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
});
it('PUT /admin/backup/config keeps the stored secret when the sentinel round-trips', async () => {
await request(app)
.put('/api/admin/backup/config')
.send({
backup_destination_type: 's3',
backup_s3_endpoint: 'https://s3.example.com',
backup_s3_bucket: 'renamed-bucket',
backup_s3_access_key: 'AKIAEXAMPLE',
backup_s3_secret_key: '••••••••',
backup_rsync_ssh_key: '••••••••',
})
.expect(200);
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
expect(JSON.parse(secret.setting_value)).toBe('super-secret-s3-key');
const sshKey = await db('app_settings').where({ setting_key: 'backup_rsync_ssh_key' }).first();
expect(JSON.parse(sshKey.setting_value)).toBe('-----BEGIN OPENSSH PRIVATE KEY-----abc');
const bucket = await db('app_settings').where({ setting_key: 'backup_s3_bucket' }).first();
expect(JSON.parse(bucket.setting_value)).toBe('renamed-bucket');
});
it('PUT /admin/backup/config stores a genuinely new secret', async () => {
await request(app)
.put('/api/admin/backup/config')
.send({ backup_s3_secret_key: 'rotated-s3-key' })
.expect(200);
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
expect(JSON.parse(secret.setting_value)).toBe('rotated-s3-key');
});
});
@@ -0,0 +1,211 @@
/**
* Layered per-event category ordering (#782).
*
* Two ordering layers, resolved per event:
* - GLOBAL default — photo_categories.display_order (migration 159),
* set via POST /reorder-global; applies everywhere.
* - PER-EVENT override — event_category_order (migration 160), set via
* POST /reorder; overrides the default for one gallery.
* - DELETE /reorder/:eventId clears an event's override.
*
* Verified against a real SQLite DB with the full core-migration set applied.
*/
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('category ordering (#782)', () => {
let db;
let cleanup;
let token;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = buildRouteApp('/api/admin/categories', require('../../src/routes/adminCategories'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
const auth = (r) => r.set('Authorization', `Bearer ${token}`);
async function insertEvent(slug) {
await db('events').insert({
event_type: 'wedding', password_hash: 'x',
expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug, share_link: slug,
event_name: slug, event_date: '2026-01-01',
});
return (await db('events').where({ slug }).first()).id;
}
async function insertCat(name, { is_global = false, event_id = null, display_order = 0 } = {}) {
const res = await db('photo_categories').insert({
name,
slug: name.toLowerCase().replace(/\s+/g, '-'),
is_global: is_global ? 1 : 0,
event_id,
display_order,
}).returning('id');
return res[0]?.id ?? res[0];
}
const getEvent = (eventId) => auth(request(app).get(`/api/admin/categories/event/${eventId}`)).expect(200);
describe('migration 159 backfill', () => {
it('seeds display_order from alphabetical order, scoped per event', async () => {
const eventId = await insertEvent('backfill-ev');
await insertCat('Reception', { event_id: eventId });
await insertCat('Ceremony', { event_id: eventId });
await insertCat('Pre-Ceremony', { event_id: eventId });
// Re-run the migration: addColumn is guarded (no-op); the backfill loop
// re-runs and assigns per-scope alphabetical order — what an upgrade does.
await require('../../migrations/core/159_add_category_display_order').up(db);
const evCats = await db('photo_categories').where({ event_id: eventId }).orderBy('display_order', 'asc');
expect(evCats.map((c) => c.name)).toEqual(['Ceremony', 'Pre-Ceremony', 'Reception']);
expect(evCats.map((c) => c.display_order)).toEqual([1, 2, 3]);
});
});
describe('global default order (POST /reorder-global)', () => {
it('reverses the global order and every non-customised event follows it', async () => {
const before = (await auth(request(app).get('/api/admin/categories/global')).expect(200)).body;
expect(before.length).toBeGreaterThan(1);
const reversedIds = before.map((c) => c.id).reverse();
const res = await auth(request(app).post('/api/admin/categories/reorder-global'))
.send({ orderedIds: reversedIds })
.expect(200);
expect(res.body.map((c) => c.id)).toEqual(reversedIds);
// A fresh event (no override) shows globals in the new global order.
const eventId = await insertEvent('follows-global');
const globalsInEvent = (await getEvent(eventId)).body.filter((c) => c.is_global).map((c) => c.id);
expect(globalsInEvent).toEqual(reversedIds);
});
});
describe('per-event override (POST /reorder)', () => {
it('pins a custom order for one event without affecting another', async () => {
const eventA = await insertEvent('override-a');
const eventB = await insertEvent('override-b');
const a1 = await insertCat('A-Ceremony', { event_id: eventA });
const a2 = await insertCat('A-Reception', { event_id: eventA });
// Current resolved list for A (globals + A's two categories).
const listA = (await getEvent(eventA)).body;
// Put A-Reception first, then A-Ceremony, then the globals in their order.
const globalsA = listA.filter((c) => c.is_global).map((c) => c.id);
const desired = [a2, a1, ...globalsA];
const res = await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventA, orderedIds: desired })
.expect(200);
expect(res.body.map((c) => c.id)).toEqual(desired);
// override_position is set on every row for a customised event.
expect(res.body.every((c) => c.override_position != null)).toBe(true);
// Event B is untouched — no override, follows the global default.
const listB = (await getEvent(eventB)).body;
expect(listB.every((c) => c.override_position == null)).toBe(true);
});
it('accepts global ids but rejects another events category', async () => {
const eventId = await insertEvent('scope-ev');
const own = await insertCat('Own', { event_id: eventId });
const global = (await db('photo_categories').where('is_global', 1).first()).id;
const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') });
// A global id is allowed (globals can be arranged per event).
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [own, global] })
.expect(200);
// A foreign event's category is out of scope.
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [own, foreign] })
.expect(400);
});
});
describe('reset (DELETE /reorder/:eventId)', () => {
it('clears the override and reverts to the global default', async () => {
const eventId = await insertEvent('reset-ev');
const c1 = await insertCat('R-One', { event_id: eventId });
const list = (await getEvent(eventId)).body;
const globals = list.filter((c) => c.is_global).map((c) => c.id);
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [c1, ...globals] })
.expect(200);
expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true);
const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200);
expect(res.body.every((c) => c.override_position == null)).toBe(true);
expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined();
});
});
describe('event ownership (PR #790 review)', () => {
let limitedToken;
let foreignEventId;
beforeAll(async () => {
const bcrypt = require('bcrypt');
// A non-super_admin role that DOES hold settings.view + settings.edit —
// the exact case the review flagged (settings.edit is grantable).
const roleRes = await db('roles').insert({ name: 'gallery-mgr', display_name: 'Gallery Mgr' }).returning('id');
const roleId = roleRes[0]?.id ?? roleRes[0];
const permIds = await db('permissions').whereIn('name', ['settings.view', 'settings.edit']).pluck('id');
await db('role_permissions').insert(permIds.map((permission_id) => ({ role_id: roleId, permission_id })));
const a2 = await db('admin_users').insert({
username: 'limited', email: 'limited@example.com',
password_hash: await bcrypt.hash('x', 4), role_id: roleId,
must_change_password: false, created_at: new Date(),
}).returning('id');
limitedToken = mintAdminToken(a2[0]?.id ?? a2[0]);
// An event owned by a DIFFERENT admin (the seeded super_admin).
const owner = (await db('admin_users').where({ username: 'tester' }).first()).id;
await db('events').insert({
event_type: 'wedding', password_hash: 'x',
expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug: 'owned-ev', share_link: 'owned-ev',
event_name: 'Owned', event_date: '2026-01-01', created_by: owner,
});
foreignEventId = (await db('events').where({ slug: 'owned-ev' }).first()).id;
});
const limitedAuth = (r) => r.set('Authorization', `Bearer ${limitedToken}`);
it('blocks a non-owner from reading, reordering or resetting another event', async () => {
await limitedAuth(request(app).get(`/api/admin/categories/event/${foreignEventId}`)).expect(403);
await limitedAuth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: foreignEventId, orderedIds: [1] }).expect(403);
await limitedAuth(request(app).delete(`/api/admin/categories/reorder/${foreignEventId}`)).expect(403);
});
});
describe('POST / (create) appends to the end of its scope', () => {
it('assigns display_order = max + 1 within the event', async () => {
const eventId = await insertEvent('append-ev');
await insertCat('First', { event_id: eventId, display_order: 1 });
await insertCat('Second', { event_id: eventId, display_order: 2 });
const res = await auth(request(app).post('/api/admin/categories'))
.send({ name: 'Third', is_global: false, event_id: eventId })
.expect(200);
expect(res.body.display_order).toBe(3);
});
});
});
@@ -0,0 +1,50 @@
/**
* Catalog-driven event-type defaults (#800 follow-up).
*
* The contract→event conversion used to hardcode `event_type: 'wedding'` and
* the v1 API validated against a fixed whitelist. Both now follow the live
* event_types catalog; these tests pin the shared resolver.
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('resolveDefaultEventType follows the catalog', () => {
let db;
let cleanup;
let eventTypeService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Require AFTER bootCrmDb so the service shares this db instance
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
eventTypeService = require('../../src/services/eventTypeService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it("prefers the 'other' catch-all while it is active", async () => {
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
});
it('falls over to the first active type when other is deactivated', async () => {
const other = await db('event_types').where({ slug_prefix: 'other' }).first();
await db('event_types').where({ id: other.id }).update({ is_active: 0 });
const resolved = await eventTypeService.resolveDefaultEventType();
expect(resolved).not.toBe('other');
expect(await db('event_types').where({ slug_prefix: resolved }).first()).toBeTruthy();
await db('event_types').where({ id: other.id }).update({ is_active: 1 });
});
it("returns the literal 'other' only for an empty catalog", async () => {
const rows = await db('event_types').select('*');
await db('event_types').del();
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
await db('event_types').insert(rows);
});
});
@@ -0,0 +1,133 @@
/**
* Setup-window event type deletion (#800).
*
* The first-run setup wizard may delete the seeded SYSTEM event types —
* but ONLY while the `setup_wizard_completed` flag is unset (migration 161
* seeds it false on a fresh install, true when an admin already exists).
* These tests pin the whole contract:
*
* - fresh install → flag false → system types deletable (in-use checks
* still apply), and the per-type reminder template goes with the type
* - reminder-template self-heal does NOT resurrect templates for slugs
* that no longer exist in the catalog
* - after markSetupWizardCompleted() → system deletion is refused again
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('event type deletion during the setup window (#800)', () => {
let db;
let cleanup;
let eventTypeService;
let setupService;
let ensureEventReminderTemplatesSeeded;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Require AFTER bootCrmDb so every service shares this db instance
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
eventTypeService = require('../../src/services/eventTypeService');
setupService = require('../../src/services/setupService');
({ ensureEventReminderTemplatesSeeded } = require('../../src/services/eventReminderTemplates'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('migration 161 seeds the flag false on a fresh (admin-less) install', async () => {
const row = await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).first();
expect(row).toBeTruthy();
expect(JSON.parse(row.setting_value)).toBe(false);
expect(await setupService.isSetupWizardCompleted()).toBe(false);
});
it('refuses to delete a system type that events already use, even in the window', async () => {
const corporate = await db('event_types').where({ slug_prefix: 'corporate' }).first();
await db('events').insert({
slug: 'corporate-test-2026-01-01',
event_name: 'Test',
event_type: 'corporate',
event_date: '2026-01-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: 'share-corporate-test',
expires_at: new Date(Date.now() + 86400000),
});
await expect(eventTypeService.deleteEventType(corporate.id))
.rejects.toMatchObject({ code: 'IN_USE' });
});
it('deletes an unused system type in the window, taking its reminder template along', async () => {
// Seed the per-type reminder templates first so there is something to clean up.
await ensureEventReminderTemplatesSeeded(db);
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeTruthy();
const wedding = await db('event_types').where({ slug_prefix: 'wedding' }).first();
expect(wedding.is_system).toBeTruthy();
const result = await eventTypeService.deleteEventType(wedding.id);
expect(result.success).toBe(true);
expect(await db('event_types').where({ slug_prefix: 'wedding' }).first()).toBeFalsy();
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
// The deleted slug must NOT stay creatable through the legacy fallback —
// the live catalog is authoritative while it has rows.
expect(await eventTypeService.isValidEventType('wedding')).toBe(false);
expect(await eventTypeService.isValidEventType('birthday')).toBe(true);
});
it('does not resurrect reminder templates for deleted types on the next self-heal pass', async () => {
// The seeder caches success per process — reset the module to force a
// genuine second pass, exactly what a backend restart would run.
jest.resetModules();
const fresh = require('../../src/services/eventReminderTemplates');
await fresh.ensureEventReminderTemplatesSeeded(db);
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
// Types still in the catalog keep their templates.
expect(await db('email_templates').where({ template_key: 'event_reminder_birthday' }).first()).toBeTruthy();
expect(await db('email_templates').where({ template_key: 'event_reminder_default' }).first()).toBeTruthy();
});
it('re-locks system types once the wizard is marked complete', async () => {
await setupService.markSetupWizardCompleted();
expect(await setupService.isSetupWizardCompleted()).toBe(true);
const birthday = await db('event_types').where({ slug_prefix: 'birthday' }).first();
await expect(eventTypeService.deleteEventType(birthday.id))
.rejects.toMatchObject({ code: 'SYSTEM_TYPE' });
// Custom (non-system) types remain deletable as before.
const custom = await eventTypeService.createEventType({ name: 'Family', slug_prefix: 'family' });
const result = await eventTypeService.deleteEventType(custom.id);
expect(result.success).toBe(true);
});
it('fails closed when the completion marker row is missing', async () => {
// A portable-backup restore can replace app_settings with a set that
// predates migration 161 (which will not rerun) — absence must mean
// "configured instance", never an open deletion window.
await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
expect(await setupService.isSetupWizardCompleted()).toBe(true);
await setupService.markSetupWizardCompleted();
});
it('refuses to delete the last remaining event type', async () => {
// Reduce the catalog to a single custom type via direct db writes (the
// service paths are already covered above), then hit the guard.
const solo = await eventTypeService.createEventType({ name: 'Solo', slug_prefix: 'solo' });
await db('events').del();
await db('event_types').whereNot('id', solo.id).del();
await expect(eventTypeService.deleteEventType(solo.id))
.rejects.toMatchObject({ code: 'LAST_TYPE' });
// Deactivating it would empty the ACTIVE catalog just the same.
await expect(eventTypeService.updateEventType(solo.id, { is_active: false }))
.rejects.toMatchObject({ code: 'LAST_ACTIVE' });
});
});
@@ -0,0 +1,165 @@
/**
* Minimal in-process OIDC provider for integration tests (#798).
*
* Serves just enough of the spec for openid-client's full validation to
* pass: discovery, JWKS (RS256), authorization endpoint (immediate redirect,
* no login UI), and token endpoint (authorization_code + PKCE). Claims for
* the next login are scripted per test via `setNextUser()`.
*
* Runs on an ephemeral localhost port over plain http — the service allows
* that in NODE_ENV=test only.
*/
const http = require('http');
const crypto = require('crypto');
const { URL } = require('url');
function b64url(input) {
return Buffer.from(input).toString('base64url');
}
class MockOidcProvider {
constructor() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
this.privateKey = privateKey;
this.publicJwk = publicKey.export({ format: 'jwk' });
this.publicJwk.kid = 'test-key-1';
this.publicJwk.alg = 'RS256';
this.publicJwk.use = 'sig';
this.clientId = 'picpeak-test';
this.clientSecret = 'test-client-secret';
this.codes = new Map(); // code -> { nonce, redirectUri, codeChallenge, user }
this.nextUser = { sub: 'user-1', email: 'sso@example.com', email_verified: true };
// Test hooks:
this.tamperNonce = false; // sign the ID token with a WRONG nonce
this.emailViaUserinfoOnly = false; // omit email from the ID token; serve it on /userinfo
this.accessTokens = new Map(); // access_token -> user (for /userinfo)
this.server = null;
this.issuer = null;
}
setNextUser(user) {
this.nextUser = user;
}
signIdToken({ sub, nonce, extraClaims = {} }) {
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'RS256', kid: this.publicJwk.kid, typ: 'JWT' };
const payload = {
iss: this.issuer,
aud: this.clientId,
sub,
iat: now,
exp: now + 300,
nonce,
...extraClaims,
};
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), this.privateKey);
return `${signingInput}.${signature.toString('base64url')}`;
}
async start() {
this.server = http.createServer((req, res) => this.handle(req, res));
await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve));
this.issuer = `http://127.0.0.1:${this.server.address().port}`;
return this.issuer;
}
async stop() {
if (this.server) await new Promise((resolve) => this.server.close(resolve));
}
handle(req, res) {
const url = new URL(req.url, this.issuer);
const json = (status, body) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (url.pathname === '/.well-known/openid-configuration') {
return json(200, {
issuer: this.issuer,
authorization_endpoint: `${this.issuer}/authorize`,
token_endpoint: `${this.issuer}/token`,
userinfo_endpoint: `${this.issuer}/userinfo`,
jwks_uri: `${this.issuer}/jwks`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
});
}
if (url.pathname === '/jwks') {
return json(200, { keys: [this.publicJwk] });
}
if (url.pathname === '/authorize') {
// "Log in" instantly: mint a code bound to this request's params and
// bounce back to the redirect_uri like a real IdP would.
const code = crypto.randomBytes(16).toString('base64url');
this.codes.set(code, {
nonce: url.searchParams.get('nonce'),
redirectUri: url.searchParams.get('redirect_uri'),
codeChallenge: url.searchParams.get('code_challenge'),
user: this.nextUser,
});
const back = new URL(url.searchParams.get('redirect_uri'));
back.searchParams.set('code', code);
back.searchParams.set('state', url.searchParams.get('state'));
res.writeHead(302, { location: back.href });
return res.end();
}
if (url.pathname === '/token' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const params = new URLSearchParams(body);
const stored = this.codes.get(params.get('code'));
if (!stored) return json(400, { error: 'invalid_grant' });
this.codes.delete(params.get('code'));
// PKCE check — S256(code_verifier) must match the challenge.
const verifier = params.get('code_verifier') || '';
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
if (challenge !== stored.codeChallenge) {
return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
}
const { sub, ...extraClaims } = stored.user;
// Spec-compliant providers may keep profile/email claims OFF the ID
// token and serve them from /userinfo only — this hook simulates that.
const idTokenClaims = this.emailViaUserinfoOnly ? {} : extraClaims;
const idToken = this.signIdToken({
sub,
nonce: this.tamperNonce ? 'tampered-nonce' : stored.nonce,
extraClaims: idTokenClaims,
});
const accessToken = crypto.randomBytes(16).toString('base64url');
this.accessTokens.set(accessToken, stored.user);
return json(200, {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 300,
id_token: idToken,
});
});
return undefined;
}
if (url.pathname === '/userinfo') {
const auth = req.headers.authorization || '';
const user = this.accessTokens.get(auth.replace(/^Bearer\s+/i, ''));
if (!user) return json(401, { error: 'invalid_token' });
return json(200, { ...user });
}
return json(404, { error: 'not_found' });
}
}
module.exports = { MockOidcProvider };
@@ -0,0 +1,302 @@
/**
* OIDC SSO integration tests (#798, phase 1).
*
* Full-stack over a mock in-process IdP (mockOidcProvider): supertest drives
* the real /admin/sso/login and /admin/sso/callback routes on a fresh-SQLite
* database, openid-client does genuine discovery/JWKS/PKCE/ID-token
* validation against the mock issuer. Pins:
*
* - happy path: JIT provisioning creates an admin and sets the session cookie
* - JIT off → not_provisioned redirect, no row created
* - repeat login matches by sub, not email (email change ≠ new account)
* - verified-email one-time link onto an existing local admin
* - unverified email must NOT link (falls through to JIT/or error)
* - deactivated admin → inactive redirect
* - missing/forged state cookie → state redirect
* - nonce tamper from the IdP → idp redirect
* - settings endpoints: secret write-only, generic /general upsert cannot
* clobber oidc_client_secret
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb } = require('./helpers/crmDb');
const { MockOidcProvider } = require('./helpers/mockOidcProvider');
describe('OIDC SSO (#798)', () => {
let db;
let cleanup;
let app;
let idp;
let oidcService;
const agentCookies = {};
beforeAll(async () => {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-test-secret';
// The redirect_uri derives from the public base URL — pin it explicitly:
// CI has no backend/.env, and getFrontendBaseUrl() returning '' makes
// buildAuthorizationRequest fail (by design) with OIDC_BAD_CONFIG.
process.env.FRONTEND_URL = 'http://localhost:5199';
({ db, cleanup } = await bootCrmDb());
idp = new MockOidcProvider();
const issuer = await idp.start();
// Require AFTER bootCrmDb so services share this db instance.
oidcService = require('../../src/services/oidcService');
await oidcService.saveOidcSettings({
oidc_enabled: true,
oidc_issuer_url: issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
oidc_autoprovision: true,
oidc_default_role: 'viewer',
});
const authRouter = require('../../src/routes/auth');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRouter);
}, 120000);
afterAll(async () => {
if (idp) await idp.stop();
if (cleanup) await cleanup();
});
/** Drive login → IdP → callback like a browser; returns the callback response. */
async function ssoRoundTrip({ mutateState } = {}) {
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
const idpUrl = loginRes.headers.location;
expect(idpUrl.startsWith(idp.issuer)).toBe(true);
let stateCookie = (loginRes.headers['set-cookie'] || [])
.find((c) => c.startsWith('oidc_state='));
expect(stateCookie).toBeTruthy();
stateCookie = stateCookie.split(';')[0];
if (mutateState === 'drop') stateCookie = null;
if (mutateState === 'forge') {
stateCookie = `oidc_state=${jwt.sign({ type: 'oidc_state', s: 'x', n: 'y', cv: 'z' }, 'wrong-secret', { issuer: 'picpeak-auth' })}`;
}
// "Browser" follows the redirect to the IdP, which instantly bounces back.
const idpRes = await fetch(idpUrl, { redirect: 'manual' });
expect(idpRes.status).toBe(302);
const back = new URL(idpRes.headers.get('location'));
let cb = request(app).get(`${back.pathname}?${back.searchParams.toString()}`);
if (stateCookie) cb = cb.set('Cookie', stateCookie);
return cb.expect(302);
}
it('JIT-provisions an unknown user and establishes an admin session', async () => {
idp.setNextUser({ sub: 'sub-jit-1', email: 'jit@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const adminCookie = (res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='));
expect(adminCookie).toBeTruthy();
const row = await db('admin_users').where({ email: 'jit@example.com' }).first();
expect(row).toBeTruthy();
expect(row.auth_provider).toBe('oidc');
expect(row.external_subject).toBe('sub-jit-1');
const role = await db('roles').where('id', row.role_id).first();
expect(role.name).toBe('viewer');
// The session JWT must be a normal admin token.
const token = adminCookie.split(';')[0].replace('admin_token=', '');
const decoded = jwt.verify(decodeURIComponent(token), process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
expect(decoded.type).toBe('admin');
expect(decoded.id).toBe(row.id);
agentCookies.jitAdminId = row.id;
});
it('matches repeat logins by sub even when the email changed at the IdP', async () => {
idp.setNextUser({ sub: 'sub-jit-1', email: 'renamed@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
// No second row — resolved via external_subject.
expect(await db('admin_users').where({ email: 'renamed@example.com' }).first()).toBeFalsy();
const byId = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
expect(byId.external_subject).toBe('sub-jit-1');
});
it('links an existing local admin one-time via VERIFIED email and stamps the sub', async () => {
const role = await db('roles').where({ name: 'admin' }).first();
const [localId] = await db('admin_users').insert({
username: 'local-admin',
email: 'local@example.com',
password_hash: await bcrypt.hash('LocalPass123', 4),
role_id: role.id,
is_active: 1,
auth_provider: 'local',
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
idp.setNextUser({ sub: 'sub-local-1', email: 'local@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const row = await db('admin_users').where({ id: localId }).first();
expect(row.external_subject).toBe('sub-local-1');
expect(row.auth_provider).toBe('local'); // password keeps working
});
it('does NOT link by unverified email — provisions a separate account instead', async () => {
const role = await db('roles').where({ name: 'admin' }).first();
await db('admin_users').insert({
username: 'victim-admin',
email: 'victim@example.com',
password_hash: await bcrypt.hash('VictimPass123', 4),
role_id: role.id,
is_active: 1,
auth_provider: 'local',
created_at: new Date(),
updated_at: new Date(),
});
idp.setNextUser({ sub: 'sub-attacker', email: 'victim@example.com', email_verified: false });
// JIT would need this email but the victim row owns it (unique) — the
// insert fails and the flow must land on an error, never on the
// victim's session.
const res = await ssoRoundTrip();
expect(res.headers.location).toMatch(/sso_error=/);
const victim = await db('admin_users').where({ email: 'victim@example.com' }).first();
expect(victim.external_subject).toBeNull();
});
it('refuses a deactivated admin with sso_error=inactive', async () => {
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 0 });
idp.setNextUser({ sub: 'sub-jit-1', email: 'renamed@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=inactive');
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 1 });
});
it('rejects a callback without the state cookie', async () => {
const res = await ssoRoundTrip({ mutateState: 'drop' });
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=state');
});
it('rejects a forged state cookie (wrong signing key)', async () => {
const res = await ssoRoundTrip({ mutateState: 'forge' });
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=state');
});
it('rejects an ID token whose nonce does not match', async () => {
idp.tamperNonce = true;
idp.setNextUser({ sub: 'sub-nonce', email: 'nonce@example.com', email_verified: true });
const res = await ssoRoundTrip();
idp.tamperNonce = false;
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=idp');
expect(await db('admin_users').where({ email: 'nonce@example.com' }).first()).toBeFalsy();
});
it('blocks JIT with sso_error=not_provisioned when autoprovision is off', async () => {
await oidcService.saveOidcSettings({ oidc_autoprovision: false });
idp.setNextUser({ sub: 'sub-new-user', email: 'new@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=not_provisioned');
expect(await db('admin_users').where({ email: 'new@example.com' }).first()).toBeFalsy();
await oidcService.saveOidcSettings({ oidc_autoprovision: true });
});
it('stores the client secret encrypted and survives a config round-trip', async () => {
const row = await db('app_settings').where({ setting_key: 'oidc_client_secret' }).first();
const stored = JSON.parse(row.setting_value);
expect(stored).not.toContain(idp.clientSecret);
expect(oidcService.decryptSecret(stored)).toBe(idp.clientSecret);
const cfg = await oidcService.getOidcConfig();
expect(cfg.clientSecret).toBe(idp.clientSecret);
});
it('refuses local password login for OIDC-owned accounts', async () => {
// Give the JIT admin a KNOWN password hash directly in the DB — the
// auth_provider check must reject the login even with valid credentials
// (otherwise a password reset would mint an IdP-bypassing local login).
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({
password_hash: await bcrypt.hash('KnownPass123', 4),
});
const row = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
const res = await request(app)
.post('/api/auth/admin/login')
.send({ username: row.email, password: 'KnownPass123' });
expect(res.status).toBe(401);
});
it('returns 404 from /sso/login when SSO is disabled', async () => {
await oidcService.saveOidcSettings({ oidc_enabled: false });
await request(app).get('/api/auth/admin/sso/login').expect(404);
await oidcService.saveOidcSettings({ oidc_enabled: true });
});
it('merges email from the UserInfo endpoint when the ID token omits it', async () => {
idp.emailViaUserinfoOnly = true;
idp.setNextUser({ sub: 'sub-userinfo', email: 'userinfo@example.com', email_verified: true });
const res = await ssoRoundTrip();
idp.emailViaUserinfoOnly = false;
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const row = await db('admin_users').where({ email: 'userinfo@example.com' }).first();
expect(row).toBeTruthy();
expect(row.external_subject).toBe('sub-userinfo');
});
it('binds identities per ISSUER — a sub collision on a new IdP must not inherit the old account', async () => {
// The JIT admin from the first test is bound to (issuer A, 'sub-jit-1').
const boundAdmin = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
expect(boundAdmin.external_issuer).toBe(idp.issuer);
// Same sub, DIFFERENT issuer: a second IdP the instance switches to.
const idp2 = new MockOidcProvider();
await idp2.start();
try {
await oidcService.saveOidcSettings({
oidc_issuer_url: idp2.issuer,
oidc_client_id: idp2.clientId,
oidc_client_secret: idp2.clientSecret,
});
idp2.setNextUser({ sub: 'sub-jit-1', email: 'colliding@example.com', email_verified: true });
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
const stateCookie = (loginRes.headers['set-cookie'] || [])
.find((c) => c.startsWith('oidc_state=')).split(';')[0];
const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' });
const back = new URL(idpRes.headers.get('location'));
const res = await request(app)
.get(`${back.pathname}?${back.searchParams.toString()}`)
.set('Cookie', stateCookie)
.expect(302);
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
// A NEW row bound to issuer B — the issuer-A admin is untouched and
// its role was not inherited.
const collider = await db('admin_users').where({ email: 'colliding@example.com' }).first();
expect(collider).toBeTruthy();
expect(collider.id).not.toBe(agentCookies.jitAdminId);
expect(collider.external_issuer).toBe(idp2.issuer);
const original = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
expect(original.external_issuer).toBe(idp.issuer);
} finally {
await idp2.stop();
await oidcService.saveOidcSettings({
oidc_issuer_url: idp.issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
});
}
});
});
@@ -0,0 +1,190 @@
/**
* PostgreSQL integration tests for the .picpeak restore robustness fixes.
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway Postgres DB,
* e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_restore_test" \
* npx jest __tests__/integration/picpeakRestorePg.test.js
*
* Validates the Postgres-specific paths that SQLite can't exercise: identity
* sequences left stale by explicit-id inserts, pg_get_serial_sequence raising on
* id-less tables, reinject/role-recreate explicit-id inserts, and FK integrity.
*/
const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('picpeak restore on Postgres', () => {
let pgDb;
let svc;
beforeAll(async () => {
pgDb = knex({ client: 'pg', connection: PG_URL });
await pgDb.raw('DROP TABLE IF EXISTS role_permissions, events, admin_users, roles, permissions, app_settings CASCADE');
await pgDb.schema.createTable('roles', (t) => {
t.increments('id');
t.string('name', 50).notNullable().unique();
t.string('display_name', 100);
t.integer('priority').defaultTo(0);
t.boolean('is_system').defaultTo(false);
});
await pgDb.schema.createTable('permissions', (t) => {
t.increments('id');
t.string('name', 100).notNullable().unique();
t.string('display_name', 150);
t.string('category', 50);
});
await pgDb.schema.createTable('role_permissions', (t) => {
t.integer('role_id').notNullable().references('id').inTable('roles').onDelete('CASCADE');
t.integer('permission_id').notNullable().references('id').inTable('permissions').onDelete('CASCADE');
t.primary(['role_id', 'permission_id']);
});
await pgDb.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('username').notNullable().unique();
t.string('email').notNullable().unique();
t.string('password_hash');
t.boolean('is_active').defaultTo(true);
t.boolean('must_change_password').defaultTo(false);
t.integer('role_id').references('id').inTable('roles').onDelete('SET NULL');
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
t.boolean('two_factor_enabled').defaultTo(false);
t.string('two_factor_secret');
t.text('two_factor_recovery_codes');
});
await pgDb.schema.createTable('events', (t) => {
t.increments('id');
t.string('slug');
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
});
await pgDb.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.json('setting_value');
t.string('setting_type');
t.timestamp('updated_at').defaultTo(pgDb.fn.now());
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
svc = require('../../src/services/picpeakImportService');
});
afterAll(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
if (pgDb) await pgDb.destroy();
});
beforeEach(async () => {
await pgDb('role_permissions').del();
await pgDb('events').del();
await pgDb('admin_users').del();
await pgDb('roles').del();
await pgDb('permissions').del();
});
test('resyncSequences fast-forwards stale sequences and skips id-less tables', async () => {
// Simulate a restore: explicit-id inserts leave the sequence at 1.
await pgDb('roles').insert([{ id: 5, name: 'super_admin', display_name: 'SA' }]);
await pgDb('admin_users').insert([{ id: 9, username: 'a', email: 'a@x.io', password_hash: 'h' }]);
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
await pgDb('role_permissions').insert([{ role_id: 5, permission_id: 3 }]); // id-less table
// Must not throw on role_permissions (no `id` column → pg_get_serial_sequence raises unguarded).
await expect(svc.resyncSequences(['roles', 'admin_users', 'permissions', 'role_permissions'])).resolves.toBeUndefined();
// Natural inserts (no explicit id) now avoid the restored ids.
const [adminId] = await pgDb('admin_users').insert({ username: 'b', email: 'b@x.io', password_hash: 'h' }).returning('id');
expect(Number(adminId.id || adminId)).toBe(10); // max(9)+1, no duplicate-key error
const [roleId] = await pgDb('roles').insert({ name: 'editor', display_name: 'Ed' }).returning('id');
expect(Number(roleId.id || roleId)).toBe(6);
});
test('reinjectCurrentAdmin insert branch works with a stale sequence (explicit max+1)', async () => {
await pgDb('admin_users').insert({ id: 9, username: 'backup', email: 'backup@x.io', password_hash: 'h' });
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, created_by: 42 };
await pgDb.transaction((trx) => svc.reinjectCurrentAdmin(trx, operator));
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
expect(op.id).toBe(10); // max(9)+1
expect(op.password_hash).toBe('OP');
expect(op.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
});
test('preserveOperatorRole re-creates a missing role on Postgres and keeps FK integrity', async () => {
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
await pgDb('roles').insert([{ id: 2, name: 'viewer', display_name: 'V' }]);
await pgDb('admin_users').insert({ id: 1, username: 'admin', email: 'op@x.io', password_hash: 'h', role_id: null });
const snapshot = { role: { name: 'super_admin', display_name: 'SA', priority: 100, is_system: true }, permissions: ['events.create', 'missing.perm'] };
await pgDb.transaction((trx) => svc.preserveOperatorRole(trx, 1, snapshot));
await svc.resyncSequences(['roles']); // post-commit, mirrors importFromPicpeak
const role = await pgDb('roles').where({ name: 'super_admin' }).first();
expect(role).toBeTruthy();
const op = await pgDb('admin_users').where({ id: 1 }).first();
expect(op.role_id).toBe(role.id); // FK valid, operator not downgraded
const grants = await pgDb('role_permissions').where({ role_id: role.id }).pluck('permission_id');
expect(grants).toEqual([3]); // existing perm granted, missing.perm skipped
});
test('full replaceAllTables: cross-instance backup preserves the operator, role, FKs, and sequences', async () => {
// A backup from ANOTHER instance: omits the operator's email AND their
// super_admin role; uses explicit ids that leave sequences stale.
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pgtest-'));
const dataDir = path.join(staging, 'data');
fs.mkdirSync(dataDir);
const write = (t, rows) => fs.writeFileSync(path.join(dataDir, `${t}.ndjson`), rows.map((r) => JSON.stringify(r)).join('\n'));
write('roles', [{ id: 5, name: 'admin', display_name: 'Admin', priority: 50, is_system: true }]);
write('permissions', [{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
write('role_permissions', [{ role_id: 5, permission_id: 3 }]);
write('admin_users', [{ id: 9, username: 'backupadmin', email: 'backup@x.io', password_hash: 'h', role_id: 5, is_active: true }]);
write('events', [{ id: 2, slug: 'restored-ev', created_by: 9 }]);
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, role_id: 999, created_by: null };
const roleSnapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
const tables = ['roles', 'permissions', 'role_permissions', 'admin_users', 'events'];
// replaceAllTables isn't exported, so drive its exact transaction sequence
// (suspend FKs, wipe, batchInsert, reinject, preserve role) through the
// exported units against real Postgres.
const importSvc = svc;
await pgDb.transaction(async (trx) => {
await trx.raw('SET session_replication_role = \'replica\'');
for (const t of tables) await trx(t).del();
for (const t of tables) {
const rows = fs.readFileSync(path.join(dataDir, `${t}.ndjson`), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
if (rows.length) await trx.batchInsert(t, rows, 100);
}
const opId = await importSvc.reinjectCurrentAdmin(trx, operator);
await importSvc.preserveOperatorRole(trx, opId, roleSnapshot);
await trx.raw('SET session_replication_role = \'origin\'');
});
await importSvc.resyncSequences(tables);
// Operator preserved (inserted, since email absent from backup).
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
expect(op).toBeTruthy();
expect(op.password_hash).toBe('OP');
// super_admin role re-created and the operator bound to it.
const sa = await pgDb('roles').where({ name: 'super_admin' }).first();
expect(sa).toBeTruthy();
expect(op.role_id).toBe(sa.id);
expect(await pgDb('role_permissions').where({ role_id: sa.id }).pluck('permission_id')).toEqual([3]);
// Restored event's created_by FK to the backup admin still valid.
const ev = await pgDb('events').where({ slug: 'restored-ev' }).first();
expect(ev.created_by).toBe(9);
// Sequences resynced → natural inserts don't collide.
const [newAdmin] = await pgDb('admin_users').insert({ username: 'fresh', email: 'fresh@x.io', password_hash: 'h' }).returning('id');
expect(Number(newAdmin.id || newAdmin)).toBeGreaterThan(op.id);
fs.rmSync(staging, { recursive: true, force: true });
});
});
@@ -180,6 +180,27 @@ describe('admin events CRUD endpoints (smoke)', () => {
});
expect(res.status).toBe(404);
});
// #822 — hero_logo_visible/position are nullable (null = "inherit the global
// branding toggle"), but the validator used .optional() without
// { nullable: true }, so an explicit null was rejected with 400.
it('accepts hero_logo_visible: null and stores NULL (inherit)', async () => {
const id = await insertEvent(db, adminId, { hero_logo_visible: 1 });
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: null,
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.hero_logo_visible).toBeNull();
});
it('still rejects a non-boolean hero_logo_visible', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: 'maybe',
});
expect(res.status).toBe(400);
});
});
describe('DELETE /:id', () => {
@@ -32,9 +32,17 @@ jest.mock('../../src/database/db', () => {
if (table === 'admin_users') {
let rowFilter = () => true;
return {
// The session route joins roles for the adminUser payload (#798);
// fake rows carry no role fields, so the join is a pass-through.
leftJoin() {
return this;
},
where(criteria) {
rowFilter = (row) => {
return Object.entries(criteria).every(([k, v]) => {
return Object.entries(criteria).every(([rawKey, v]) => {
// Joined queries prefix columns ('admin_users.id') — the fake
// rows use bare names.
const k = rawKey.replace(/^admin_users\./, '');
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
return row[k] === v;
});
@@ -50,7 +58,12 @@ jest.mock('../../src/database/db', () => {
if (!row) return undefined;
if (!this._cols) return row;
const out = {};
for (const c of this._cols) out[c] = row[c];
for (const c of this._cols) {
// Support 'table.col' and 'table.col as alias' shapes.
const [source, alias] = c.split(/\s+as\s+/i);
const bare = source.includes('.') ? source.split('.').pop() : source;
out[alias || bare] = row[bare];
}
return out;
},
};
@@ -0,0 +1,127 @@
/**
* Regression test for GHSA-9hmx-68vc-qpqw — share-link login must not bypass
* the gallery password.
*
* POST /auth/gallery/share-login validates only the share token. For a
* password-protected gallery it previously minted a full `type:'gallery'`
* access token on the share token alone, letting anyone holding the share URL
* read the gallery without the password. The fix: when the gallery requires a
* password, return `{ requires_password: true }` with NO token and NO cookie.
*/
const express = require('express');
const request = require('supertest');
process.env.JWT_SECRET = 'share-login-test-secret';
const events = [];
jest.mock('../../src/database/db', () => {
function dbFn(table) {
if (table === 'events') {
let filter = () => true;
return {
where(criteria) {
filter = (row) => Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
return row[k] === v;
});
return this;
},
async first() { return events.find(filter); },
};
}
return { where() { return this; }, async first() { return undefined; } };
}
dbFn.raw = async () => {};
return { db: dbFn, logActivity: async () => {} };
});
// Share token is stored plainly on the fake event row.
jest.mock('../../src/services/shareLinkService', () => ({
getEventShareToken: (event) => event.share_token,
resolveShareIdentifier: async () => ({ event: null }),
}));
const mockSetGalleryAuthCookies = jest.fn();
jest.mock('../../src/utils/tokenUtils', () => ({
setGalleryAuthCookies: (...args) => mockSetGalleryAuthCookies(...args),
clearGalleryAuthCookies: jest.fn(),
getGalleryTokenFromRequest: jest.fn(),
setAdminAuthCookies: jest.fn(),
}));
jest.mock('../../src/utils/authSecurity', () => ({
trackFailedAttempt: jest.fn(async () => {}),
trackSuccessfulLogin: jest.fn(async () => {}),
checkAccountLockout: jest.fn(async () => ({ isLocked: false })),
resetLockout: jest.fn(async () => {}),
}));
// Collaborators the router imports at load but the share-login path doesn't hit.
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: async () => true }));
jest.mock('../../src/services/mfaService', () => ({}));
jest.mock('../../src/middleware/sessionTimeout', () => ({ endSession: jest.fn(), sessionTimeoutMiddleware: (req, res, next) => next() }));
jest.mock('../../src/utils/tokenRevocation', () => ({ revokeToken: jest.fn(async () => {}), isTokenRevoked: async () => false }));
const authRouter = require('../../src/routes/auth');
function makeApp() {
const app = express();
app.use(express.json());
app.use('/auth', authRouter);
return app;
}
const SHARE_TOKEN = 'a'.repeat(64);
beforeEach(() => {
events.length = 0;
mockSetGalleryAuthCookies.mockClear();
});
describe('POST /auth/gallery/share-login password enforcement', () => {
it('does NOT mint a token for a password-protected gallery', async () => {
events.push({
id: 1, slug: 'private-gallery', is_active: 1, is_archived: 0,
require_password: 1, share_token: SHARE_TOKEN, event_name: 'Private',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'private-gallery', token: SHARE_TOKEN });
expect(res.status).toBe(200);
expect(res.body.requires_password).toBe(true);
expect(res.body.token).toBeUndefined();
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
});
it('mints a token for a public (no-password) gallery', async () => {
events.push({
id: 2, slug: 'public-gallery', is_active: 1, is_archived: 0,
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'public-gallery', token: SHARE_TOKEN });
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
expect(res.body.event).toBeDefined();
expect(mockSetGalleryAuthCookies).toHaveBeenCalledTimes(1);
});
it('rejects a wrong share token regardless of password setting', async () => {
events.push({
id: 3, slug: 'public-gallery', is_active: 1, is_archived: 0,
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'public-gallery', token: 'b'.repeat(64) });
expect(res.status).toBe(401);
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
/**
* Regression test for GHSA-4j34-x562-5vfq — broken access control in the legacy
* /api/events router.
*
* The legacy router exposed create/list/update/delete/extend guarded by
* adminAuth ALONE (no requirePermission, no requireEventOwnership), so any
* back-office account — down to a read-only viewer — could read every gallery's
* password_hash/share_token and take over any gallery. The fix removes that
* router entirely and migrates its one UI-used route (POST /:id/extend) to the
* canonical /api/admin/events mount, where it inherits the permission +
* ownership guards.
*
* This test pins two invariants:
* 1. The legacy source file is gone (nothing can re-mount it).
* 2. The migrated extend route enforces ownership — a non-owning editor gets
* 403, the owner succeeds.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-legacy-acl-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'legacy-acl-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
async function insertEvent(db, ownerId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'Owner Gallery',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_by: ownerId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('GHSA-4j34: legacy /api/events router removed + extend guarded', () => {
it('the legacy events router source file no longer exists', () => {
expect(fs.existsSync(path.join(__dirname, '../../src/routes/events.js'))).toBe(false);
});
describe('POST /api/admin/events/:id/extend ownership enforcement', () => {
let db; let cleanup; let app;
let ownerId; let ownerToken;
let editorId; let editorToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: ownerId } = await seedMinimal(db));
await assignAdminRole(db, ownerId, 'super_admin');
ownerToken = mintAdminToken(ownerId);
// A second, non-owning account with the low-trust editor role.
[editorId] = await db('admin_users').insert({
username: 'editor1', email: 'editor1@example.com',
password_hash: 'x', is_active: 1,
}).returning('id');
editorId = editorId?.id ?? editorId;
await assignAdminRole(db, editorId, 'editor');
editorToken = mintAdminToken(editorId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { await cleanup(); });
it('lets the owner extend their own gallery', async () => {
const id = await insertEvent(db, ownerId, { expires_at: '2026-06-01T00:00:00.000Z' });
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ days: 10 });
expect(res.status).toBe(200);
expect(new Date(res.body.expires_at).toISOString()).toBe('2026-06-11T00:00:00.000Z');
});
it('403s a non-owning editor trying to extend someone else\'s gallery', async () => {
const id = await insertEvent(db, ownerId); // owned by the super_admin
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ days: 30 });
expect(res.status).toBe(403); // requireEventOwnership blocks it
});
it('validates the days field', async () => {
const id = await insertEvent(db, ownerId);
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ days: 9999 });
expect(res.status).toBe(400);
});
});
});
@@ -0,0 +1,52 @@
const path = require('path');
const os = require('os');
const fs = require('fs').promises;
// Point storage at a throwaway temp dir before requiring the service so the
// module-level getStoragePath() picks it up if evaluated.
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-test-${process.pid}`);
const chunkedUpload = require('../../src/services/chunkedUploadService');
describe('chunkedUploadService.initializeUpload filename sanitisation (GHSA-pc72-jf53-w28j)', () => {
afterAll(async () => {
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
});
it('strips directory-traversal components from the stored filename', async () => {
const { uploadId } = await chunkedUpload.initializeUpload({
filename: '../../uploads/logos/evil.svg',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
});
const meta = chunkedUpload.getUploadStatus(uploadId);
// basename('../../uploads/logos/evil.svg') === 'evil.svg' — the traversal
// is gone, so path.join(tempDir, filename) can no longer escape tempDir.
expect(meta.filename).toBe('evil.svg');
});
it('keeps a normal filename intact', async () => {
const { uploadId } = await chunkedUpload.initializeUpload({
filename: 'clip.mp4',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
});
expect(uploadId).toBeTruthy();
});
it('rejects a filename that collapses to nothing', async () => {
await expect(
chunkedUpload.initializeUpload({
filename: '../',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
})
).rejects.toThrow(/Invalid filename/);
});
});
@@ -0,0 +1,58 @@
/**
* Regression tests for the Docker update instructions (environmentService).
*
* A production install (docker-compose.production.yml) must get `-f
* docker-compose.production.yml` in every update command — bare `docker compose`
* targets docker-compose.yml, a different build-based stack that also starts the
* dev-only mailhog, which left production users stranded on the old version
* (reported against 3.44.0 → 3.45.2).
*/
const { detectEnvironment, generateUpdateInstructions } = require('../../src/services/environmentService');
describe('detectEnvironment — production compose detection', () => {
const orig = process.env.PICPEAK_RELEASE_CHANNEL;
afterEach(() => {
if (orig === undefined) delete process.env.PICPEAK_RELEASE_CHANNEL;
else process.env.PICPEAK_RELEASE_CHANNEL = orig;
});
it('flags isProductionCompose when PICPEAK_RELEASE_CHANNEL is set', async () => {
process.env.PICPEAK_RELEASE_CHANNEL = 'stable';
const env = await detectEnvironment();
expect(env.isProductionCompose).toBe(true);
});
it('does not flag it when the var is absent (default docker-compose.yml)', async () => {
delete process.env.PICPEAK_RELEASE_CHANNEL;
const env = await detectEnvironment();
expect(env.isProductionCompose).toBe(false);
});
});
describe('generateUpdateInstructions — Docker commands', () => {
const cmds = (env) => generateUpdateInstructions(env, '3.45.2').steps.map((s) => s.command);
it('targets docker-compose.production.yml for a production install', () => {
const commands = cmds({ isDocker: true, isProductionCompose: true });
expect(commands).toEqual([
'docker compose -f docker-compose.production.yml pull',
'docker compose -f docker-compose.production.yml up -d',
'docker compose -f docker-compose.production.yml logs -f backend',
]);
// And the warning tells them where to run it.
const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: true }, '3.45.2');
expect(warnings.join(' ')).toMatch(/docker-compose\.production\.yml/);
});
it('uses bare commands + a hint when not a production compose', () => {
const commands = cmds({ isDocker: true, isProductionCompose: false });
expect(commands).toEqual([
'docker compose pull',
'docker compose up -d',
'docker compose logs -f backend',
]);
const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: false }, '3.45.2');
// Still nudges production users to add -f in case detection missed.
expect(warnings.join(' ')).toMatch(/-f docker-compose\.production\.yml/);
});
});
@@ -0,0 +1,125 @@
/**
* Regression tests for the file-watcher concurrency bound.
*
* chokidar fires 'add' once per file — with no ignoreInitial option the boot
* scan fires it for every existing file, and a bulk drop fires it for every
* new one at once. Unbounded handlers each run DB lookups plus a full sharp
* pipeline (sharp.concurrency(2) only caps libvips threads WITHIN one
* operation), which can OOM small hosts. Both 'add' and 'unlink' must go
* through the shared p-limit gate.
*
* Adapted from the filpgame fork (426ca491), extended to cover 'unlink'.
*/
const mockLimit = jest.fn((operation) => Promise.resolve().then(operation));
const mockPLimit = jest.fn(() => mockLimit);
const mockHandlers = {};
const mockWatcher = {
on: jest.fn((event, handler) => {
mockHandlers[event] = handler;
return mockWatcher;
}),
};
// Shared instances captured by the mock factories: jest.isolateModules re-runs
// each factory in a fresh registry, so the factories must return these same
// objects for the test to observe calls made inside the isolated module.
const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
// Chainable no-row query — enough for removePhoto's lookup/delete calls.
const mockDb = jest.fn(() => ({
where: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue(null),
delete: jest.fn().mockResolvedValue(0),
}));
jest.mock('p-limit', () => mockPLimit);
jest.mock('chokidar', () => ({
watch: jest.fn(() => mockWatcher),
}));
jest.mock('../../src/database/db', () => ({ db: mockDb }));
jest.mock('../../src/utils/logger', () => mockLogger);
jest.mock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(),
generateVideoPlaceholder: jest.fn(),
}));
jest.mock('../../src/services/videoProcessor', () => ({
isVideoMimeType: jest.fn(() => false),
}));
jest.mock('../../src/services/downloadZipService', () => ({ invalidate: jest.fn() }));
jest.mock('../../src/utils/dbCompat', () => ({
formatBoolean: jest.fn((value) => value),
}));
const loadFileWatcher = () => {
let fileWatcher;
jest.isolateModules(() => {
fileWatcher = require('../../src/services/fileWatcher');
});
return fileWatcher;
};
describe('fileWatcher concurrency bound', () => {
const originalBackend = process.env.STORAGE_BACKEND;
const originalConcurrency = process.env.FILE_WATCHER_CONCURRENCY;
beforeEach(() => {
jest.clearAllMocks();
Object.keys(mockHandlers).forEach((key) => delete mockHandlers[key]);
process.env.STORAGE_BACKEND = 'local';
delete process.env.FILE_WATCHER_CONCURRENCY;
});
afterAll(() => {
if (originalBackend === undefined) delete process.env.STORAGE_BACKEND;
else process.env.STORAGE_BACKEND = originalBackend;
if (originalConcurrency === undefined) delete process.env.FILE_WATCHER_CONCURRENCY;
else process.env.FILE_WATCHER_CONCURRENCY = originalConcurrency;
});
it.each([
[undefined, 2], // default
['3', 3], // explicit
['0', 1], // floored to 1
['-4', 1], // floored to 1
['invalid', 2], // falls back to default
])('configures the limiter with FILE_WATCHER_CONCURRENCY=%s as %i', (configured, expected) => {
if (configured === undefined) delete process.env.FILE_WATCHER_CONCURRENCY;
else process.env.FILE_WATCHER_CONCURRENCY = configured;
loadFileWatcher().startFileWatcher();
expect(mockPLimit).toHaveBeenCalledWith(expected);
});
it('routes add events through the shared limiter', async () => {
loadFileWatcher().startFileWatcher();
expect(mockHandlers.add).toEqual(expect.any(Function));
mockHandlers.add('/outside-watch-root'); // early-returns inside processNewPhoto
expect(mockLimit).toHaveBeenCalledTimes(1);
expect(mockLimit).toHaveBeenCalledWith(expect.any(Function));
await mockLimit.mock.results[0].value;
});
it('routes unlink events through the same limiter', async () => {
loadFileWatcher().startFileWatcher();
expect(mockHandlers.unlink).toEqual(expect.any(Function));
mockHandlers.unlink('/outside-watch-root'); // early-returns inside removePhoto
expect(mockLimit).toHaveBeenCalledTimes(1);
await mockLimit.mock.results[0].value;
});
it('logs instead of rejecting when a queued handler throws', async () => {
loadFileWatcher().startFileWatcher();
const failure = new Error('boom');
mockLimit.mockImplementationOnce(() => Promise.reject(failure));
mockHandlers.add('/whatever');
await new Promise(process.nextTick);
expect(mockLogger.error).toHaveBeenCalledWith('Error processing new photo:', failure);
});
});
@@ -0,0 +1,30 @@
/**
* Locks the process-wide Sharp memory guards. The file-watcher concurrency
* bound (FILE_WATCHER_CONCURRENCY) assumes these caps stay in place — they
* limit libvips threads/cache WITHIN one operation while p-limit bounds the
* number of parallel pipelines. From the filpgame fork (426ca491).
*/
const mockSharp = jest.fn();
mockSharp.cache = jest.fn();
mockSharp.concurrency = jest.fn();
jest.mock('sharp', () => mockSharp);
jest.mock('../../src/utils/logger', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
describe('imageProcessor Sharp configuration', () => {
it('disables the Sharp cache and caps libvips concurrency', () => {
jest.isolateModules(() => {
require('../../src/services/imageProcessor');
});
expect(mockSharp.cache).toHaveBeenCalledWith(false);
expect(mockSharp.concurrency).toHaveBeenCalledWith(2);
});
});
@@ -0,0 +1,50 @@
/**
* Unit tests for the RAW/DNG handling helpers (#821). The actual exiftool
* extraction can only be exercised in the built image (exiftool isn't a dev
* dependency), so these cover the gating logic: which files are treated as RAW,
* and that ordinary images pass through untouched (zero cost / no extraction).
*/
const path = require('path');
const { isRawFilename, withProcessableImage, RAW_EXTENSIONS } = require('../../src/services/imageProcessor');
describe('isRawFilename', () => {
it('recognises common RAW / DNG extensions', () => {
for (const ext of ['dng', 'cr2', 'cr3', 'nef', 'arw', 'raf', 'rw2', 'orf']) {
expect(isRawFilename(`IMG_1234.${ext}`)).toBe(true);
expect(isRawFilename(`IMG_1234.${ext.toUpperCase()}`)).toBe(true); // case-insensitive
}
});
it('does not treat ordinary images/videos as RAW', () => {
for (const name of ['photo.jpg', 'photo.jpeg', 'photo.png', 'photo.webp', 'clip.mp4', 'clip.mov', 'photo.heic']) {
expect(isRawFilename(name)).toBe(false);
}
});
it('is null/empty safe', () => {
expect(isRawFilename(null)).toBe(false);
expect(isRawFilename('')).toBe(false);
expect(isRawFilename('noextension')).toBe(false);
});
it('RAW_EXTENSIONS includes dng (Apple ProRAW)', () => {
expect(RAW_EXTENSIONS.has('dng')).toBe(true);
});
});
describe('withProcessableImage', () => {
it('passes ordinary images through with no extraction and a no-op cleanup', async () => {
const localPath = '/tmp/whatever/photo.jpg';
const proc = await withProcessableImage(localPath, 'photo.jpg');
expect(proc.path).toBe(localPath); // unchanged — sharp reads it directly
expect(proc.outputBasename).toBeUndefined(); // generators keep their default naming
await expect(Promise.resolve(proc.cleanup())).resolves.toBeUndefined();
});
it('routes RAW files to extraction (which fails cleanly without exiftool/preview)', async () => {
// In the dev sandbox exiftool isn't installed, so extraction throws — the
// caller turns that into a normal processing failure. In the built image
// (exiftool present) this instead returns the embedded JPEG preview.
await expect(withProcessableImage('/tmp/whatever/IMG_1234.dng', 'IMG_1234.dng')).rejects.toThrow();
});
});
@@ -234,3 +234,66 @@ describe('renderInvoiceToBuffer — Storno branch', () => {
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
});
});
// VAT free-text note (#794) + multi-page page-number placement. Same
// constraint as the Storno tests: PDFKit Flate-compresses content streams,
// so we can't grep the note text — but the page-TREE objects are NOT
// compressed, so `/Type /Page` (not `/Pages`) is countable to assert
// pagination, and a byte-size delta proves the note actually rendered.
describe('renderInvoiceToBuffer — VAT note + multi-page footer (#794)', () => {
function baseCtx(overrides = {}) {
return {
locale: 'de', currency: 'CHF',
issuer: { companyName: 'AcmeCo' },
recipient: {
companyName: 'KundenCo', addressLine1: 'Strasse 1',
city: 'Bern', postalCode: '3000',
},
lineItems: [{
quantity: 1, description: 'Photo session',
unitPriceMinor: 30000, lineTotalMinor: 30000,
parentLineItemId: null, parentPosition: null,
}],
totals: {
netAmountMinor: 30000, vatRate: 0, vatAmountMinor: 0,
shippingAmountMinor: 0, totalAmountMinor: 30000,
},
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
qrFormat: 'none',
paymentTerm: { netDays: 30 },
...overrides,
};
}
const pageCount = (buf) => (buf.toString('latin1').match(/\/Type\s*\/Page(?![s])/g) || []).length;
const VAT_NOTE = 'Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).';
it('renders the VAT note on a single-page invoice (adds content, valid PDF)', async () => {
const withNote = await pdfService.renderInvoiceToBuffer(baseCtx({ vatNote: VAT_NOTE }));
const without = await pdfService.renderInvoiceToBuffer(baseCtx());
expect(withNote.slice(0, 4).toString('ascii')).toBe('%PDF');
expect(pageCount(withNote)).toBe(1);
expect(withNote.length).toBeGreaterThan(without.length);
});
it('paginates a long invoice (with the note) across multiple pages without a stray blank page', async () => {
const manyItems = Array.from({ length: 60 }, (_, i) => ({
quantity: 1, description: `Position ${i + 1} — fotografische Leistung`,
unitPriceMinor: 3225, lineTotalMinor: 3225,
parentLineItemId: null, parentPosition: null,
}));
const buf = await pdfService.renderInvoiceToBuffer(baseCtx({
lineItems: manyItems,
totals: {
netAmountMinor: 193500, vatRate: 0, vatAmountMinor: 0,
shippingAmountMinor: 0, totalAmountMinor: 193500,
},
vatNote: VAT_NOTE,
}));
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
const pages = pageCount(buf);
expect(pages).toBeGreaterThanOrEqual(2);
// 60 short rows fit in 23 pages; a stray blank page (the old margin
// bug) or a runaway loop would blow past this.
expect(pages).toBeLessThanOrEqual(3);
});
});
@@ -71,15 +71,24 @@ jest.mock('../../src/services/imageProcessor', () => {
const mockExtractCaptureDate = jest.fn();
return {
generateThumbnail: mockGenerateThumbnail,
generateVideoPlaceholder: jest.fn(async (filename) => `thumbnails/thumb_${filename.replace(/\.[^.]+$/, '')}.jpg`),
extractCaptureDate: mockExtractCaptureDate,
withLocalCopy: jest.fn(async (key, fn) =>
fn(`/tmp/local-copy-${require('path').basename(key)}`)
),
// Pass-through for ordinary (non-RAW) images: returns the path unchanged
// with a no-op cleanup, matching the real helper's behaviour for jpg/png.
withProcessableImage: jest.fn(async (localPath) => ({
path: localPath,
outputBasename: undefined,
cleanup: () => {},
})),
};
});
jest.mock('../../src/services/videoProcessor', () => ({
processUploadedVideo: jest.fn(),
extractVideoMetadata: jest.fn(),
isVideoMimeType: (mime) => typeof mime === 'string' && mime.startsWith('video/'),
}));
@@ -205,6 +214,44 @@ describe('photoProcessor.processPhoto', () => {
expect(watermarkService.generateForPhoto).not.toHaveBeenCalled();
});
it('keeps a video complete with a placeholder thumbnail when ffmpeg fails', async () => {
dbModule.__setPhoto({
id: 203,
event_id: 9,
filename: 'drone-clip.mp4',
original_filename: 'drone.mp4',
mime_type: 'video/mp4',
media_type: 'video',
size_bytes: 12345,
captured_at: null,
});
dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' });
// ffmpeg thumbnail pipeline throws (e.g. unsupported pixel format)…
videoProcessor.processUploadedVideo.mockRejectedValueOnce(new Error('ffmpeg exited with code 1'));
// …but a plain probe still works.
videoProcessor.extractVideoMetadata.mockResolvedValueOnce({
duration: 42,
videoCodec: 'hevc',
audioCodec: 'aac',
width: 3840,
height: 2160,
});
const { processPhoto } = require('../../src/services/photoProcessor');
await processPhoto(203);
const finalUpdate = dbModule.__recorded().updateCalls.pop();
// The row must complete — 'failed' rows are invisible to guests.
expect(finalUpdate.data.processing_status).toBe('complete');
// Placeholder instead of NULL: a completed video without thumbnail would
// make the grid fetch the original video file for the tile (#845 review).
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_drone-clip.jpg');
expect(imageProcessor.generateVideoPlaceholder).toHaveBeenCalledWith('drone-clip.mp4');
expect(finalUpdate.data.duration).toBe(42);
expect(finalUpdate.data.video_codec).toBe('hevc');
});
it('throws when the photo row no longer exists', async () => {
dbModule.__setPhoto(null);
dbModule.__setEvent({ id: 1 });
@@ -0,0 +1,111 @@
/**
* Regression tests for reinjectCurrentAdmin — the operator-preservation step of
* the .picpeak restore (GHSA-qxfx-4493-4v8f follow-up). Runs against a real
* in-memory SQLite DB so the UNIQUE(email)/UNIQUE(username) constraints behave
* as in production. Reconciliation is non-destructive (update-in-place / rename,
* never delete) so restored rows referenced by FKs keep their ids.
*/
const knex = require('knex');
let db;
let reinjectCurrentAdmin;
beforeAll(() => {
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }), { virtual: false });
reinjectCurrentAdmin = require('../../src/services/picpeakImportService').reinjectCurrentAdmin;
});
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('username').notNullable().unique();
t.string('email').notNullable().unique();
t.string('password_hash');
t.boolean('is_active').defaultTo(true);
t.boolean('must_change_password').defaultTo(false);
t.integer('role_id');
t.integer('created_by');
t.boolean('two_factor_enabled').defaultTo(false);
t.string('two_factor_secret');
t.text('two_factor_recovery_codes');
});
});
afterEach(async () => { await db.destroy(); });
const operator = {
id: 1, username: 'admin', email: 'op@example.com',
password_hash: 'OP_HASH', is_active: 1, must_change_password: 0, role_id: 1, created_by: 99,
two_factor_enabled: 1, two_factor_secret: 'OP_SECRET', two_factor_recovery_codes: '["a","b"]',
};
test('restores login + MFA in place, keeping the row id and its FK columns (FK-safe)', async () => {
await db('admin_users').insert({
id: 7, username: 'someoneelse', email: 'OP@example.com',
password_hash: 'ATTACKER', is_active: 1, must_change_password: 0, role_id: 4, created_by: 5,
two_factor_enabled: 0, two_factor_secret: 'ATTACKER_SECRET', two_factor_recovery_codes: null,
});
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
const rows = await db('admin_users');
expect(rows).toHaveLength(1);
const row = rows[0];
expect(row.id).toBe(7); // id preserved → FK refs hold
expect(row.username).toBe('admin');
expect(row.password_hash).toBe('OP_HASH');
expect(Boolean(row.two_factor_enabled)).toBe(true);
expect(row.two_factor_secret).toBe('OP_SECRET'); // attacker MFA secret gone
expect(row.two_factor_recovery_codes).toBe('["a","b"]');
// Relationship/audit FKs are NOT forced from the operator snapshot (avoids
// dangling role_id/created_by on a cross-instance restore) — the restored
// row keeps its own already-valid values.
expect(row.role_id).toBe(4);
expect(row.created_by).toBe(5);
});
test('renames (not deletes) a different row holding the operator username', async () => {
await db('admin_users').insert({
id: 3, username: 'admin', email: 'other@instance.test',
password_hash: 'OTHER', is_active: 1, role_id: 4,
});
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // the other admin survives (FK-safe)
const other = rows.find((r) => r.id === 3);
expect(other.username).toBe('admin__restored_3'); // renamed, id kept
expect(other.email).toBe('other@instance.test');
const op = rows.find((r) => r.username === 'admin');
expect(op.password_hash).toBe('OP_HASH');
});
test('reconciles email and username colliding with DIFFERENT rows without deleting either', async () => {
await db('admin_users').insert([
{ id: 4, username: 'someoneelse', email: 'op@example.com', password_hash: 'A', role_id: 4 },
{ id: 5, username: 'admin', email: 'other@instance.test', password_hash: 'B', role_id: 4 },
]);
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // both rows survive
const opRow = rows.find((r) => r.id === 4); // email match updated in place
expect(opRow.username).toBe('admin');
expect(opRow.password_hash).toBe('OP_HASH');
const renamed = rows.find((r) => r.id === 5); // username holder renamed, not deleted
expect(renamed.username).toBe('admin__restored_5');
});
test('inserts the operator with a non-colliding id when neither key exists in the backup', async () => {
await db('admin_users').insert({
id: 9, username: 'backupadmin', email: 'backup@instance.test', password_hash: 'B', role_id: 1,
});
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // backup admin untouched
const opRow = rows.find((r) => r.username === 'admin');
expect(opRow.password_hash).toBe('OP_HASH');
expect(opRow.id).toBe(10); // max(9)+1, no collision
expect(opRow.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
});
@@ -0,0 +1,105 @@
/**
* Tests for preserveOperatorRole — re-establishing the operator's authorization
* after a restore replaces the roles / permissions / role_permissions tables.
* Real in-memory SQLite so the joins and inserts behave as in production.
*/
const knex = require('knex');
let db;
let svc;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('roles', (t) => {
t.increments('id');
t.string('name').notNullable().unique();
t.string('display_name');
t.integer('priority').defaultTo(0);
t.boolean('is_system').defaultTo(false);
});
await db.schema.createTable('permissions', (t) => {
t.increments('id');
t.string('name').notNullable().unique();
t.string('display_name');
t.string('category');
});
await db.schema.createTable('role_permissions', (t) => {
t.integer('role_id').notNullable();
t.integer('permission_id').notNullable();
t.primary(['role_id', 'permission_id']);
});
await db.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('email');
t.integer('role_id');
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }));
jest.doMock('../../src/database/db', () => ({ db }));
svc = require('../../src/services/picpeakImportService');
});
afterEach(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
await db.destroy();
});
test('captureOperatorRole returns the role + its permission names', async () => {
await db('roles').insert({ id: 1, name: 'super_admin', display_name: 'Super Admin', priority: 100 });
await db('permissions').insert([
{ id: 1, name: 'events.create', display_name: 'Create', category: 'events' },
{ id: 2, name: 'users.manage', display_name: 'Manage', category: 'users' },
]);
await db('role_permissions').insert([{ role_id: 1, permission_id: 1 }, { role_id: 1, permission_id: 2 }]);
const snap = await svc.captureOperatorRole(1);
expect(snap.role.name).toBe('super_admin');
expect(snap.permissions.sort()).toEqual(['events.create', 'users.manage']);
});
test('preserveOperatorRole binds to a restored role of the same NAME (ids remapped)', async () => {
const snapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
// Simulate post-restore RBAC where super_admin now has a DIFFERENT id.
await db('roles').insert({ id: 7, name: 'super_admin', display_name: 'Super Admin (restored)', priority: 100 });
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, snapshot));
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBe(7); // bound to restored super_admin by name
expect(await db('roles').count({ c: '*' }).first()).toEqual({ c: 1 }); // no duplicate role created
});
test('preserveOperatorRole re-creates the role + grants when the backup omits it', async () => {
const snapshot = {
role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true },
permissions: ['events.create', 'users.manage', 'gone.permission'],
};
// Post-restore RBAC WITHOUT super_admin; only some permissions exist.
await db('roles').insert({ id: 2, name: 'viewer', display_name: 'Viewer', priority: 10 });
await db('permissions').insert([
{ id: 5, name: 'events.create', display_name: 'Create', category: 'events' },
{ id: 6, name: 'users.manage', display_name: 'Manage', category: 'users' },
]);
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, snapshot));
const recreated = await db('roles').where({ name: 'super_admin' }).first();
expect(recreated).toBeTruthy(); // role re-created, not left missing
expect(recreated.id).toBe(3); // max(2)+1
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBe(recreated.id); // operator not locked out / downgraded
const grants = await db('role_permissions').where({ role_id: recreated.id }).pluck('permission_id');
expect(grants.sort()).toEqual([5, 6]); // existing perms re-granted; 'gone.permission' skipped
});
test('preserveOperatorRole no-ops when the operator had no role', async () => {
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, null));
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBeNull();
});
@@ -0,0 +1,51 @@
const fs = require('fs');
const path = require('path');
const {
EXTENSION_TO_MIME,
extensionsToMimeTypes,
} = require('../../src/services/uploadSettings');
const { validateFileType } = require('../../src/utils/fileSecurityUtils');
const RAW_AND_HEIF_TYPES = {
dng: 'image/x-adobe-dng',
heic: 'image/heic',
heif: 'image/heif',
};
function getFrontendExtensionMap() {
const source = fs.readFileSync(
path.join(__dirname, '../../../frontend/src/utils/fileTypes.ts'),
'utf8'
);
const match = source.match(/const EXTENSION_TO_MIME[^=]*= \{([\s\S]*?)\n\};/);
if (!match) throw new Error('Could not find frontend EXTENSION_TO_MIME');
// Parse `key: 'mime',` entries — quoted keys and trailing `//` comments are
// tolerated; any other non-blank, non-comment line inside the map is a parse
// failure, so a syntax the parser can't read fails loudly instead of silently
// dropping the entry from the comparison.
const entries = [];
for (const line of match[1].split('\n')) {
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('//')) continue;
const entry = trimmed.match(/^'?(\w+)'?\s*:\s*'([^']+)'\s*,?\s*(?:\/\/.*)?$/);
if (!entry) throw new Error(`Unparsable EXTENSION_TO_MIME line in frontend fileTypes.ts: "${trimmed}"`);
entries.push([entry[1], entry[2]]);
}
return Object.fromEntries(entries);
}
describe('configured upload file types', () => {
test('supports configured DNG, HEIC, and HEIF uploads', () => {
expect(extensionsToMimeTypes('dng,heic,heif')).toEqual(Object.values(RAW_AND_HEIF_TYPES));
for (const [extension, mimeType] of Object.entries(RAW_AND_HEIF_TYPES)) {
expect(validateFileType(`image.${extension}`, mimeType, [mimeType])).toBe(true);
}
});
test('uses the same extension-to-MIME map as the frontend', () => {
expect(getFrontendExtensionMap()).toEqual(EXTENSION_TO_MIME);
});
});
@@ -0,0 +1,65 @@
/**
* Unit tests for the per-file upload size limit getter (general_max_file_size_mb),
* added so the admin's "Max File Size (MB)" setting applies to guest uploads
* (#613 follow-up — mat1990dj). Real in-memory SQLite app_settings so the
* read/parse/cache path runs exactly as in production.
*/
const knex = require('knex');
let db;
let svc;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.text('setting_value');
t.string('setting_type');
t.timestamp('updated_at');
});
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db }));
svc = require('../../src/services/uploadSettings');
svc.clearMaxFileSizeCache();
});
afterEach(async () => {
jest.dontMock('../../src/database/db');
await db.destroy();
});
async function setLimit(mb) {
await db('app_settings')
.insert({ setting_key: 'general_max_file_size_mb', setting_value: JSON.stringify(mb), setting_type: 'general', updated_at: new Date() })
.onConflict('setting_key').merge({ setting_value: JSON.stringify(mb) });
svc.clearMaxFileSizeCache();
}
test('defaults to 50MB when the setting is absent', async () => {
expect(await svc.getMaxFileSizeMb()).toBe(50);
expect(await svc.getMaxFileSizeBytes()).toBe(50 * 1024 * 1024);
});
test('honours a configured value (e.g. 500MB video)', async () => {
await setLimit(500);
expect(await svc.getMaxFileSizeMb()).toBe(500);
expect(await svc.getMaxFileSizeBytes()).toBe(500 * 1024 * 1024);
});
test('clamps a nonsense value to the default and caps absurd values at the ceiling', async () => {
await setLimit(0);
expect(await svc.getMaxFileSizeMb()).toBe(50); // 0 → default
await setLimit(99_999_999);
expect(await svc.getMaxFileSizeMb()).toBe(svc.MAX_ALLOWED_FILE_SIZE_MB); // ceiling
});
test('caches for the TTL — a mid-window DB change is not seen until the cache is cleared', async () => {
await setLimit(200);
expect(await svc.getMaxFileSizeMb()).toBe(200);
// change the DB but do NOT clear cache
await db('app_settings').where({ setting_key: 'general_max_file_size_mb' }).update({ setting_value: JSON.stringify(300) });
expect(await svc.getMaxFileSizeMb()).toBe(200); // still cached
svc.clearMaxFileSizeCache();
expect(await svc.getMaxFileSizeMb()).toBe(300); // refreshed
});
@@ -0,0 +1,61 @@
/**
* Regression tests for the password-complexity setting read path.
*
* Bug 1 (key mismatch): the settings UI saves the admin's choice as
* `security_password_complexity` (useSettingsState.ts prefixes every
* security field with `security_`), but getPasswordComplexitySettings()
* queried `security_password_complexity_level` — a key nothing writes —
* so the configured level was silently ignored.
*
* Bug 2 (driver shape, codex review of #843): on SQLite the TEXT column
* returns the JSON-stringified value ('"very_strong"'), but on Postgres
* (production default) `setting_value` is a json column and comes back
* already decoded ('very_strong'). A bare JSON.parse throws on the
* decoded shape and the outer catch fell back to 'moderate' — the
* setting stayed unenforced on Postgres even with the right key.
*/
const mockQueriedKeys = [];
let mockStoredValue;
jest.mock('../../src/database/db', () => ({
db: () => ({
where(_col, key) {
mockQueriedKeys.push(key);
return this;
},
first() {
return Promise.resolve(
mockQueriedKeys[mockQueriedKeys.length - 1] === 'security_password_complexity'
? { setting_key: 'security_password_complexity', setting_value: mockStoredValue }
: undefined
);
},
}),
withRetry: (fn) => fn(),
}));
const { getPasswordComplexitySettings } = require('../../src/utils/passwordValidation');
describe('getPasswordComplexitySettings', () => {
beforeEach(() => { mockQueriedKeys.length = 0; });
it('reads the key the settings UI actually writes (SQLite shape: JSON-stringified)', async () => {
mockStoredValue = JSON.stringify('very_strong'); // '"very_strong"'
const level = await getPasswordComplexitySettings();
expect(mockQueriedKeys).toContain('security_password_complexity');
expect(level).toBe('very_strong');
});
it('accepts the Postgres json-column shape (already decoded, no quotes)', async () => {
mockStoredValue = 'very_strong'; // pg driver auto-parses the json column
const level = await getPasswordComplexitySettings();
expect(level).toBe('very_strong');
});
it('falls back to moderate on an empty value', async () => {
mockStoredValue = '';
const level = await getPasswordComplexitySettings();
expect(level).toBe('moderate');
});
});
@@ -0,0 +1,41 @@
const path = require('path');
const { assertZipEntriesWithin } = require('../../src/utils/safePath');
describe('assertZipEntriesWithin (ZIP-slip guard, GHSA-jfhw-fj23-fx6x)', () => {
const root = path.join('/tmp', 'picpeak-extract-root');
it('accepts entries that stay within the extraction root', () => {
const entries = [
{ name: 'photo.jpg' },
{ name: 'category/nested/photo.png' },
{ name: 'photos_manifest.json' },
{ name: 'subdir/' },
];
expect(() => assertZipEntriesWithin(entries, root)).not.toThrow();
});
it('rejects a parent-traversal entry', () => {
const entries = [{ name: '../../uploads/logos/evil.svg' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
it('rejects an absolute-path entry', () => {
const entries = [{ name: '/etc/cron.d/evil' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
it('rejects when a safe entry is mixed with a traversal entry', () => {
const entries = [{ name: 'ok.jpg' }, { name: '../escape.txt' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
it('tolerates empty / nameless entries', () => {
expect(() => assertZipEntriesWithin([{}, { name: '' }, null], root)).not.toThrow();
});
it('does not treat a sibling prefix directory as inside the root', () => {
// root is .../picpeak-extract-root; ../picpeak-extract-root-evil must not pass
const entries = [{ name: '../picpeak-extract-root-evil/x' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
});
@@ -0,0 +1,56 @@
/**
* Unit tests for the global session cutoff (utils/sessionCutoff.js). Uses a
* real in-memory SQLite `app_settings` table so the read/write/parse path is
* exercised exactly as in production.
*/
const knex = require('knex');
let db;
let cutoff;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.text('setting_value');
t.string('setting_type');
t.timestamp('updated_at');
});
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db }));
cutoff = require('../../src/utils/sessionCutoff');
cutoff._resetCache();
});
afterEach(async () => {
jest.dontMock('../../src/database/db');
await db.destroy();
});
test('no cutoff set → nothing is invalidated', async () => {
expect(await cutoff.getSessionsValidAfter()).toBe(0);
expect(await cutoff.isTokenBeforeCutoff({ iat: 1000 })).toBe(false);
});
test('token issued before the cutoff is rejected, at/after is accepted', async () => {
await cutoff.setSessionsValidAfter(2000);
expect(await cutoff.isTokenBeforeCutoff({ iat: 1999 })).toBe(true); // pre-restore session
expect(await cutoff.isTokenBeforeCutoff({ iat: 2000 })).toBe(false); // same second → kept
expect(await cutoff.isTokenBeforeCutoff({ iat: 2001 })).toBe(false); // post-restore login
});
test('setSessionsValidAfter upserts a single row and refreshes the cache', async () => {
await cutoff.setSessionsValidAfter(1000);
await cutoff.setSessionsValidAfter(3000);
const rows = await db('app_settings').where('setting_key', 'security_sessions_valid_after');
expect(rows).toHaveLength(1);
cutoff._resetCache();
expect(await cutoff.getSessionsValidAfter()).toBe(3000);
});
test('a token without iat is never treated as before the cutoff', async () => {
await cutoff.setSessionsValidAfter(2000);
expect(await cutoff.isTokenBeforeCutoff({})).toBe(false);
expect(await cutoff.isTokenBeforeCutoff(null)).toBe(false);
});
@@ -0,0 +1,37 @@
/**
* Migration 158: per-event slideshow ordering + category filter (#202).
*
* - `show_order` — 'chronological' (default, upload order) | 'random'
* (client-side shuffle). Lets the Live Slideshow play
* photos in a varied order during an event.
* - `show_category_id`— optional FK into `photo_categories`. When set, the
* slideshow only shows photos in that category (NULL =
* all visible photos, the existing behaviour).
*
* Both additive + guarded. Defaults preserve today's behaviour (chronological,
* all photos), so existing slideshows are unchanged.
*/
exports.up = async function up(knex) {
const hasOrder = await knex.schema.hasColumn('events', 'show_order');
if (!hasOrder) {
await knex.schema.alterTable('events', (t) => {
t.string('show_order', 20).defaultTo('chronological');
});
}
const hasCat = await knex.schema.hasColumn('events', 'show_category_id');
if (!hasCat) {
await knex.schema.alterTable('events', (t) => {
t.integer('show_category_id').nullable();
});
}
};
exports.down = async function down(knex) {
for (const col of ['show_order', 'show_category_id']) {
// eslint-disable-next-line no-await-in-loop
if (await knex.schema.hasColumn('events', col)) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable('events', (t) => t.dropColumn(col));
}
}
};
@@ -0,0 +1,57 @@
/**
* Migration 159: per-event category ordering (#782).
*
* Adds a `display_order` integer to `photo_categories` so photographers can
* arrange an event's categories in the flow of the day (Pre-Ceremony →
* Ceremony → Reception …) instead of the hard-coded AZ order. Mirrors the
* `display_order` column + reorder pattern already used by `event_types`.
*
* Preserve existing galleries: backfill `display_order` from the CURRENT
* (alphabetical) order, scoped — globals numbered together, event-specific
* numbered per event — so nothing reshuffles on upgrade. A custom order is
* opt-in via the admin reorder controls. See feedback: migrations should pin
* previously-implicit defaults onto existing rows.
*
* Backfill runs in JS (not a SQL window function) to stay portable across
* SQLite (dev) and Postgres (prod).
*
* Additive + hasColumn-guarded.
*/
async function addColumn(knex, table, column, builder) {
if (!(await knex.schema.hasColumn(table, column))) {
await knex.schema.alterTable(table, builder);
}
}
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
await addColumn(knex, 'photo_categories', 'display_order', (t) => {
t.integer('display_order').notNullable().defaultTo(0);
t.index('display_order');
});
// Backfill from the current alphabetical order, per scope, so existing
// galleries render exactly as before until an admin reorders.
const cats = await knex('photo_categories')
.select('id', 'name', 'is_global', 'event_id')
.orderBy('name', 'asc');
const counters = {};
for (const c of cats) {
const scope = c.is_global ? 'global' : `event:${c.event_id}`;
counters[scope] = (counters[scope] || 0) + 1;
await knex('photo_categories')
.where('id', c.id)
.update({ display_order: counters[scope] });
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
if (await knex.schema.hasColumn('photo_categories', 'display_order')) {
await knex.schema.alterTable('photo_categories', (t) =>
t.dropColumn('display_order')
);
}
};
@@ -0,0 +1,46 @@
/**
* Migration 160: per-event category order override (#782).
*
* Builds on migration 159 (photo_categories.display_order = the GLOBAL default
* order) by adding a per-event OVERRIDE layer. Global categories are shared
* across every event, so a single display_order can only express one order for
* them. This table lets a single gallery arrange its categories — globals AND
* event-specific, interleaved into the flow of the day — independently of the
* global default.
*
* Resolution (see adminCategories / gallery):
* 1. if the event has override rows -> use override.position;
* 2. else fall back to photo_categories.display_order (the global default);
* 3. else name.
*
* An event is either "using the default" (no rows here) or "customised" (a row
* per category it shows). No backfill: every existing event starts on the
* default order, so nothing reshuffles — a custom order is opt-in per event.
*
* Additive + hasTable-guarded.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
if (await knex.schema.hasTable('event_category_order')) return;
await knex.schema.createTable('event_category_order', (t) => {
t.increments('id').primary();
t.integer('event_id').notNullable()
.references('id').inTable('events').onDelete('CASCADE');
t.integer('category_id').notNullable()
.references('id').inTable('photo_categories').onDelete('CASCADE');
t.integer('position').notNullable().defaultTo(0);
t.timestamp('created_at').defaultTo(knex.fn.now());
// At most one position per (event, category).
t.unique(['event_id', 'category_id']);
// Ordered reads are always scoped to one event.
t.index(['event_id', 'position']);
});
};
exports.down = async function (knex) {
if (await knex.schema.hasTable('event_category_order')) {
await knex.schema.dropTable('event_category_order');
}
};
@@ -0,0 +1,43 @@
/**
* Migration 161: `setup_wizard_completed` app setting (#800).
*
* The setup wizard gains an event-types step that may rename or DELETE the
* seeded system event types. That is only safe on a pristine install, so the
* backend gates system-type deletion on this flag being unset (plus zero
* usage — see eventTypeService.deleteEventType).
*
* Backfill rule: any install that already has an admin account predates the
* wizard step (or already finished the wizard), so it is marked completed
* here — the deletion window never opens on existing setups. A genuinely
* fresh install runs this migration BEFORE its first admin is created, so
* the flag starts false and the wizard's finish call flips it to true.
*
* Idempotent: skips when the key already exists. Values are JSON-stringified
* to match getAppSetting's JSON.parse on read.
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
const existing = await knex('app_settings')
.where({ setting_key: 'setup_wizard_completed' })
.first();
if (existing) return;
let hasAdmin = false;
if (await knex.schema.hasTable('admin_users')) {
const row = await knex('admin_users').count({ c: '*' }).first();
hasAdmin = Number(row?.c || 0) > 0;
}
await knex('app_settings').insert({
setting_key: 'setup_wizard_completed',
setting_value: JSON.stringify(hasAdmin),
setting_type: 'boolean',
updated_at: new Date(),
});
};
exports.down = async function down(knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
await knex('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
};
@@ -0,0 +1,51 @@
/**
* Migration 162: OIDC identity binding for admin users (#798).
*
* - `auth_provider` — 'local' (default) or 'oidc'. Which authority owns the
* account's credentials.
* - `external_issuer` — the validated `iss` of the IdP that owns the subject.
* OIDC only guarantees `sub` uniqueness WITHIN an
* issuer, so bindings match on (iss, sub) — otherwise
* switching `oidc_issuer_url` could map a new
* provider's user onto an old provider's admin when
* their subjects collide.
* - `external_subject` — the IdP's stable subject identifier (OIDC `sub`).
* SSO logins match on (external_issuer,
* external_subject), NEVER on email alone —
* email-matching is an account-takeover vector with
* IdPs that don't verify addresses. Nullable: local
* accounts have neither.
*
* Composite unique index so one IdP identity can't map to two admin rows.
* Additive + guarded; existing rows keep working untouched ('local', NULL).
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasColumn('admin_users', 'auth_provider'))) {
await knex.schema.alterTable('admin_users', (t) => {
t.string('auth_provider', 20).notNullable().defaultTo('local');
});
}
if (!(await knex.schema.hasColumn('admin_users', 'external_issuer'))) {
await knex.schema.alterTable('admin_users', (t) => {
t.string('external_issuer', 512).nullable();
});
}
if (!(await knex.schema.hasColumn('admin_users', 'external_subject'))) {
await knex.schema.alterTable('admin_users', (t) => {
t.string('external_subject', 255).nullable();
t.unique(['external_issuer', 'external_subject'], {
indexName: 'admin_users_issuer_subject_unique',
});
});
}
};
exports.down = async function down(knex) {
for (const col of ['external_subject', 'external_issuer', 'auth_provider']) {
// eslint-disable-next-line no-await-in-loop
if (await knex.schema.hasColumn('admin_users', col)) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable('admin_users', (t) => t.dropColumn(col));
}
}
};
+64 -4
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "3.80.0-beta.0",
"version": "3.92.1-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "3.80.0-beta.0",
"version": "3.92.1-beta.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -40,7 +40,9 @@
"node-cron": "^3.0.2",
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
"openid-client": "^5.7.1",
"otplib": "^12.0.1",
"p-limit": "^3.1.0",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
@@ -7905,6 +7907,15 @@
"@sideway/pinpoint": "^2.0.0"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jpeg-exif": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
@@ -9330,6 +9341,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -9342,6 +9362,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -9404,6 +9433,39 @@
"license": "MIT",
"peer": true
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/openid-client/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -9437,7 +9499,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
@@ -12450,7 +12511,6 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.45.0",
"version": "3.92.2-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -11,6 +11,7 @@
"generate:watermarks": "node scripts/generate-watermarks.js",
"test": "jest",
"test:s3": "SKIP_S3_TESTS=false jest __tests__/integration/backup-s3",
"test:pg": "jest __tests__/integration/picpeakRestorePg",
"lint": "eslint src/"
},
"dependencies": {
@@ -46,7 +47,9 @@
"node-cron": "^3.0.2",
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
"openid-client": "^5.7.1",
"otplib": "^12.0.1",
"p-limit": "^3.1.0",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
+1 -3
View File
@@ -38,7 +38,6 @@ const {
// Import routes
const authRoutes = require('./src/routes/auth');
const eventRoutes = require('./src/routes/events');
const galleryRoutes = require('./src/routes/gallery');
const adminRoutes = require('./src/routes/admin');
const adminAuthRoutes = require('./src/routes/adminAuth');
@@ -695,8 +694,7 @@ app.get('/health', async (req, res) => {
// Routes
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
app.use('/api/auth', authRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
// Gallery routes - main routes first, then feedback routes
app.use('/api/gallery', galleryRoutes);
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
@@ -33,6 +33,13 @@ jest.mock('../utils/tokenRevocation', () => ({
isTokenRevoked: jest.fn(),
}));
// The global session cutoff (added for .picpeak restore invalidation) queries
// app_settings; stub it to "no cutoff" so it doesn't consume this suite's
// one-shot db() mock. Its own behaviour is covered by utils/sessionCutoff.test.js.
jest.mock('../utils/sessionCutoff', () => ({
isTokenBeforeCutoff: jest.fn().mockResolvedValue(false),
}));
jest.mock('../utils/tokenUtils', () => ({
getCustomerTokenFromRequest: jest.fn(),
}));
+19 -1
View File
@@ -2,6 +2,7 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
@@ -38,6 +39,13 @@ async function adminAuth(req, res, next) {
});
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject any session issued before the global cutoff (set by a .picpeak
// restore, which can reassign admin ids). Forces every pre-restore admin
// session to re-authenticate against the restored data.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Verify token type
if (decoded.type !== 'admin') {
@@ -157,7 +165,12 @@ async function galleryAuth(req, res, next) {
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Verify token type
if (decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid access token' });
@@ -221,6 +234,11 @@ async function photoAuth(req, res, next) {
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Allow both admin and gallery tokens
if (decoded.type === 'admin') {
const admin = await db('admin_users')
+6
View File
@@ -13,6 +13,7 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const logger = require('../utils/logger');
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
@@ -61,6 +62,11 @@ async function customerAuth(req, res, next) {
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
if (decoded.type !== 'customer') {
logger.warn('[customerAuth] wrong token type', {
url: req.originalUrl,
+9
View File
@@ -73,6 +73,15 @@ async function maintenanceMiddleware(req, res, next) {
// entries here matched nothing, which is exactly why the lockout happened).
const skipPaths = [
'/api/auth/admin/login',
// The second factor is part of the same login — without this, any
// MFA-enrolled admin gets a 503 on the verify step and cannot sign in
// at all while maintenance mode is on.
'/api/auth/admin/login/mfa',
// SSO variants of the admin login (#798) — same reasoning: an SSO-only
// (JIT-provisioned) admin has no password, so blocking these would make
// maintenance mode admin-proof for them.
'/api/auth/admin/sso/login',
'/api/auth/admin/sso/callback',
'/api/auth/session',
'/api/public/settings',
'/health'
+11
View File
@@ -9,6 +9,7 @@ const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
const StreamZip = require('node-stream-zip');
const { requireEventOwnership } = require('../middleware/ownership');
const { assertZipEntriesWithin } = require('../utils/safePath');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
const router = express.Router();
@@ -183,6 +184,16 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
const entries = Object.values(await zip.entries());
logger.info(`Archive contains ${entries.length} entries`);
// Reject ZIP-slip entries before writing anything to disk — extract()
// does not neutralise `../` in entry names (GHSA-jfhw-fj23-fx6x).
try {
assertZipEntriesWithin(entries, eventDir);
} catch (slipErr) {
await zip.close();
logger.warn(`Refusing archive restore — unsafe entry path: ${slipErr.message}`);
return res.status(400).json({ error: 'Archive contains invalid entry paths' });
}
// Stream-extract everything to disk
await zip.extract(null, eventDir);
await zip.close();
+43 -2
View File
@@ -2,6 +2,8 @@ const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
const { revokeToken } = require('../utils/tokenRevocation');
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
const logger = require('../utils/logger');
const { errorResponse, getPagination } = require('../utils/routeHelpers');
@@ -29,7 +31,13 @@ router.get('/config', adminAuth, requirePermission('backup.view'), async (req, r
config[setting.setting_key] = setting.setting_value;
}
});
// Never return the stored credentials — mask like the email/WhatsApp
// config endpoints do. The PUT below skips the mask sentinel, so the
// form round-trips without clobbering the real values.
if (config.backup_s3_secret_key) config.backup_s3_secret_key = '••••••••';
if (config.backup_rsync_ssh_key) config.backup_rsync_ssh_key = '••••••••';
res.json(config);
} catch (error) {
errorResponse(res, error, 500, 'Failed to get backup configuration');
@@ -65,6 +73,11 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
// Update settings
for (const [key, value] of Object.entries(updates)) {
// An unchanged secret round-trips as the GET mask sentinel — keep the
// stored value instead of overwriting it with bullets.
if (value === '••••••••') {
continue;
}
if (key.startsWith('backup_')) {
await db('app_settings')
.insert({
@@ -178,12 +191,40 @@ router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), p
const picpeakPath = req.file.path;
try {
const { importFromPicpeak } = require('../services/picpeakImportService');
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.user && req.user.id });
// adminAuth populates req.admin, not req.user. Passing req.user.id here
// left currentAdminId undefined, so reinjectCurrentAdmin() had no account
// to preserve and the admin_users table was fully replaced by the backup —
// letting a crafted .picpeak take over every admin account (GHSA-qxfx-4493-4v8f).
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.admin && req.admin.id });
// The restore rewrote admin_users, so ids may have shifted. importFromPicpeak
// already stamped a GLOBAL session cutoff (see setSessionsValidAfter), so
// every JWT issued before the restore — admin, customer, gallery — now fails
// auth. Here we additionally give the importing admin an immediate, clean
// logout: revoke this token and clear the cookie so their browser drops the
// session at once rather than on the next 401. Cookie clear is the
// unconditional guarantee; revokeToken() swallows DB errors and returns
// false, so check the result and log loudly if the denylist write didn't
// land (the operator still re-logs-in, which the cookie clear forces).
let tokenRevoked = false;
try {
if (req.token) {
tokenRevoked = await revokeToken(req.token, 'picpeak-import', { adminId: req.admin && req.admin.id });
}
} catch (revokeErr) {
logger.warn('[picpeak-import] failed to revoke session token after restore', { error: revokeErr.message });
}
if (req.token && !tokenRevoked) {
logger.warn('[picpeak-import] session token was NOT added to the revocation denylist after restore; relying on cookie clear to force re-login');
}
clearAdminAuthCookie(res);
res.json({
success: true,
tables: result.tables,
filesRestored: result.filesRestored,
usesExternalMedia: result.usesExternalMedia,
sessionInvalidated: true,
});
} catch (error) {
const status = error.statusCode || 500;
+154 -14
View File
@@ -4,6 +4,8 @@ const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const logger = require('../utils/logger');
const router = express.Router();
@@ -12,8 +14,9 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
try {
const categories = await db('photo_categories')
.where('is_global', formatBoolean(true))
.orderBy('display_order', 'asc')
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
logger.error('Error fetching categories:', error);
@@ -21,19 +24,12 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
}
});
// Get categories for a specific event (global + event-specific)
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
// Get categories for a specific event (global + event-specific), resolved to
// the event's effective order: per-event override, else global default, else
// name (#782). Each row carries `override_position` (null when not customised).
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
const categories = await db('photo_categories')
.where(function() {
this.where('is_global', formatBoolean(true))
.orWhere('event_id', eventId);
})
.orderBy('is_global', 'desc')
.orderBy('name', 'asc');
const categories = await getEventCategoriesOrdered(req.params.eventId);
res.json(categories);
} catch (error) {
logger.error('Error fetching event categories:', error);
@@ -81,12 +77,27 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
return res.status(400).json({ error: 'Category with this slug already exists' });
}
// Append to the end of its scope so a new category doesn't jump to the
// top of an admin-defined order (#782).
const maxRow = await db('photo_categories')
.where(function() {
if (is_global) {
this.where('is_global', formatBoolean(true));
} else {
this.where('event_id', event_id);
}
})
.max('display_order as maxOrder')
.first();
const nextOrder = (maxRow?.maxOrder || 0) + 1;
// Create category
const insertResult = await db('photo_categories').insert({
name,
slug: categorySlug,
is_global,
event_id: is_global ? null : event_id
event_id: is_global ? null : event_id,
display_order: nextOrder
}).returning('id');
const categoryId = insertResult[0]?.id || insertResult[0];
@@ -254,4 +265,133 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
}
});
// Set a per-event category order override (#782). The client sends the full
// ordered id list for THIS event — globals + event-specific, interleaved — and
// we replace the event's override rows in one transaction. This overrides the
// global default order for this gallery only.
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
body('event_id').isInt().withMessage('event_id must be an integer'),
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const eventId = parseInt(req.body.event_id, 10);
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
// Event ownership (event_id comes from the body, so requireEventOwnership —
// which reads req.params — can't be used here). Mirror it: super_admins
// bypass; other admins may only reorder events they own (ownerless
// legacy/system events allowed).
if (req.admin.roleName !== 'super_admin') {
const event = await db('events').where('id', eventId).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (event.created_by && event.created_by !== req.admin.id) {
return res.status(403).json({ error: 'Access denied' });
}
}
// Every id must be a category available to this event: a shared global OR
// one of the event's own categories. Anything else is out of scope.
const available = await db('photo_categories')
.where(function() {
this.where('is_global', formatBoolean(true)).orWhere('event_id', eventId);
})
.pluck('id');
const availableSet = new Set(available);
const invalid = orderedIds.filter((id) => !availableSet.has(id));
if (invalid.length > 0) {
return res.status(400).json({ error: 'One or more categories are not available for this event' });
}
await db.transaction(async (trx) => {
await trx('event_category_order').where('event_id', eventId).del();
await trx('event_category_order').insert(
orderedIds.map((id, i) => ({ event_id: eventId, category_id: id, position: i + 1 }))
);
});
// Log activity after commit (avoids a SQLite in-transaction global write).
await logActivity('event_category_order_set',
{ eventId, count: orderedIds.length },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(await getEventCategoriesOrdered(eventId));
} catch (error) {
logger.error('Error reordering categories:', error);
res.status(500).json({ error: 'Failed to reorder categories' });
}
});
// Clear an event's override — revert this gallery to the global default order.
router.delete('/reorder/:eventId', adminAuth, requirePermission('settings.edit'), requireEventOwnership, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId, 10);
await db('event_category_order').where('event_id', eventId).del();
await logActivity('event_category_order_reset',
{ eventId },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(await getEventCategoriesOrdered(eventId));
} catch (error) {
logger.error('Error resetting category order:', error);
res.status(500).json({ error: 'Failed to reset category order' });
}
});
// Set the GLOBAL default order for shared (global) categories (#782). Applies
// to every gallery that hasn't set its own override. Rewrites display_order.
router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
const globals = await db('photo_categories').where('is_global', formatBoolean(true)).pluck('id');
const globalsSet = new Set(globals);
const invalid = orderedIds.filter((id) => !globalsSet.has(id));
if (invalid.length > 0) {
return res.status(400).json({ error: 'One or more categories are not global' });
}
await db.transaction(async (trx) => {
for (let i = 0; i < orderedIds.length; i += 1) {
await trx('photo_categories').where('id', orderedIds[i]).update({ display_order: i + 1 });
}
});
await logActivity('global_category_order_set',
{ count: orderedIds.length },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
const categories = await db('photo_categories')
.where('is_global', formatBoolean(true))
.orderBy('display_order', 'asc')
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
logger.error('Error reordering global categories:', error);
res.status(500).json({ error: 'Failed to reorder global categories' });
}
});
module.exports = router;
+2 -2
View File
@@ -178,7 +178,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
if (error.code === 'NOT_FOUND') {
return res.status(404).json({ error: error.message });
}
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
if (error.code === 'DUPLICATE_SLUG_PREFIX' || error.code === 'LAST_ACTIVE') {
return res.status(400).json({ error: error.message });
}
@@ -216,7 +216,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
if (error.code === 'NOT_FOUND') {
return res.status(404).json({ error: error.message });
}
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') {
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE' || error.code === 'LAST_TYPE') {
return res.status(400).json({ error: error.message });
}
+53 -4
View File
@@ -94,7 +94,7 @@ module.exports = (router) => {
body('allow_presigned_download').optional().isBoolean(),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
@@ -342,8 +342,10 @@ module.exports = (router) => {
// hero_logo_visible: store NULL ("inherit") unless the admin explicitly
// set it, so the global branding_logo_display_hero toggle keeps
// controlling this gallery afterwards (#756). Only an explicit per-event
// choice overrides the global.
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined
// choice overrides the global. `!= null` treats an explicit null the same
// as omitted (both → inherit); otherwise formatBoolean(null) would coerce
// to 0/false on SQLite instead of NULL (the PUT handler already does this).
const effectiveHeroLogoVisible = req.body.hero_logo_visible != null
? formatBoolean(hero_logo_visible)
: null;
// NULL = inherit the global branding_logo_size (#756), resolved at read
@@ -1224,7 +1226,7 @@ module.exports = (router) => {
}),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
@@ -1596,4 +1598,51 @@ module.exports = (router) => {
}
});
// Extend a gallery's expiration. Migrated from the legacy /api/events router
// (removed — GHSA-4j34-x562-5vfq), now on the canonical mount with the same
// permission + ownership guards as every other gallery mutation, so a
// non-owning editor/viewer can no longer touch a gallery they don't own.
router.post('/:id/extend', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('days').isInt({ min: 1, max: 365 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { days } = req.body;
let eventQuery = db('events').where('id', id);
// Editor role can only touch their own events (defence in depth alongside
// requireEventOwnership).
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const newExpiration = new Date(event.expires_at);
newExpiration.setDate(newExpiration.getDate() + days);
await db('events').where('id', id).update({
expires_at: newExpiration,
is_active: formatBoolean(true) // reactivate if it had expired
});
await logActivity('event_expiration_extended',
{ eventName: event.event_name, days },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ expires_at: newExpiration });
} catch (error) {
errorResponse(res, error, 500, 'Failed to extend expiration');
}
});
};
@@ -307,6 +307,9 @@ async function deleteEventCascade(eventId, adminContext) {
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
// Allowed per-slide color filters.
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
// Allowed slideshow play orders (#202). 'chronological' = upload order,
// 'random' = client-side shuffle.
const SLIDESHOW_ORDERS = ['chronological', 'random'];
module.exports = {
validateHeroImageAnchor,
getStoragePath,
@@ -321,6 +324,7 @@ module.exports = {
mapEventForApi,
hasCustomerContactColumns,
deleteEventCascade,
SLIDESHOW_ORDERS,
SLIDESHOW_TRANSITIONS,
SLIDESHOW_COLORFILTERS,
};
+24 -3
View File
@@ -13,7 +13,7 @@ const { parseBooleanInput } = require('../../utils/parsers');
const { requireEventOwnership } = require('../../middleware/ownership');
const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag');
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS, SLIDESHOW_ORDERS } = require('./helpers');
// The watermark LOOK (source/position/opacity/style/size) is global-only
// (app_settings, Settings → Slideshow); events only carry the show_watermark
@@ -105,7 +105,9 @@ module.exports = (router) => {
body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS),
body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }),
body('show_watermark').optional({ nullable: true }),
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS)
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS),
body('show_order').optional().isIn(SLIDESHOW_ORDERS),
body('show_category_id').optional({ nullable: true }).isInt({ min: 1 })
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -130,6 +132,23 @@ module.exports = (router) => {
: formatBoolean(parseBooleanInput(req.body.show_watermark, false));
}
if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter;
if (req.body.show_order !== undefined) updates.show_order = req.body.show_order;
// Category filter (#202). null clears it (all photos). A non-null id must
// belong to this event or be a global category — otherwise ignore it so a
// stale/foreign id can't leak another event's category selection.
if (req.body.show_category_id !== undefined) {
if (req.body.show_category_id === null) {
updates.show_category_id = null;
} else {
const catId = parseInt(req.body.show_category_id, 10);
const cat = await db('photo_categories')
.where({ id: catId })
.where(function () { this.where('event_id', event.id).orWhere('is_global', formatBoolean(true)); })
.first();
if (!cat) return res.status(400).json({ error: 'Category does not belong to this event' });
updates.show_category_id = catId;
}
}
// Knex throws on an empty update; only write if something changed.
if (Object.keys(updates).length > 0) {
@@ -141,7 +160,9 @@ module.exports = (router) => {
show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade',
show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800,
show_watermark: updates.show_watermark ?? event.show_watermark ?? null,
show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none'
show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none',
show_order: updates.show_order ?? event.show_order ?? 'chronological',
show_category_id: 'show_category_id' in updates ? updates.show_category_id : (event.show_category_id ?? null)
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to update slideshow settings');
+194 -5
View File
@@ -25,12 +25,28 @@ const { resetSecurityConfigCache } = require('../utils/authSecurity');
const { errorResponse } = require('../utils/routeHelpers');
const logger = require('../utils/logger');
const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD, clearMaxFileSizeCache, MAX_ALLOWED_FILE_SIZE_MB } = require('../services/uploadSettings');
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Reserved first-run bootstrap keys — never writable through the generic
// settings upserts in this file: setup_wizard_completed is a one-way marker
// (#800; writing false would reopen system-event-type deletion) and
// setup_token is the first-run bootstrap secret. Every handler that loops
// arbitrary request keys into app_settings must strip these first.
// oidc_client_secret is reserved too: it is AES-encrypted at rest and only
// writable through PUT /sso below — a generic upsert would store plaintext
// and break decryption (#798).
const RESERVED_SETTING_KEYS = ['setup_wizard_completed', 'setup_token', 'oidc_client_secret'];
const stripReservedSettingKeys = (settings) => {
for (const key of RESERVED_SETTING_KEYS) {
delete settings[key];
}
return settings;
};
// Configure multer for logo uploads
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
@@ -143,10 +159,28 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
}
});
// Reserved bootstrap/credential keys are NEVER readable through the
// generic settings reads — oidc_client_secret (#798) is stored encrypted
// with setting_type 'string' and would otherwise leak its ciphertext to
// any settings.view holder; setup_token is the first-run bootstrap secret.
for (const key of RESERVED_SETTING_KEYS) {
delete settingsObject[key];
}
// Mask sensitive secrets before sending to client
if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••';
}
// Backup credentials — the S3 secret key and the rsync SSH PRIVATE KEY
// were returned in plaintext to any settings.view holder. Same masking
// pattern as the recaptcha/umami/rybbit keys; the dedicated
// /admin/backup/config endpoints handle the edit round-trip.
if (settingsObject.backup_s3_secret_key) {
settingsObject.backup_s3_secret_key = '••••••••';
}
if (settingsObject.backup_rsync_ssh_key) {
settingsObject.backup_rsync_ssh_key = '••••••••';
}
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
// outbound calls to the operator's Umami instance for the device
// breakdown. Masked on GET, same pattern as the recaptcha secret.
@@ -363,6 +397,124 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r
});
// Get settings by type
// ──────────────────────────────────────────────────────────────────────────
// OIDC SSO settings (#798). Dedicated endpoints — NOT the generic upsert —
// because the client secret must be encrypted at rest and never echoed back.
// ──────────────────────────────────────────────────────────────────────────
// Read the SSO config. The secret is redacted to a set/unset flag; the
// computed redirect URI is included for copy-paste into the IdP client.
router.get('/sso', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const oidcService = require('../services/oidcService');
const cfg = await oidcService.getOidcConfig();
// No public base URL configured → surface an empty redirect_uri rather
// than failing the whole settings read; the login route refuses to start
// the flow in that state anyway (OIDC_BAD_CONFIG).
const redirectUri = await oidcService.getRedirectUri().catch(() => '');
res.json({
oidc_enabled: cfg.enabled,
oidc_issuer_url: cfg.issuerUrl || '',
oidc_client_id: cfg.clientId || '',
oidc_client_secret_set: Boolean(cfg.clientSecret),
oidc_autoprovision: cfg.autoprovision,
oidc_default_role: cfg.defaultRole,
oidc_button_label: cfg.buttonLabel || '',
oidc_scopes: cfg.scopes,
redirect_uri: redirectUri,
});
} catch (error) {
logger.error('Failed to read SSO settings', { error: error.message });
res.status(500).json({ error: 'Failed to read SSO settings' });
}
});
router.put('/sso', adminAuth, requirePermission('settings.edit'), [
body('oidc_enabled').optional().isBoolean(),
body('oidc_issuer_url').optional({ checkFalsy: true }).isURL({ protocols: ['http', 'https'], require_tld: false }),
body('oidc_client_id').optional().isString().trim(),
body('oidc_client_secret').optional().isString(),
body('oidc_autoprovision').optional().isBoolean(),
body('oidc_default_role').optional().isString().trim(),
body('oidc_button_label').optional().isString().trim().isLength({ max: 60 }),
body('oidc_scopes').optional().isString().trim(),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const oidcService = require('../services/oidcService');
// Validate the MERGED resulting state, not just the request: enabling
// requires a complete config, and a partial PUT must not be able to
// blank the issuer/client while a stored enabled=true keeps a login
// button alive that can only fail.
const current = await oidcService.getOidcConfig();
const effectiveEnabled = req.body.oidc_enabled ?? current.enabled;
if (effectiveEnabled === true) {
const issuer = req.body.oidc_issuer_url ?? current.issuerUrl;
const clientId = req.body.oidc_client_id ?? current.clientId;
const secretPresent = (typeof req.body.oidc_client_secret === 'string' && req.body.oidc_client_secret.length > 0)
|| Boolean(current.clientSecret);
if (!issuer || !clientId || !secretPresent) {
return res.status(400).json({ error: 'Issuer URL, client ID and client secret must be configured while SSO is enabled — disable SSO first to clear them' });
}
// The redirect URI must be derivable too, or the login button leads
// straight to an error (needs API_URL / FRONTEND_URL / general_site_url).
try {
await oidcService.getRedirectUri();
} catch (err) {
return res.status(400).json({ error: err.message });
}
}
// Default role must exist — a typo here would brick JIT provisioning.
if (req.body.oidc_default_role !== undefined) {
const role = await db('roles').where('name', req.body.oidc_default_role).first();
if (!role) {
return res.status(400).json({ error: `Unknown role: ${req.body.oidc_default_role}` });
}
}
await oidcService.saveOidcSettings(req.body);
await logActivity('sso_settings_updated',
{ changes: Object.keys(req.body).filter((k) => k !== 'oidc_client_secret') },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'SSO settings saved' });
} catch (error) {
logger.error('Failed to save SSO settings', { error: error.message });
res.status(500).json({ error: 'Failed to save SSO settings' });
}
});
// Server-side discovery probe: confirms the issuer is reachable and speaks
// OIDC before the admin flips the enable toggle. Uses the SAVED config.
router.post('/sso/test', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const oidcService = require('../services/oidcService');
const cfg = await oidcService.getOidcConfig();
if (!oidcService.isConfigured(cfg)) {
return res.status(400).json({ ok: false, error: 'Issuer URL, client ID and client secret must be saved first' });
}
oidcService.invalidateDiscoveryCache();
const { issuerMetadata } = await oidcService.getClient(cfg);
res.json({
ok: true,
issuer: issuerMetadata.issuer,
authorization_endpoint: issuerMetadata.authorization_endpoint,
token_endpoint: issuerMetadata.token_endpoint,
});
} catch (error) {
logger.warn('SSO discovery test failed', { error: error.message });
res.status(400).json({ ok: false, error: `Discovery failed: ${error.message}` });
}
});
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { type } = req.params;
@@ -393,10 +545,28 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
}
});
// Reserved bootstrap/credential keys are NEVER readable through the
// generic settings reads — oidc_client_secret (#798) is stored encrypted
// with setting_type 'string' and would otherwise leak its ciphertext to
// any settings.view holder; setup_token is the first-run bootstrap secret.
for (const key of RESERVED_SETTING_KEYS) {
delete settingsObject[key];
}
// Mask sensitive secrets before sending to client
if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••';
}
// Backup credentials — the S3 secret key and the rsync SSH PRIVATE KEY
// were returned in plaintext to any settings.view holder. Same masking
// pattern as the recaptcha/umami/rybbit keys; the dedicated
// /admin/backup/config endpoints handle the edit round-trip.
if (settingsObject.backup_s3_secret_key) {
settingsObject.backup_s3_secret_key = '••••••••';
}
if (settingsObject.backup_rsync_ssh_key) {
settingsObject.backup_rsync_ssh_key = '••••••••';
}
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
// outbound calls to the operator's Umami instance for the device
// breakdown. Masked on GET, same pattern as the recaptcha secret.
@@ -906,7 +1076,7 @@ router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req,
// Update general settings
router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = { ...req.body };
const settings = stripReservedSettingKeys({ ...req.body });
let uploadLimitTouched = false;
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
@@ -925,6 +1095,24 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
settings.general_max_files_per_upload = normalizedValue;
}
// Per-file size limit (MB). Validate/clamp on save, mirroring the count
// above, so an out-of-range value can't be persisted — otherwise the public
// endpoint would advertise the raw value while getMaxFileSizeMb() normalizes
// it, and the guest UI would reject files the backend actually accepts.
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_file_size_mb')) {
uploadLimitTouched = true;
const rawValue = Number(settings.general_max_file_size_mb);
const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILE_SIZE_MB) {
return res.status(400).json({
error: `general_max_file_size_mb must be an integer between 1 and ${MAX_ALLOWED_FILE_SIZE_MB}`
});
}
settings.general_max_file_size_mb = normalizedValue;
}
if (publicSiteKeysTouched) {
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
@@ -981,6 +1169,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
}
if (uploadLimitTouched) {
clearMaxFilesPerUploadCache();
clearMaxFileSizeCache();
}
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
clearShareLinkSettingsCache();
@@ -1017,7 +1206,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
// Update security settings
router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = req.body;
const settings = stripReservedSettingKeys({ ...req.body });
// Update or insert each setting
for (const [key, value] of Object.entries(settings)) {
@@ -1055,7 +1244,7 @@ router.put('/security', adminAuth, requirePermission('settings.edit'), async (re
// Update analytics settings
router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = req.body;
const settings = stripReservedSettingKeys({ ...req.body });
// Validate the provider switch (#663 Phase 1). Reject unknown values
// so the dashboard route's factory doesn't have to defensively guard.
@@ -1110,7 +1299,7 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
// Update SEO settings
router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = req.body;
const settings = stripReservedSettingKeys({ ...req.body });
// Validate seo_blocked_ai_agents is an array of strings
if (settings.seo_blocked_ai_agents !== undefined) {
+194 -20
View File
@@ -42,9 +42,20 @@ const router = express.Router();
* both produce an identical session. `lockoutKey` is the identifier the user
* typed (username or email) so success/failure tracking stays in one bucket.
*/
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
async function establishAdminSession(res, admin, ipAddress, userAgent, lockoutKey) {
await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
// A normal login means the first-run wizard is over — the wizard never hits
// this route (setup sets its cookie directly). Close the system-event-type
// deletion window durably even when the wizard was abandoned mid-way (#800).
// Best-effort: a failure here must never block a login.
try {
const setupService = require('../services/setupService');
if (!(await setupService.isSetupWizardCompleted())) {
await setupService.markSetupWizardCompleted();
}
} catch (_) { /* best-effort */ }
await db('admin_users').where('id', admin.id).update({
last_login: new Date(),
last_login_ip: ipAddress
@@ -64,18 +75,21 @@ async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockout
setAdminAuthCookie(res, token);
return res.json({
user: {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
}
});
return {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
};
}
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
const user = await establishAdminSession(res, admin, ipAddress, userAgent, lockoutKey);
return res.json({ user });
}
// Admin login with enhanced security
@@ -129,8 +143,11 @@ router.post('/admin/login', [
)
.first();
// Use generic error to prevent user enumeration
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
// Use generic error to prevent user enumeration. OIDC-owned accounts
// (#798) never authenticate locally — their random hash is unusable by
// design, and the explicit check keeps that true even if a hash ever
// gets set through some other path.
if (!admin || admin.auth_provider === 'oidc' || !await bcrypt.compare(password, admin.password_hash)) {
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
@@ -543,6 +560,18 @@ router.post('/gallery/share-login', [
return res.status(401).json({ error: 'Invalid or expired share link' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
// The share link only proves the holder was given the link — it is NOT the
// gallery password. For a password-protected gallery, minting a full
// `type:'gallery'` token here would let anyone with the share URL bypass
// the password entirely (GHSA-9hmx-68vc-qpqw). Signal that a password is
// still required and return WITHOUT a token/cookie; the client then goes
// through POST /gallery/verify, which does check the password.
if (requiresPassword) {
return res.json({ requires_password: true });
}
const jwtToken = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
@@ -557,8 +586,6 @@ router.post('/gallery/share-login', [
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
setGalleryAuthCookies(res, jwtToken, event.slug);
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
token: jwtToken,
event: {
@@ -640,12 +667,22 @@ router.get('/session', async (req, res) => {
// or the gallery event was archived/deleted. Mirror those checks
// here so the session endpoint is always at least as strict as
// what the protected endpoints will enforce next.
// Full user payload for admin sessions — the SSO callback establishes
// the session via redirect (no JSON response the SPA could store), so
// session restoration must be able to hydrate the user object (#798).
let adminUser = null;
if (decoded.type === 'admin') {
let admin = null;
try {
admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'username', 'email', 'password_changed_at')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
.select(
'admin_users.id', 'admin_users.username', 'admin_users.email',
'admin_users.password_changed_at', 'admin_users.must_change_password',
'roles.name as role_name', 'roles.display_name as role_display_name'
)
.first();
} catch (lookupErr) {
// admin_users table not present (test fixture, fresh DB) — fall
@@ -684,6 +721,19 @@ router.get('/session', async (req, res) => {
// Helper lookup failed (test stub may not export it) — fall through
// and trust the token. Real deployments always have the middleware.
}
if (admin) {
adminUser = {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
};
}
} else if (decoded.type === 'gallery') {
try {
const event = await db('events')
@@ -715,7 +765,11 @@ router.get('/session', async (req, res) => {
expiresIn: Math.floor(remainingTime),
user: decoded.username || decoded.eventSlug,
eventSlug: decoded.eventSlug,
adminUsername: decoded.username
adminUsername: decoded.username,
// Full admin payload (or null) — lets the SPA hydrate its user
// state after a redirect-established session (SSO, #798) where no
// login JSON response ever reached it.
adminUser
});
} catch (err) {
res.json({
@@ -837,4 +891,124 @@ router.post('/password-strength', [
}
});
// ──────────────────────────────────────────────────────────────────────────
// OIDC SSO for admins (#798, phase 1)
//
// Authorization-code + PKCE. The per-request secrets (state, nonce, PKCE
// verifier) cross the IdP redirect in a short-lived signed cookie —
// httpOnly, SameSite=Lax (the IdP returns via a top-level GET, which Lax
// permits), scoped to this route prefix. Token/claim validation happens in
// oidcService via openid-client; a successful callback reuses the exact
// session establishment of the local login, so an SSO session is
// indistinguishable from a password one downstream. MFA is the IdP's job on
// this path — local TOTP guards the password flow SSO users don't take.
// ──────────────────────────────────────────────────────────────────────────
const OIDC_STATE_COOKIE = 'oidc_state';
function oidcStateCookieOptions(req) {
return {
httpOnly: true,
secure: Boolean(req.secure),
sameSite: 'Lax',
path: '/api/auth/admin/sso',
maxAge: 10 * 60 * 1000,
};
}
// Kick off the IdP round-trip. 404 when SSO is off so the endpoint is
// invisible on non-SSO installs.
router.get('/admin/sso/login', async (req, res) => {
const oidcService = require('../services/oidcService');
try {
const { url, state, nonce, codeVerifier } = await oidcService.buildAuthorizationRequest();
const stash = jwt.sign(
{ type: 'oidc_state', s: state, n: nonce, cv: codeVerifier },
process.env.JWT_SECRET,
{ expiresIn: '10m', issuer: 'picpeak-auth' }
);
res.cookie(OIDC_STATE_COOKIE, stash, oidcStateCookieOptions(req));
return res.redirect(url);
} catch (error) {
if (error.code === 'OIDC_NOT_CONFIGURED') {
return res.status(404).json({ error: 'SSO is not enabled' });
}
logger.error('OIDC login initiation failed', { error: error.message });
// Absolute like the callback's redirects: in split-origin deployments a
// relative path would resolve on the API origin and 404.
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const frontendBase = (await getFrontendBaseUrl().catch(() => '')) || '';
return res.redirect(`${frontendBase}/admin/login?sso_error=config`);
}
});
// IdP redirect target. Every failure lands back on the login page with a
// translatable error key — never a raw error, never a broken JSON screen.
// Final redirects are ABSOLUTE to the frontend base: in split-origin
// deployments (absolute VITE_API_URL / API_URL) this callback runs on the
// API origin, where a relative /admin/login would 404.
router.get('/admin/sso/callback', async (req, res) => {
const oidcService = require('../services/oidcService');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const frontendBase = (await getFrontendBaseUrl().catch(() => '')) || '';
const fail = (key) => res.redirect(`${frontendBase}/admin/login?sso_error=${key}`);
const stashCookie = req.cookies?.[OIDC_STATE_COOKIE];
res.clearCookie(OIDC_STATE_COOKIE, { ...oidcStateCookieOptions(req), maxAge: undefined });
if (!stashCookie) return fail('state');
let stash;
try {
stash = jwt.verify(stashCookie, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
if (stash.type !== 'oidc_state') throw new Error('wrong token type');
} catch (_) {
return fail('state');
}
try {
// Reconstruct the exact redirect URI + the IdP's query for validation.
const callbackUrl = new URL(await oidcService.getRedirectUri());
callbackUrl.search = req.originalUrl.split('?')[1] || '';
const claims = await oidcService.handleCallback(callbackUrl.href, {
state: stash.s,
nonce: stash.n,
codeVerifier: stash.cv,
});
const resolved = await oidcService.resolveAdminFromClaims(claims);
// Reload with role info so the session payload matches a local login.
const admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', resolved.id)
.select('admin_users.*', 'roles.name as role_name', 'roles.display_name as role_display_name')
.first();
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
await establishAdminSession(res, admin, ipAddress, userAgent, admin.username);
await logActivity('admin_sso_login', { provider: 'oidc' }, null, {
type: 'admin', id: admin.id, name: admin.username,
});
return res.redirect(`${frontendBase}/admin/dashboard`);
} catch (error) {
const codeMap = {
OIDC_NOT_CONFIGURED: 'config',
OIDC_BAD_CONFIG: 'config',
OIDC_INACTIVE: 'inactive',
OIDC_NOT_PROVISIONED: 'not_provisioned',
OIDC_NO_EMAIL: 'no_email',
OIDC_BAD_CLAIMS: 'idp',
};
const key = codeMap[error.code] || 'idp';
// 'idp' covers token-exchange/validation failures from openid-client
// (bad state/nonce, signature, issuer mismatch, IdP-side errors).
logger.warn('OIDC callback failed', { error: error.message, key });
return fail(key);
}
});
module.exports = router;
-443
View File
@@ -1,443 +0,0 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { slugify } = require('../utils/slug');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { adminAuth } = require('../middleware/auth');
const fs = require('fs').promises;
const path = require('path');
const router = express.Router();
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
const logger = require('../utils/logger');
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
// Whether the global "phone field" toggle (#322) is enabled. Same shape as
// the helper in adminEvents.js — kept local so this route doesn't import
// from a sibling route file.
const isPhoneFieldEnabled = async () => {
try {
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
if (!row) return false;
let value = row.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
return value === true;
} catch {
return false;
}
};
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
...rest
} = event;
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null
};
};
let customerColumnCache = null;
const hasCustomerContactColumns = async () => {
if (customerColumnCache === true) {
return true;
}
try {
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
if (hasColumn) {
customerColumnCache = true;
}
return hasColumn;
} catch (error) {
return false;
}
};
// Create new event
router.post('/', adminAuth, [
body('event_type').notEmpty().trim().custom(async (value) => {
const isValid = await eventTypeService.isValidEventType(value);
if (!isValid) {
throw new Error('Invalid event type');
}
return true;
}),
body('event_name').notEmpty(),
body('event_date').isDate(),
body('customer_name').notEmpty().trim(),
body('customer_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
body('customer_phone').optional({ nullable: true, checkFalsy: true })
.isString().trim()
.isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'),
body('admin_email').isEmail(),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
const requirePassword = parseBooleanInput(req.body.require_password, true);
if (!requirePassword) {
return true;
}
if (typeof value !== 'string' || value.trim().length < 6) {
throw new Error('Password must be at least 6 characters long');
}
return true;
}),
body('expiration_days').isInt({ min: 1, max: 365 }).optional()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
event_type,
event_name,
event_date,
admin_email,
password,
require_password: requirePasswordInput = true,
welcome_message,
color_theme,
expiration_days = 30
} = req.body;
const customerEmail = getCustomerEmailFromPayload(req.body);
const customerName = getCustomerNameFromPayload(req.body);
if (!customerName || !customerEmail) {
return res.status(400).json({ error: 'customer_name and customer_email are required' });
}
const customerColumnsAvailable = await hasCustomerContactColumns();
const phoneEnabled = await isPhoneFieldEnabled();
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null;
const requirePassword = parseBooleanInput(requirePasswordInput, true);
if (requirePassword) {
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
}
// Generate unique slug — slugify() handles accents (see #525).
const baseSlug = `${event_type}-${slugify(event_name)}-${event_date}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
}
// Generate share link variants (auto-detects short URL preference)
const shareToken = crypto.randomBytes(16).toString('hex');
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password (or placeholder when not required)
const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date)
const expires_at = new Date(event_date);
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
// Create folder structure
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const eventPath = path.join(storagePath, 'events/active', slug);
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Insert into database
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
event_date,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
...(customerPhone ? { customer_phone: customerPhone } : {}),
host_name: customerName,
host_email: customerEmail,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at,
require_password: formatBoolean(requirePassword)
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// Queue creation email
const { queueEmail } = require('../services/emailProcessor');
await queueEmail(eventId, customerEmail, 'gallery_created', {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName,
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
});
// WhatsApp gallery_ready notification (#647 follow-up). Mirrors the
// adminEvents.js path: fires when the customer supplied a phone, the
// feature is enabled, and a config exists. Non-fatal — a queue failure
// must never block gallery creation.
if (customerPhone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig();
if (waConfig && waConfig.enabled) {
await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
customer_name: customerName || '',
event_name,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : '',
expiry_date: expires_at ? expires_at.toISOString() : null,
language: null,
});
}
} catch (waError) {
logger.warn('Failed to queue WhatsApp notification on create', waError.message);
}
}
// Webhook lifecycle (#327). Legacy public endpoint — events go live
// immediately so created + published fire together. Payload uses the
// canonical event subject (#341) — every event.* webhook now includes
// customer contact + share_token.
try {
const webhookService = require('../services/webhookService');
const eventSubject = webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
});
await webhookService.fire('event.created', { event: eventSubject });
await webhookService.fire('event.published', { event: eventSubject });
} catch (e) { /* non-fatal */ }
res.json({
id: eventId,
slug,
share_link: shareUrl,
expires_at,
require_password: requirePassword,
customer_name: customerName,
customer_email: customerEmail
});
} catch (error) {
logger.error(error);
res.status(500).json({ error: 'Failed to create event' });
}
});
// Get all events (admin)
router.get('/', adminAuth, async (req, res) => {
try {
const { status = 'all' } = req.query;
let query = db('events').select('*');
if (status === 'active') {
query = query.where('is_active', formatBoolean(true));
} else if (status === 'archived') {
query = query.where('is_archived', formatBoolean(true));
}
const events = await query.orderBy('created_at', 'desc');
// Add photo counts
for (const event of events) {
const photoCount = await db('photos').where('event_id', event.id).count('id as count').first();
event.photo_count = photoCount.count;
}
res.json(events.map(mapEventForApi));
} catch (error) {
res.status(500).json({ error: 'Failed to fetch events' });
}
});
// Update event
router.put('/:id', adminAuth, [
body('customer_name').optional().trim().notEmpty(),
body('customer_email').optional().isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
body('require_password').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const updates = { ...req.body };
const customerColumnsAvailable = await hasCustomerContactColumns();
// Don't allow updating certain fields
delete updates.id;
delete updates.slug;
delete updates.created_at;
delete updates.password_confirmation;
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
const nextName = getCustomerNameFromPayload(updates);
if (nextName) {
if (customerColumnsAvailable) {
updates.customer_name = nextName;
} else {
delete updates.customer_name;
}
updates.host_name = nextName;
} else {
delete updates.customer_name;
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
const nextEmail = getCustomerEmailFromPayload(updates);
if (nextEmail) {
if (customerColumnsAvailable) {
updates.customer_email = nextEmail;
} else {
delete updates.customer_email;
}
updates.host_email = nextEmail;
} else {
delete updates.customer_email;
}
}
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
let requirePasswordUpdate;
if (hasRequirePasswordUpdate) {
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
updates.require_password = formatBoolean(requirePasswordUpdate);
}
let newPasswordPlain;
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
if (updates.password === undefined || updates.password === null || updates.password === '') {
delete updates.password;
} else {
newPasswordPlain = updates.password;
delete updates.password;
}
}
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const currentRequirePassword = parseBooleanInput(event.require_password, true);
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
}
if (newPasswordPlain) {
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
}
await db('events').where('id', id).update(updates);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to update event' });
}
});
// Delete event (mark as inactive)
router.delete('/:id', adminAuth, async (req, res) => {
try {
const { id } = req.params;
await db('events').where('id', id).update({ is_active: formatBoolean(false) });
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to delete event' });
}
});
// Extend expiration
router.post('/:id/extend', adminAuth, [
body('days').isInt({ min: 1, max: 365 })
], async (req, res) => {
try {
const { id } = req.params;
const { days } = req.body;
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const newExpiration = new Date(event.expires_at);
newExpiration.setDate(newExpiration.getDate() + days);
await db('events').where('id', id).update({
expires_at: newExpiration,
is_active: formatBoolean(true) // Reactivate if expired
});
res.json({ expires_at: newExpiration });
} catch (error) {
res.status(500).json({ error: 'Failed to extend expiration' });
}
});
module.exports = router;
+64 -11
View File
@@ -25,6 +25,7 @@ const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
@@ -37,6 +38,24 @@ const {
} = require('../services/downloadFilenameService');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const { getStorage } = require('../services/storage');
// Formats whose ORIGINAL bytes a browser can't render in an <img> (HEIC/HEIF,
// camera RAW/DNG). For these the lightbox must be served the generated JPEG
// preview instead of `url` (the original) — otherwise it shows a broken image.
// So we force `preview_url` for them regardless of the lightbox_preview_enabled
// toggle. Detection is by MIME first, extension as a fallback (browsers report
// these MIMEs inconsistently). EXPERIMENTAL: whether a preview actually renders
// still depends on the backend being able to decode the source (HEVC-in-HEIC on
// the prod image; exiftool for DNG) — see #821.
const NON_DISPLAYABLE_ORIGINAL_EXT = new Set(['heic', 'heif', 'dng']);
const NON_DISPLAYABLE_ORIGINAL_MIME = new Set(['image/heic', 'image/heif', 'image/x-adobe-dng']);
function originalNeedsPreview(photo) {
const mime = (photo.mime_type || '').toLowerCase();
if (NON_DISPLAYABLE_ORIGINAL_MIME.has(mime)) return true;
const name = photo.original_filename || photo.filename || '';
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
return NON_DISPLAYABLE_ORIGINAL_EXT.has(ext);
}
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
// Read globals from app_settings (the real table) — settingsService.getSetting
// queries a non-existent `settings` table and throws.
@@ -243,8 +262,8 @@ router.get('/:slug/info', async (req, res) => {
// Photos a slideshow may display: published, finished, non-hidden. Mirrors the
// guest filter in GET /:slug/photos so the live count matches the rendered set.
function slideshowPhotosQuery(eventId) {
return db('photos')
function slideshowPhotosQuery(eventId, categoryId = null) {
const q = db('photos')
.where('photos.event_id', eventId)
.where(function() {
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
@@ -252,6 +271,10 @@ function slideshowPhotosQuery(eventId) {
.where(function() {
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
});
// Category filter (#202) — keep the /session + /state count in sync with the
// photos the kiosk actually renders.
if (categoryId) q.where('photos.category_id', categoryId);
return q;
}
// Resolve an active slideshow by slug + token. Returns the event row, or null
@@ -324,6 +347,9 @@ async function slideshowSettings(event) {
transition: event.show_transition || 'crossfade',
transition_ms: event.show_transition_ms || 800,
colorfilter: event.show_colorfilter || 'none',
// Play order (#202): 'chronological' | 'random'. The client shuffles when
// 'random' so live-appended uploads keep working.
order: event.show_order || 'chronological',
fit: g.fit,
watermark,
};
@@ -356,7 +382,7 @@ router.get('/:slug/show/:token/session', handleAsync(async (req, res) => {
// here so the kiosk's image requests are authorized with zero extra wiring.
setGalleryAuthCookies(res, sessionToken, event.slug);
const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count');
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
res.json({
token: sessionToken,
@@ -382,7 +408,7 @@ router.get('/:slug/show/:token/state', handleAsync(async (req, res) => {
throw new NotFoundError('Slideshow');
}
const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count');
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
res.json({
...(await slideshowSettings(event)),
@@ -426,6 +452,13 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
});
}
// Live Slideshow category filter (#202). Enforced server-side so the kiosk
// viewer can't widen the set: when the event pins show_category_id, the
// slideshow only sees that category. NULL = all photos (unchanged).
if (req.accessLevel === 'slideshow' && req.event.show_category_id) {
photosQuery = photosQuery.where('photos.category_id', req.event.show_category_id);
}
// Apply sort option
if (sort === 'capture_date') {
// Sort by capture date, falling back to uploaded_at if capture date is null
@@ -573,10 +606,12 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// Fetch category details from photo_categories table
let categories = [];
if (usedCategoryIds.length > 0) {
const categoryDetails = await db('photo_categories')
.whereIn('id', usedCategoryIds)
.select('id', 'name', 'slug', 'is_global', 'hero_photo_id', 'allow_downloads')
.orderBy('name', 'asc');
// Resolved category order (#782): per-event override, else global
// default, else name — restricted to categories that have photos.
const categoryDetails = await getEventCategoriesOrdered(req.event.id, {
onlyIds: usedCategoryIds,
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads'],
});
categories = categoryDetails.map(cat => ({
id: cat.id,
@@ -709,7 +744,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// installs that haven't opted in keep loading the original
// (current behaviour). Skipped for videos since they don't
// get a preview tier; lightbox will use the original .url.
preview_url: lightboxPreviewEnabled
preview_url: (lightboxPreviewEnabled || originalNeedsPreview(photo))
&& photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
@@ -1816,7 +1851,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
// Import multer and photo processing
const multer = require('multer');
const { getAllowedMimeTypes, getMaxFilesPerUpload } = require('../services/uploadSettings');
const { getAllowedMimeTypes, getMaxFilesPerUpload, getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../services/uploadSettings');
const { validateFileType } = require('../utils/fileSecurityUtils');
// Resolve allowed MIME types from settings
@@ -1842,10 +1877,22 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
maxFilesPerUpload = 500;
}
// Per-file size cap was hardcoded to 50MB here, so the admin's Settings →
// General → "Max File Size (MB)" value (general_max_file_size_mb) never
// applied to guest uploads — a guest could not upload a large video even
// when the admin allowed it (reported on #613 by mat1990dj). Resolve it from
// settings like the count above; fall back to the 50MB default on read error.
let maxFileSizeBytes;
try {
maxFileSizeBytes = await getMaxFileSizeBytes();
} catch {
maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024;
}
const upload = multer({
dest: tempUploadDir,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB per file (separate concern from #613)
fileSize: maxFileSizeBytes,
files: maxFilesPerUpload
},
fileFilter: (req, file, cb) => {
@@ -1861,6 +1908,12 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
upload(req, res, async (err) => {
if (err) {
logger.error('Upload error:', err);
// Turn multer's generic "File too large" into an actionable message
// that names the configured limit.
if (err.code === 'LIMIT_FILE_SIZE') {
const limitMb = Math.floor(maxFileSizeBytes / (1024 * 1024));
return res.status(400).json({ error: `File too large. Maximum size is ${limitMb} MB per file.` });
}
return res.status(400).json({ error: err.message });
}
+21 -1
View File
@@ -27,7 +27,17 @@ router.get('/', async (req, res) => {
// backend route also enforces it via getMaxFilesPerUpload,
// but a client-side guard saves a 4MB+ round-trip when the
// limit is small.
'general_max_files_per_upload'
'general_max_files_per_upload',
// Same rationale for the per-file size limit — the gallery upload
// component renders it in the requirements hint and guards
// client-side before posting an oversized file. Backend enforces
// via getMaxFileSizeBytes regardless.
'general_max_file_size_mb',
// #798 — the admin login page needs to know whether to show
// the "Sign in with SSO" button (and its label). Only these
// two oidc_* keys are public; issuer/client stay admin-only.
'oidc_enabled',
'oidc_button_label'
]);
})
.select('setting_key', 'setting_value');
@@ -128,6 +138,10 @@ router.get('/', async (req, res) => {
crm_overview_show_outstanding: settingsObject.crm_overview_show_outstanding !== false,
crm_overview_show_quotes: settingsObject.crm_overview_show_quotes !== false,
crm_overview_show_invoices: settingsObject.crm_overview_show_invoices !== false,
// OIDC SSO (#798): the admin login page renders the "Sign in with
// SSO" button from these. Issuer/client/secret are never public.
oidc_enabled: settingsObject.oidc_enabled === true,
oidc_button_label: settingsObject.oidc_button_label || '',
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
@@ -198,6 +212,12 @@ router.get('/', async (req, res) => {
general_max_files_per_upload: Number.isFinite(Number(settingsObject.general_max_files_per_upload))
? Number(settingsObject.general_max_files_per_upload)
: 500,
// Per-file size limit (MB). Default mirrors uploadSettings.js
// DEFAULT_MAX_FILE_SIZE_MB so the gallery UI shows a sensible number on
// installs that never set it explicitly.
general_max_file_size_mb: Number.isFinite(Number(settingsObject.general_max_file_size_mb))
? Number(settingsObject.general_max_file_size_mb)
: 50,
// SEO meta tag flags (safe to expose - these are intended for crawlers)
seo_meta_noindex: settingsObject.seo_meta_noindex === true,
seo_meta_nofollow: settingsObject.seo_meta_nofollow === true,
+16
View File
@@ -10,6 +10,7 @@ const { body, validationResult } = require('express-validator');
const setupService = require('../services/setupService');
const { getClientIp } = require('../utils/requestIp');
const { setAdminAuthCookie } = require('../utils/tokenUtils');
const { adminAuth } = require('../middleware/auth');
const logger = require('../utils/logger');
const router = express.Router();
@@ -79,4 +80,19 @@ router.post('/admin', [
}
});
// Wizard finish marker — unlike the endpoints above this one runs AFTER the
// admin exists (the wizard is authenticated from the account step onward), so
// it takes the normal admin auth. One-way: while the flag is unset the seeded
// SYSTEM event types may be deleted from the wizard's event-types step; once
// set they are permanently protected (#800).
router.post('/complete', adminAuth, async (req, res) => {
try {
await setupService.markSetupWizardCompleted();
res.json({ completed: true });
} catch (err) {
logger.error('[setup] markSetupWizardCompleted failed', { error: err.message });
res.status(500).json({ error: 'Failed to mark setup complete' });
}
});
module.exports = router;
@@ -80,7 +80,16 @@ jest.mock('../../../services/webhookService', () => ({
buildEventSubject: jest.fn().mockReturnValue({}),
}));
// event_type is validated against the live event_types catalog (#800) —
// that lookup would consume the first queued db() chain and shift the
// call sequence these tests pin. Stub it valid; the invalid path has its
// own test below.
jest.mock('../../../services/eventTypeService', () => ({
isValidEventType: jest.fn().mockResolvedValue(true),
}));
const { db } = require('../../../database/db');
const { isValidEventType } = require('../../../services/eventTypeService');
const eventsRouter = require('../events');
const buildApp = () => {
@@ -231,4 +240,16 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
.send({ ...BASE_BODY, feedback_enabled: 'maybe' })
.expect(400);
});
it('rejects an event_type unknown to the catalog with 400 (#800)', async () => {
isValidEventType.mockResolvedValueOnce(false);
const res = await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, event_type: 'nope' })
.expect(400);
expect(isValidEventType).toHaveBeenCalledWith('nope');
expect(JSON.stringify(res.body.errors)).toContain('event_type');
expect(db).not.toHaveBeenCalled();
});
});
+52 -2
View File
@@ -26,6 +26,7 @@ const logger = require('../../utils/logger');
const { slugify } = require('../../utils/slug');
const { formatBoolean } = require('../../utils/dbCompat');
const { parseBooleanInput } = require('../../utils/parsers');
const { isValidEventType } = require('../../services/eventTypeService');
const router = express.Router();
@@ -80,7 +81,7 @@ const photoUpload = multer({
* event_name: { type: string }
* event_type:
* type: string
* enum: [wedding, birthday, corporate, other, family]
* description: "Slug of an active event type from the catalog (Settings → Event Types). Defaults on a fresh install: wedding, birthday, corporate, other. GET /api/v1/event-types lists the live values."
* event_date: { type: string, format: date, nullable: true }
* customer_name: { type: string, nullable: true }
* customer_email: { type: string, format: email, nullable: true }
@@ -117,7 +118,14 @@ router.post(
requireApiScope('admin'),
[
body('event_name').isString().trim().notEmpty(),
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other', 'family']),
// Validate against the live event_types catalog (admins can rename/delete
// the defaults and add custom types), not a hardcoded whitelist (#800).
body('event_type').isString().trim().notEmpty().bail().custom(async (value) => {
if (!(await isValidEventType(value))) {
throw new Error('Unknown event type — must match an active event type slug');
}
return true;
}),
body('event_date').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('customer_name').optional({ nullable: true }).isString(),
body('customer_email').optional({ nullable: true, checkFalsy: true }).isEmail(),
@@ -447,6 +455,48 @@ router.get(
}
);
// ──────────────────────────────────────────────────────────────────────────
// GET /event-types — read (catalog discovery for event creation, #800)
// ──────────────────────────────────────────────────────────────────────────
/**
* @openapi
* /event-types:
* get:
* tags: [Events]
* summary: List active event types
* description: The slugs accepted as `event_type` when creating events. The catalog is admin-customizable (Settings → Event Types), so integrations should discover values here instead of hardcoding them.
* security: [{ bearerAuth: [] }]
* responses:
* 200:
* description: Active event types
* content:
* application/json:
* schema:
* type: object
* properties:
* eventTypes:
* type: array
* items:
* type: object
* properties:
* slug_prefix: { type: string }
* name: { type: string }
* emoji: { type: string }
*/
router.get('/event-types', apiTokenAuth, requireApiScope('read'), async (req, res) => {
try {
const types = await db('event_types')
.where('is_active', formatBoolean(true))
.orderBy('display_order', 'asc')
.select('slug_prefix', 'name', 'emoji');
res.json({ eventTypes: types });
} catch (error) {
logger.error('v1 GET /event-types failed', { error: error.message });
res.status(500).json({ error: 'Failed to list event types' });
}
});
// ──────────────────────────────────────────────────────────────────────────
// GET /events/:id — read
// ──────────────────────────────────────────────────────────────────────────
+12 -2
View File
@@ -30,6 +30,16 @@ async function initializeUpload(options) {
totalChunks
} = options;
// Strip any directory components from the client-supplied filename. It is
// later joined onto the temp merge dir (path.join(tempDir, filename)), and
// path.join does NOT neutralise `../` — a filename like `../../uploads/
// logos/evil.svg` would escape the temp dir and overwrite arbitrary files
// (GHSA-pc72-jf53-w28j). basename() collapses it to the leaf name only.
const safeFilename = path.basename(String(filename || ''));
if (!safeFilename || safeFilename === '.' || safeFilename === '..') {
throw new Error('Invalid filename');
}
// Generate unique upload ID
const uploadId = crypto.randomUUID();
@@ -43,7 +53,7 @@ async function initializeUpload(options) {
// Store upload metadata
const uploadMeta = {
uploadId,
filename,
filename: safeFilename,
fileSize,
mimeType,
eventId,
@@ -59,7 +69,7 @@ async function initializeUpload(options) {
logger.info('Initialized chunked upload', {
uploadId,
filename,
filename: safeFilename,
fileSize,
expectedChunks,
eventId
+8 -1
View File
@@ -11,6 +11,7 @@ const businessProfileService = require('../businessProfileService');
const { ensureSystemBlocksSeeded } = require('../contractBlocksService');
const { ensureInt } = require('../../utils/numericHelpers');
const { adminActor, ensureCustomerActive, nextContractNumber } = require('./helpers');
const { resolveDefaultEventType } = require('../eventTypeService');
/**
@@ -221,6 +222,12 @@ async function convertToEvent(contractId, adminId) {
const placeholderHash = crypto.randomBytes(32).toString('hex');
const shareToken = crypto.randomBytes(32).toString('hex');
// Event type: the configurable org default, else the resolved catch-all —
// same chain as quoteService.convertToEvent. Never a hardcoded slug: the
// admin may have renamed or deleted 'wedding' (#800).
const eventType = (await getAppSetting('crm_default_event_type'))
|| (await resolveDefaultEventType());
const eventCols = await db('events').columnInfo();
const candidate = {
slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`,
@@ -236,7 +243,7 @@ async function convertToEvent(contractId, adminId) {
customer_email: customerEmail,
customer_phone: customer.phone,
admin_email: adminEmail,
event_type: 'wedding',
event_type: eventType,
password_hash: placeholderHash,
share_link: shareToken,
share_token: shareToken,
+27 -4
View File
@@ -47,11 +47,23 @@ async function detectEnvironment() {
type = 'standalone';
}
// Detect a production compose install. The backend runs INSIDE a container and
// cannot see the host's compose files (the image only carries backend/), so we
// can't stat docker-compose.production.yml. Instead we key off an env var the
// production compose sets in the backend environment (PICPEAK_RELEASE_CHANNEL)
// and the default docker-compose.yml does not. When present, the update
// instructions must target that file explicitly — bare `docker compose`
// operates on docker-compose.yml, a different (build-based) stack that also
// starts the dev-only mailhog and leaves the real production containers on the
// old version.
const isProductionCompose = Boolean(process.env.PICPEAK_RELEASE_CHANNEL);
return {
type,
isDocker,
isGit,
hasDockerCompose,
isProductionCompose,
platform: process.platform,
nodeVersion: process.version,
appVersion
@@ -94,25 +106,36 @@ function generateUpdateInstructions(env, targetVersion) {
if (env.isDocker) {
instructions.environmentName = 'Docker';
// Production installs use docker-compose.production.yml (the file the README
// documents and the only one with pinned GHCR images + no dev-only mailhog).
// Bare `docker compose` targets docker-compose.yml instead, so a production
// user who runs it stays on the old version and gets a stray mailhog. When we
// detect a production compose (PICPEAK_RELEASE_CHANNEL set), point every
// command at that file with `-f`.
const composeFile = env.isProductionCompose ? '-f docker-compose.production.yml ' : '';
instructions.steps = [
{
description: 'Pull latest images',
command: 'docker compose pull',
command: `docker compose ${composeFile}pull`,
note: 'Downloads the new version images'
},
{
description: 'Recreate containers with new images',
command: 'docker compose up -d',
command: `docker compose ${composeFile}up -d`,
note: 'Restarts containers with new version'
},
{
description: 'Watch logs for startup (optional)',
command: 'docker compose logs -f backend',
command: `docker compose ${composeFile}logs -f backend`,
note: 'Press Ctrl+C to exit logs',
optional: true
}
];
instructions.warnings.push('Make sure you are in the directory containing your docker-compose.yml file');
if (env.isProductionCompose) {
instructions.warnings.push('Run these from the directory containing your docker-compose.production.yml file.');
} else {
instructions.warnings.push('Make sure you are in the directory containing your compose file. If you installed with docker-compose.production.yml, add `-f docker-compose.production.yml` to each command.');
}
} else if (env.isGit) {
instructions.environmentName = 'Git (Development)';
instructions.steps = [
@@ -266,7 +266,22 @@ async function ensureEventReminderTemplatesSeeded(db, logger) {
}
};
// Per-type templates are only seeded for slugs that still exist in the
// event_types catalog — the setup wizard (and admins) can delete the
// seeded defaults, and re-inserting event_reminder_<slug> for a removed
// type would resurrect an orphan on every boot (#800). The catch-all
// event_reminder_default is always seeded.
let existingSlugs = null;
if (await db.schema.hasTable('event_types')) {
const rows = await db('event_types').select('slug_prefix');
existingSlugs = new Set(rows.map((r) => r.slug_prefix));
}
for (const [templateKey, def] of Object.entries(EVENT_REMINDER_TEMPLATES)) {
const typeSlug = templateKey.replace(/^event_reminder_/, '');
if (typeSlug !== 'default' && existingSlugs && !existingSlugs.has(typeSlug)) {
continue;
}
try {
let existing = await db('email_templates').where({ template_key: templateKey }).first();
+120 -10
View File
@@ -67,13 +67,20 @@ const getEventTypeBySlugPrefix = async (slugPrefix) => {
const isValidEventType = async (slugPrefix) => {
const normalized = slugPrefix.toLowerCase();
// Check in database
// The live catalog is authoritative: a row decides by its active flag, and
// a slug the admin deleted (setup wizard, #800) or deactivated must NOT
// sneak back in through the legacy list below.
const eventType = await getEventTypeBySlugPrefix(normalized);
if (eventType && eventType.is_active) {
return true;
if (eventType) {
return Boolean(eventType.is_active);
}
const anyType = await db('event_types').first('id');
if (anyType) {
return false;
}
// Legacy fallback: Accept old hardcoded values for backward compatibility
// Legacy fallback: only for a degenerate install with an EMPTY catalog
// (pre-catalog schema drift) — accept the old hardcoded values.
const legacyTypes = ['wedding', 'birthday', 'corporate', 'other'];
return legacyTypes.includes(normalized);
};
@@ -198,6 +205,19 @@ const updateEventType = async (id, updates) => {
}
if (updates.is_active !== undefined) {
// Deactivating the last active type would empty the ACTIVE catalog and
// brick event creation (unknown slugs are rejected since #800).
if (updates.is_active === false && eventType.is_active) {
const otherActive = await db('event_types')
.whereNot('id', id)
.where('is_active', formatBoolean(true))
.first('id');
if (!otherActive) {
const error = new Error('Cannot deactivate the last active event type — activate another one first.');
error.code = 'LAST_ACTIVE';
throw error;
}
}
updateData.is_active = formatBoolean(updates.is_active);
}
@@ -250,6 +270,12 @@ const updateEventType = async (id, updates) => {
/**
* Delete an event type
*
* System types are protected — EXCEPT during the first-run setup wizard
* (setup_wizard_completed flag unset, see setupService), where the admin may
* replace the seeded defaults before anything references them (#800). The
* in-use checks below still apply in that window as defense in depth.
*
* @param {number} id - Event type ID
* @returns {Promise<Object>}
*/
@@ -261,11 +287,17 @@ const deleteEventType = async (id) => {
throw error;
}
// Prevent deletion of system types
// Prevent deletion of system types once the setup wizard has completed.
if (eventType.is_system) {
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
error.code = 'SYSTEM_TYPE';
throw error;
// Lazy require: keeps the module graph flat (setupService has no
// dependency back on this service, but the require is only needed on
// this rare path).
const { isSetupWizardCompleted } = require('./setupService');
if (await isSetupWizardCompleted()) {
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
error.code = 'SYSTEM_TYPE';
throw error;
}
}
// Check if any events use this type
@@ -280,7 +312,62 @@ const deleteEventType = async (id) => {
throw error;
}
await db('event_types').where('id', id).del();
// Never delete the last remaining type — and never delete the last ACTIVE
// one either: event creation and the quote/contract default-type resolution
// both need at least one active catalog entry.
const remaining = await db('event_types').whereNot('id', id).count('id as count').first();
if (!remaining || parseInt(remaining.count) === 0) {
const error = new Error('Cannot delete the last event type — at least one must remain.');
error.code = 'LAST_TYPE';
throw error;
}
if (eventType.is_active) {
const remainingActive = await db('event_types')
.whereNot('id', id)
.where('is_active', formatBoolean(true))
.count('id as count')
.first();
if (!remainingActive || parseInt(remainingActive.count) === 0) {
const error = new Error('Cannot delete the last active event type — activate another one first.');
error.code = 'LAST_TYPE';
throw error;
}
}
// Quotes carry event_type too (migration 146) — a dangling slug there would
// corrupt the quote→event conversion default chain.
if (await hasColumnCached('quotes', 'event_type')) {
const quotesUsingType = await db('quotes')
.where('event_type', eventType.slug_prefix)
.count('id as count')
.first();
if (quotesUsingType && parseInt(quotesUsingType.count) > 0) {
const error = new Error(`Cannot delete: ${quotesUsingType.count} quotes are using this type. Deactivate it instead.`);
error.code = 'IN_USE';
throw error;
}
}
// Resolve schema lookups BEFORE opening the transaction — a global-db read
// inside a SQLite transaction (single connection) deadlocks. Same pattern
// as the rename cascade in updateEventType above.
const hasTranslations = await db.schema.hasTable('email_template_translations');
await db.transaction(async (trx) => {
await trx('event_types').where('id', id).del();
// Drop the per-type reminder template with the type, or it lingers as an
// orphan (invisible in the Reminder Emails tab, which derives its rows
// from the live catalog).
const tpl = await trx('email_templates')
.where({ template_key: `event_reminder_${eventType.slug_prefix}` })
.first('id');
if (tpl) {
if (hasTranslations) {
await trx('email_template_translations').where({ template_id: tpl.id }).del();
}
await trx('email_templates').where({ id: tpl.id }).del();
}
});
return { success: true, deleted: eventType };
};
@@ -341,6 +428,28 @@ const getEventTypeForSlug = async (eventTypeIdentifier) => {
return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' };
};
/**
* Resolve the fallback event type for document→event conversions (quotes,
* contracts) when the source carries none. Never hardcodes a specific slug
* (any of them, incl. 'other', can be disabled by the admin): prefer the
* generic 'other' catch-all when it's active, else the first active type by
* display order, and only fall back to the literal 'other' if the catalog is
* somehow empty/unreadable.
* @param {Object} [conn] - Optional knex connection/transaction
* @returns {Promise<string>} - slug_prefix to use
*/
const resolveDefaultEventType = async (conn) => {
const q = conn || db;
try {
const other = await q('event_types').where({ slug_prefix: 'other', is_active: formatBoolean(true) }).first('slug_prefix');
if (other) return 'other';
const firstActive = await q('event_types').where({ is_active: formatBoolean(true) }).orderBy('display_order', 'asc').first('slug_prefix');
return firstActive?.slug_prefix || 'other';
} catch (_) {
return 'other';
}
};
module.exports = {
getAllEventTypes,
getActiveEventTypes,
@@ -352,5 +461,6 @@ module.exports = {
updateEventType,
deleteEventType,
reorderEventTypes,
getEventTypeForSlug
getEventTypeForSlug,
resolveDefaultEventType
};
+21 -10
View File
@@ -2,6 +2,7 @@ const chokidar = require('chokidar');
const path = require('path');
const fs = require('fs').promises;
const sharp = require('sharp');
const pLimit = require('p-limit');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
@@ -13,6 +14,20 @@ const downloadZipService = require('./downloadZipService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const WATCH_PATH = () => path.join(getStoragePath(), 'events/active');
// Bound concurrent watcher work. chokidar fires 'add' once per file — with no
// ignoreInitial option the boot scan fires it for EVERY existing file, and a
// bulk drop into the watch folder fires it for every new one at once. Each
// handler runs DB lookups and (for new files) a full sharp pipeline;
// sharp.concurrency(2) only caps libvips threads WITHIN one operation, not the
// number of parallel pipelines, so unbounded handlers can OOM small hosts.
// 'unlink' shares the limiter: mass deletes otherwise burst DB work and
// ZIP-cache invalidation the same way.
const configuredConcurrency = Number.parseInt(process.env.FILE_WATCHER_CONCURRENCY || '2', 10);
const watcherConcurrency = Number.isFinite(configuredConcurrency)
? Math.max(1, configuredConcurrency)
: 2;
const processLimit = pLimit(watcherConcurrency);
function startFileWatcher() {
// Auto-import via filesystem watching only works with the local storage
// backend. In S3 mode there is no local directory to watch — every photo
@@ -34,19 +49,15 @@ function startFileWatcher() {
});
watcher
.on('add', async (filePath) => {
try {
await processNewPhoto(filePath);
} catch (error) {
.on('add', (filePath) => {
processLimit(() => processNewPhoto(filePath)).catch((error) => {
logger.error('Error processing new photo:', error);
}
});
})
.on('unlink', async (filePath) => {
try {
await removePhoto(filePath);
} catch (error) {
.on('unlink', (filePath) => {
processLimit(() => removePhoto(filePath)).catch((error) => {
logger.error('Error removing photo:', error);
}
});
});
logger.info('File watcher started');
+99 -11
View File
@@ -7,11 +7,80 @@ const crypto = require('crypto');
const logger = require('../utils/logger');
const { db } = require('../database/db');
const { getStorage } = require('./storage');
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
// Configure sharp for better memory management with large batches
sharp.cache(false); // Disable cache to prevent memory buildup
sharp.concurrency(2); // Limit concurrent operations
// Camera RAW / DNG formats. Sharp's bundled libvips has no raw loader, so these
// can't be fed to sharp() directly — instead we extract the full-resolution JPEG
// preview that every RAW file embeds (via exiftool) and process THAT. Gated
// strictly by extension, so nothing here runs for ordinary jpg/png/webp photos.
const RAW_EXTENSIONS = new Set([
'dng', 'cr2', 'cr3', 'nef', 'nrw', 'arw', 'sr2', 'srf',
'raf', 'rw2', 'orf', 'pef', 'srw', 'raw', '3fr', 'dcr', 'kdc'
]);
function isRawFilename(name) {
if (!name || typeof name !== 'string') return false;
const ext = path.extname(name).toLowerCase().replace(/^\./, '');
return RAW_EXTENSIONS.has(ext);
}
/**
* Extract the embedded full-resolution JPEG preview from a RAW/DNG file to a
* temp .jpg and return its path. Tries the largest previews first
* (JpgFromRaw → PreviewImage → ThumbnailImage). Throws if none can be extracted
* or the result isn't a valid image — the caller treats that as a processing
* failure (photo → 'failed'), same as any unreadable upload.
*/
async function extractRawPreview(rawPath) {
const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-raw-'));
const outPath = path.join(outDir, `${crypto.randomBytes(4).toString('hex')}.jpg`);
const tags = ['-JpgFromRaw', '-PreviewImage', '-ThumbnailImage'];
let lastErr;
for (const tag of tags) {
try {
// `-b` writes the raw tag bytes to stdout; -w isn't reliable across tags,
// so capture stdout as a buffer and write it ourselves.
const { stdout } = await execFileAsync('exiftool', ['-b', tag, rawPath], {
encoding: 'buffer',
maxBuffer: 256 * 1024 * 1024,
});
if (stdout && stdout.length > 0) {
await fsp.writeFile(outPath, stdout);
// Validate it's a real, decodable image before handing it to the pipeline.
const meta = await sharp(outPath).metadata();
if (meta.width && meta.height) {
return { path: outPath, cleanup: () => fsp.rm(outDir, { recursive: true, force: true }).catch(() => {}) };
}
}
} catch (err) {
lastErr = err;
}
}
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {});
throw new Error(`No usable embedded preview in RAW file ${path.basename(rawPath)}: ${lastErr ? lastErr.message : 'no preview tag returned data'}`);
}
/**
* Give a Sharp-processable local image path for `localPath`. For ordinary
* images it's a pass-through (no cost). For RAW/DNG (by `sourceName` extension)
* it extracts the embedded JPEG preview and returns that, plus the basename to
* use for generated outputs so thumbnails/previews stay named after the source
* rather than the random temp file. Always call `cleanup()` when done.
*/
async function withProcessableImage(localPath, sourceName) {
if (!isRawFilename(sourceName)) {
return { path: localPath, outputBasename: undefined, cleanup: () => {} };
}
const { path: previewPath, cleanup } = await extractRawPreview(localPath);
return { path: previewPath, outputBasename: path.basename(sourceName), cleanup };
}
// Default thumbnail settings
const DEFAULT_THUMBNAIL_WIDTH = 300;
const DEFAULT_THUMBNAIL_HEIGHT = 300;
@@ -297,9 +366,14 @@ async function ensureThumbnail(photo) {
return null;
}
logger.info(`Ensuring thumbnail for photo ${photo.id} from key: ${sourceKey}`);
newThumbnailPath = await withLocalCopy(sourceKey, (localPath) =>
generateThumbnail(localPath, { regenerate: true })
);
newThumbnailPath = await withLocalCopy(sourceKey, async (localPath) => {
const proc = await withProcessableImage(localPath, sourceKey);
try {
return await generateThumbnail(proc.path, { regenerate: true, outputBasename: proc.outputBasename });
} finally {
await proc.cleanup();
}
});
}
if (newThumbnailPath) {
@@ -366,7 +440,7 @@ async function generateVideoPlaceholder(originalFilename, options = {}) {
* Outputs a 1920x1080 image suitable for full-width hero sections
*/
async function generateHeroImage(imagePath, options = {}) {
const filename = path.basename(imagePath);
const filename = options.outputBasename || path.basename(imagePath);
const heroFilename = `hero_${filename}`;
const heroRelKey = path.posix.join('heroes', heroFilename);
const storage = getStorage();
@@ -469,9 +543,14 @@ async function ensureHeroImage(photo) {
logger.warn(`Invalid hero image detected for photo ${photo.id}, regenerating...`);
}
const newHeroPath = await withLocalCopy(sourceKey, (localPath) =>
generateHeroImage(localPath, { regenerate: true })
);
const newHeroPath = await withLocalCopy(sourceKey, async (localPath) => {
const proc = await withProcessableImage(localPath, sourceKey);
try {
return await generateHeroImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename });
} finally {
await proc.cleanup();
}
});
if (newHeroPath) {
await db('photos')
@@ -498,7 +577,7 @@ async function ensureHeroImage(photo) {
* thumbnails or heroes.
*/
async function generatePreviewImage(imagePath, options = {}) {
const filename = path.basename(imagePath);
const filename = options.outputBasename || path.basename(imagePath);
const previewFilename = `preview_${filename}`;
const previewRelKey = path.posix.join('previews', previewFilename);
const storage = getStorage();
@@ -599,9 +678,14 @@ async function ensurePreviewImage(photo) {
logger.warn(`Invalid preview detected for photo ${photo.id}, regenerating…`);
}
const newPreviewPath = await withLocalCopy(sourceKey, (localPath) =>
generatePreviewImage(localPath, { regenerate: true })
);
const newPreviewPath = await withLocalCopy(sourceKey, async (localPath) => {
const proc = await withProcessableImage(localPath, sourceKey);
try {
return await generatePreviewImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename });
} finally {
await proc.cleanup();
}
});
if (newPreviewPath) {
await db('photos').where({ id: photo.id }).update({ preview_path: newPreviewPath });
@@ -665,4 +749,8 @@ module.exports = {
ensurePreviewImage,
extractCaptureDate,
withLocalCopy,
isRawFilename,
extractRawPreview,
withProcessableImage,
RAW_EXTENSIONS,
};
+10
View File
@@ -174,6 +174,14 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
? 0
: ensureInt(invoice.net_amount_minor) - displayedNetMinor;
// Optional free-text VAT / legal note printed directly under the MwSt. line
// on the invoice PDF (#794). Configured globally in Settings → CRM → Invoices.
// Data-driven: the admin types the exact wording (e.g. the Austrian
// Kleinunternehmer statement, § 6 Abs. 1 Z 27 UStG 1994), so no jurisdiction
// is hardcoded. Empty/whitespace → null (row omitted).
const vatNoteRaw = await getAppSetting('crm_invoices_vat_note_text');
const vatNote = typeof vatNoteRaw === 'string' && vatNoteRaw.trim() ? vatNoteRaw.trim() : null;
return {
locale: invoice.language || profile?.default_locale || 'de',
currency: invoice.currency,
@@ -189,6 +197,8 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
iban: bank.iban, bic: bank.bic, currency: bank.currency,
} : null,
paymentTerm,
// Free-text VAT/legal note (#794) — rendered under the MwSt. line by drawTotals.
vatNote,
lineItems: lineItems.map((li) => ({
quantity: li.quantity,
description: li.description,
+440
View File
@@ -0,0 +1,440 @@
/**
* OIDC SSO for admin users (#798, phase 1).
*
* Authorization-code + PKCE against a single configurable IdP (Keycloak,
* Authentik, Pocket ID, or any spec-compliant provider). Scope is deliberately
* narrow in phase 1: admin logins only, JIT provisioning with one default
* role. Role-claim mapping and logout-to-IdP are follow-ups.
*
* Identity binding: SSO logins match on `admin_users.external_subject` (the
* IdP's stable `sub` claim) — NEVER on email alone, which is an
* account-takeover vector with IdPs that don't verify addresses. A one-time
* link of an EXISTING local admin by email is allowed only when the ID token
* carries `email_verified: true`; the sub is stamped so all future logins
* match by sub even if the email changes. Linked local admins keep
* `auth_provider='local'` (their password still works); JIT-provisioned rows
* get `auth_provider='oidc'` and an unusable random password hash.
*
* Config lives in app_settings (oidc_* keys, managed via the dedicated
* /admin/settings/sso endpoints). The client secret is AES-256-GCM encrypted
* at rest — same construction as mfaService, own salt, key from
* OIDC_ENCRYPTION_KEY (fallback JWT_SECRET).
*
* MFA is delegated to the IdP for SSO logins: local TOTP protects the local
* password path, which SSO users don't take.
*/
const crypto = require('crypto');
const bcrypt = require('bcrypt');
// openid-client v5 (CommonJS). v6+ is ESM-only, which Node 22 can require()
// but Jest's CJS runtime cannot — v5 is the battle-tested major and its
// protocol coverage (discovery, PKCE, full ID-token validation) is identical
// for our flow.
const { Issuer, generators } = require('openid-client');
const { db } = require('../database/db');
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
const { formatBoolean } = require('../utils/dbCompat');
const { getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const ENC_ALGO = 'aes-256-gcm';
const ENC_SALT = 'picpeak-oidc-secret-v1'; // fixed: derivation must be stable
function getEncryptionKey() {
const material = process.env.OIDC_ENCRYPTION_KEY || process.env.JWT_SECRET;
if (!material) {
throw new Error('oidcService: OIDC_ENCRYPTION_KEY or JWT_SECRET must be set');
}
return crypto.scryptSync(material, ENC_SALT, 32);
}
/** AES-256-GCM encrypt → "iv.tag.ciphertext" (all base64url). */
function encryptSecret(plainSecret) {
const key = getEncryptionKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ENC_ALGO, key, iv);
const ct = Buffer.concat([cipher.update(plainSecret, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv, tag, ct].map((b) => b.toString('base64url')).join('.');
}
/** Reverse of encryptSecret. Throws on tamper/wrong key. */
function decryptSecret(stored) {
const key = getEncryptionKey();
const [ivB64, tagB64, ctB64] = String(stored).split('.');
if (!ivB64 || !tagB64 || !ctB64) {
throw new Error('oidcService: malformed encrypted secret');
}
const decipher = crypto.createDecipheriv(ENC_ALGO, key, Buffer.from(ivB64, 'base64url'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
const pt = Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64url')), decipher.final()]);
return pt.toString('utf8');
}
/**
* Read the full OIDC config from app_settings. Secret is returned DECRYPTED —
* for internal use only; the settings GET endpoint must never call this.
*/
async function getOidcConfig() {
const [enabled, issuerUrl, clientId, encSecret, autoprovision, defaultRole, buttonLabel, scopes] =
await Promise.all([
getAppSetting('oidc_enabled'),
getAppSetting('oidc_issuer_url'),
getAppSetting('oidc_client_id'),
getAppSetting('oidc_client_secret'),
getAppSetting('oidc_autoprovision'),
getAppSetting('oidc_default_role'),
getAppSetting('oidc_button_label'),
getAppSetting('oidc_scopes'),
]);
let clientSecret = null;
if (encSecret) {
try {
clientSecret = decryptSecret(encSecret);
} catch (err) {
// Wrong key / tampered / plaintext-clobbered value → treat as
// unconfigured rather than sending garbage to the IdP.
logger.error('OIDC client secret could not be decrypted — treating SSO as unconfigured', {
error: err.message,
});
}
}
return {
enabled: enabled === true,
issuerUrl: issuerUrl || null,
clientId: clientId || null,
clientSecret,
autoprovision: autoprovision === true,
defaultRole: defaultRole || 'viewer',
buttonLabel: buttonLabel || null,
scopes: scopes || 'openid profile email',
};
}
function isConfigured(cfg) {
return Boolean(cfg.issuerUrl && cfg.clientId && cfg.clientSecret);
}
// Discovery result cache. Keyed by issuer+client so a settings change gets a
// fresh client; invalidated explicitly on settings save too.
let _clientCache = null; // { key, client, issuerMetadata }
function invalidateDiscoveryCache() {
_clientCache = null;
}
/**
* Resolve the openid-client Client for the current settings, performing
* OIDC discovery on first use. Throws on unreachable/invalid issuer —
* callers surface that as a config error.
*/
async function getClient(cfg) {
// The secret is part of the key (as a fingerprint, never plaintext): in
// multi-worker deployments a secret rotation only invalidates the cache in
// the worker that handled the settings request — the others must detect
// the change through the key, or they keep signing with the old secret.
const secretFp = crypto.createHash('sha256').update(cfg.clientSecret || '').digest('hex').slice(0, 16);
const key = `${cfg.issuerUrl}|${cfg.clientId}|${secretFp}`;
if (_clientCache && _clientCache.key === key) {
return _clientCache;
}
const issuer = await Issuer.discover(cfg.issuerUrl);
const client = new issuer.Client({
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
redirect_uris: [await getRedirectUri()],
response_types: ['code'],
});
_clientCache = { key, client, issuerMetadata: issuer.metadata };
return _clientCache;
}
/**
* The redirect URI registered with the IdP. Derived from the public frontend
* base URL — nginx proxies /api to the backend, so this resolves publicly.
*/
async function getRedirectUri() {
// The callback must land on the API's public origin — that is where the
// oidc_state cookie was set when the browser hit /sso/login. In the
// standard deployment the frontend proxies /api on the same origin, so
// FRONTEND_URL works; split-origin deployments set API_URL (canonically
// ending in /api, see .env.example) and MUST be honored first or the
// callback goes to a host that has neither the route nor the cookie.
const apiBase = (process.env.API_URL || '').trim().replace(/\/$/, '');
if (apiBase) {
return `${apiBase}/auth/admin/sso/callback`;
}
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const base = (await getFrontendBaseUrl()).replace(/\/$/, '');
if (!base) {
// Without a public base URL the redirect_uri would be relative — the IdP
// would reject it with an opaque error on ITS side. Fail here with a
// clear config message instead.
const err = new Error('API_URL or FRONTEND_URL (or the general_site_url setting) must be set for SSO');
err.code = 'OIDC_BAD_CONFIG';
throw err;
}
return `${base}/api/auth/admin/sso/callback`;
}
/**
* Build the IdP authorization URL plus the per-request secrets the callback
* needs (state, nonce, PKCE verifier). The route stores those in a
* short-lived signed cookie — this service is stateless across the redirect.
*/
async function buildAuthorizationRequest() {
const cfg = await getOidcConfig();
if (!cfg.enabled || !isConfigured(cfg)) {
const err = new Error('SSO is not enabled or not fully configured');
err.code = 'OIDC_NOT_CONFIGURED';
throw err;
}
const { client } = await getClient(cfg);
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const state = generators.state();
const nonce = generators.nonce();
const url = client.authorizationUrl({
redirect_uri: await getRedirectUri(),
scope: cfg.scopes,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return { url, state, nonce, codeVerifier };
}
/**
* Exchange the authorization code and validate the ID token (issuer,
* audience, signature, nonce, state — all enforced by openid-client).
* Returns the ID token claims.
*/
async function handleCallback(currentUrl, { state, nonce, codeVerifier }) {
const cfg = await getOidcConfig();
if (!cfg.enabled || !isConfigured(cfg)) {
const err = new Error('SSO is not enabled or not fully configured');
err.code = 'OIDC_NOT_CONFIGURED';
throw err;
}
const { client, issuerMetadata } = await getClient(cfg);
// Extract code/state from the callback URL, then exchange + validate the
// ID token (issuer, audience, signature, exp, nonce, state — all enforced
// by openid-client).
const callbackUrl = new URL(currentUrl);
const params = Object.fromEntries(callbackUrl.searchParams.entries());
const tokenSet = await client.callback(await getRedirectUri(), params, {
state,
nonce,
code_verifier: codeVerifier,
});
let claims = tokenSet.claims();
// Spec-compliant providers may deliver `profile`/`email` scope claims only
// from the UserInfo endpoint, not inside the ID token. When the email is
// missing there, fetch UserInfo and merge — ID-token claims win on
// conflict (they are signature-bound to this very authorization). The sub
// must match, or the response is discarded (spec requirement).
if (!claims.email && issuerMetadata.userinfo_endpoint && tokenSet.access_token) {
try {
const userinfo = await client.userinfo(tokenSet);
if (userinfo && userinfo.sub === claims.sub) {
claims = { ...userinfo, ...claims };
}
} catch (err) {
// Non-fatal: providers that put everything in the ID token don't need
// this; resolveAdminFromClaims handles a still-missing email.
logger.warn('OIDC userinfo fetch failed — proceeding with ID token claims only', {
error: err.message,
});
}
}
return claims;
}
/**
* Map validated ID token claims to an admin_users row.
*
* Resolution order:
* 1. (external_issuer, external_subject) === (iss, sub) → that admin
* (must be active). Matching includes the issuer because OIDC only
* guarantees sub uniqueness WITHIN an issuer — a lookup on sub alone
* would let a user of a newly-configured IdP inherit an old IdP's
* admin account on a subject collision.
* 2. email match against an UNLINKED admin, only if email_verified === true
* → one-time link (stamps issuer+subject; auth_provider unchanged so
* a local password keeps working).
* 3. JIT provisioning when oidc_autoprovision is on (requires an email
* claim; role = oidc_default_role; unusable random password).
*
* Errors carry a `code` the route maps to a redirect error key.
*/
async function resolveAdminFromClaims(claims) {
const sub = claims.sub;
const iss = claims.iss;
const email = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : null;
const emailVerified = claims.email_verified === true;
if (!sub || !iss) {
const err = new Error('ID token has no sub/iss claim');
err.code = 'OIDC_BAD_CLAIMS';
throw err;
}
// 1. Established binding — issuer AND subject.
const bySub = await db('admin_users')
.where('external_issuer', iss)
.where('external_subject', sub)
.first();
if (bySub) {
if (!bySub.is_active) {
const err = new Error('Admin account is deactivated');
err.code = 'OIDC_INACTIVE';
throw err;
}
return bySub;
}
// 2. One-time email link — verified emails only, and only onto rows that
// have no binding yet (a different identity on the row means a
// different IdP identity already owns it).
if (email && emailVerified) {
const byEmail = await db('admin_users')
.where('email', email)
.whereNull('external_subject')
.first();
if (byEmail) {
if (!byEmail.is_active) {
const err = new Error('Admin account is deactivated');
err.code = 'OIDC_INACTIVE';
throw err;
}
// Claim atomically: two concurrent first-time callbacks with the same
// email but DIFFERENT subjects must not both authenticate as this
// admin — the conditional update lets exactly one win.
const claimed = await db('admin_users')
.where('id', byEmail.id)
.whereNull('external_subject')
.update({
external_issuer: iss,
external_subject: sub,
updated_at: new Date(),
});
if (claimed !== 1) {
// Lost the race. If the winner was this very identity (double-click,
// parallel tabs), the binding lookup now succeeds; anything else is
// an unbound identity again and must not proceed as this admin.
const rebound = await db('admin_users')
.where('external_issuer', iss)
.where('external_subject', sub)
.first();
if (rebound && rebound.is_active) return rebound;
const err = new Error('Account link raced with another sign-in — try again');
err.code = 'OIDC_BAD_CLAIMS';
throw err;
}
logger.info('OIDC: linked existing admin to IdP subject', {
adminId: byEmail.id,
sub,
});
return { ...byEmail, external_issuer: iss, external_subject: sub };
}
}
// 3. JIT provisioning.
const cfg = await getOidcConfig();
if (!cfg.autoprovision) {
const err = new Error('No matching admin account and auto-provisioning is disabled');
err.code = 'OIDC_NOT_PROVISIONED';
throw err;
}
if (!email) {
const err = new Error('IdP supplied no email claim — cannot provision an account');
err.code = 'OIDC_NO_EMAIL';
throw err;
}
const role = await db('roles').where('name', cfg.defaultRole).first();
if (!role) {
const err = new Error(`Configured default role '${cfg.defaultRole}' does not exist`);
err.code = 'OIDC_BAD_CONFIG';
throw err;
}
// Unusable-but-valid bcrypt hash: local login always fails for this row,
// and nothing downstream chokes on a malformed hash.
const passwordHash = await bcrypt.hash(crypto.randomBytes(32).toString('base64url'), getBcryptRounds());
const inserted = await db('admin_users')
.insert({
username: email,
email,
password_hash: passwordHash,
role_id: role.id,
is_active: formatBoolean(true),
must_change_password: formatBoolean(false),
auth_provider: 'oidc',
external_issuer: iss,
external_subject: sub,
created_at: new Date(),
updated_at: new Date(),
})
.returning('id');
const adminId = inserted[0]?.id || inserted[0];
logger.info('OIDC: JIT-provisioned admin from IdP', { adminId, sub, role: cfg.defaultRole });
return db('admin_users').where('id', adminId).first();
}
/**
* Persist SSO settings (dedicated endpoint — the generic settings upserts
* strip oidc_client_secret so it can't be clobbered with plaintext).
* An absent/empty secret keeps the stored one.
*/
async function saveOidcSettings(input) {
const writes = [];
const put = (key, value, type) => writes.push(upsertAppSetting(key, JSON.stringify(value), type));
if (input.oidc_enabled !== undefined) put('oidc_enabled', input.oidc_enabled === true, 'boolean');
if (input.oidc_issuer_url !== undefined) put('oidc_issuer_url', String(input.oidc_issuer_url).trim(), 'string');
if (input.oidc_client_id !== undefined) put('oidc_client_id', String(input.oidc_client_id).trim(), 'string');
if (input.oidc_autoprovision !== undefined) put('oidc_autoprovision', input.oidc_autoprovision === true, 'boolean');
if (input.oidc_default_role !== undefined) put('oidc_default_role', String(input.oidc_default_role).trim(), 'string');
if (input.oidc_button_label !== undefined) put('oidc_button_label', String(input.oidc_button_label).trim(), 'string');
if (input.oidc_scopes !== undefined) {
// The `openid` scope is what makes this OIDC rather than plain OAuth —
// without it there is no ID token and the callback cannot authenticate
// anyone. Force it in rather than trusting the admin's edit.
const scopes = String(input.oidc_scopes).trim().split(/\s+/).filter(Boolean);
if (!scopes.includes('openid')) scopes.unshift('openid');
put('oidc_scopes', scopes.join(' ') || 'openid profile email', 'string');
}
if (typeof input.oidc_client_secret === 'string' && input.oidc_client_secret.length > 0) {
put('oidc_client_secret', encryptSecret(input.oidc_client_secret), 'string');
}
await Promise.all(writes);
invalidateDiscoveryCache();
}
module.exports = {
getOidcConfig,
isConfigured,
getRedirectUri,
buildAuthorizationRequest,
handleCallback,
resolveAdminFromClaims,
saveOidcSettings,
invalidateDiscoveryCache,
getClient,
encryptSecret,
decryptSecret,
};
+36 -4
View File
@@ -851,6 +851,18 @@ function drawTotals(doc, ctx, x, y, width) {
doc.text(formatMinor(totals.vatAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
y = doc.y + 4;
// Free-text VAT / legal note (#794) — printed directly under the MwSt. line
// (Benedikt's requested spot). The admin sets the exact wording in
// Settings → CRM → Invoices (e.g. the Austrian Kleinunternehmer statement).
// Optional; wraps across the totals column. Font size is restored to the row
// scale so the Mahngebühr / Rundung / grand-total rows below are unaffected.
if (ctx.vatNote) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#555');
doc.text(ctx.vatNote, labelX, y, { width: right - labelX });
doc.fillColor('#000').fontSize(10);
y = doc.y + 4;
}
// Mahngebühr row — only rendered when a late fee has been added
// (second reminder onwards). Sits between VAT and the grand-total
// divider so the customer sees a clear "VAT + late fee → Total"
@@ -1670,7 +1682,16 @@ function renderDocument(type, context) {
// VAT + middle divider + Total)
const FOOTER_RESERVE = 30;
const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50;
const TOTALS_BLOCK_HEIGHT = 90;
let TOTALS_BLOCK_HEIGHT = 90;
// A free-text VAT note (#794) adds a wrapped row under the MwSt. line —
// grow the reserved totals height by its measured height so a long note
// can't push the grand total / payment block into the footer.
if (ctx.vatNote) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8);
const noteWidth = PAGE.contentWidth - ((PAGE.contentWidth - 20) / 2 + 20);
TOTALS_BLOCK_HEIGHT += doc.heightOfString(ctx.vatNote, { width: noteWidth }) + 4;
doc.fontSize(10);
}
const desiredPaymentY = PAGE.height - PAGE.marginBottom - FOOTER_RESERVE - PAYMENT_BLOCK_HEIGHT;
const desiredTotalsY = desiredPaymentY - 12 - TOTALS_BLOCK_HEIGHT;
@@ -1746,14 +1767,23 @@ function renderDocument(type, context) {
// grey line in the bottom corner) is negligible.
for (let i = 0; i < total; i++) {
doc.switchToPage(range.start + i);
// Drop this page's bottom margin to 0 so writing the label INTO the
// margin band (below the content area the line-item table fills) can't
// trigger PDFKit's auto-page-break. Previously the label sat at
// marginBottom-12 — INSIDE the content area — so on a full multi-page
// invoice the table's last row overlapped the "Seite X von Y" stamp
// (#794). The page is already fully laid out (buffered), so zeroing the
// margin here is safe.
doc.page.margins.bottom = 0;
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888');
const label = t(ctx.locale, 'page_of', {
current: i + 1,
total,
});
// Bottom-right corner, just above the bottom margin so
// it doesn't trigger PDFKit's auto-paging.
const labelY = doc.page.height - PAGE.marginBottom - 12;
// Bottom-right corner, INSIDE the bottom margin (below the content
// edge the table fills), so a full continuation page's last row can't
// overlap it.
const labelY = doc.page.height - PAGE.marginBottom + 8;
const labelW = 120;
const labelX = doc.page.width - PAGE.marginRight - labelW;
doc.text(label, labelX, labelY, {
@@ -1793,6 +1823,8 @@ function normaliseContext(type, ctx) {
totals: ctx.totals || {},
doc: ctx.doc || {},
qrFormat: ctx.qrFormat || 'none',
// Free-text VAT/legal note printed under the MwSt. line on invoices (#794).
vatNote: (typeof ctx.vatNote === 'string' && ctx.vatNote.trim()) ? ctx.vatNote.trim() : null,
// Date-format config from the `general_date_format` app setting.
// Shape: `{ format: 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' |
// 'YYYY-MM-DD', locale?: string }`. The service layer hydrates
+97 -37
View File
@@ -1,9 +1,9 @@
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { generateThumbnail, extractCaptureDate, withLocalCopy } = require('./imageProcessor');
const { generateThumbnail, generateVideoPlaceholder, extractCaptureDate, withLocalCopy, withProcessableImage } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
const { processUploadedVideo, extractVideoMetadata, isVideoMimeType } = require('./videoProcessor');
const { getStorage } = require('./storage');
const { resolvePhotoStorageKey } = require('./photoResolver');
const logger = require('../utils/logger');
@@ -141,22 +141,50 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
'thumbnails',
`thumb_${newFilename.replace(/\.[^.]+$/, '.jpg')}`
);
const result = await processUploadedVideo(tempPath, videoThumbnailKey);
videoMetadata = result.metadata;
thumbnailPath = result.thumbnailKey;
} else {
thumbnailPath = await generateThumbnail(tempPath);
// A thumbnail/probe failure must not lose the video: without this
// guard the whole upload errors here, while the image branch below
// already survives its thumbnail failures. Fall back to metadata-only
// plus the static play-button placeholder — a completed video with a
// NULL thumbnail would make the grid fetch the ORIGINAL video file
// as an <img> blob (thumbnail_url || url), i.e. a multi-GB download
// for a broken tile (codex review of #845).
try {
const sharp = require('sharp');
const metadata = await sharp(tempPath).metadata();
if (metadata.width && metadata.height) {
imageMetadata = {
width: metadata.width,
height: metadata.height
};
const result = await processUploadedVideo(tempPath, videoThumbnailKey);
videoMetadata = result.metadata;
thumbnailPath = result.thumbnailKey;
} catch (videoErr) {
logger.warn(`Video processing failed for ${file.originalname}, using placeholder thumbnail:`, videoErr.message);
try {
videoMetadata = await extractVideoMetadata(tempPath);
} catch (metaErr) {
logger.warn(`Video metadata extraction also failed for ${file.originalname}:`, metaErr.message);
}
} catch (metadataError) {
logger.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
// ffmpeg-free (sharp-rendered SVG); returns null on failure.
thumbnailPath = await generateVideoPlaceholder(newFilename);
}
} else {
// RAW/DNG can't be fed to sharp directly (no raw loader), so extract the
// embedded JPEG preview first and thumbnail/measure THAT. Pass-through
// for ordinary images. The stored original stays the RAW (download).
// Use the unique stored filename (not the client-supplied original) so
// the RAW-derived thumbnail's global key can't collide across galleries.
const proc = await withProcessableImage(tempPath, newFilename);
try {
thumbnailPath = await generateThumbnail(proc.path, { outputBasename: proc.outputBasename });
try {
const sharp = require('sharp');
const metadata = await sharp(proc.path).metadata();
if (metadata.width && metadata.height) {
imageMetadata = {
width: metadata.width,
height: metadata.height
};
}
} catch (metadataError) {
logger.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
}
} finally {
await proc.cleanup();
}
}
@@ -447,31 +475,63 @@ async function processPhoto(photoId) {
'thumbnails',
`thumb_${photo.filename.replace(/\.[^.]+$/, '.jpg')}`
);
const result = await processUploadedVideo(localPath, videoThumbnailKey);
updateData.thumbnail_path = result.thumbnailKey;
if (result.metadata) {
if (result.metadata.duration != null) updateData.duration = result.metadata.duration;
if (result.metadata.videoCodec) updateData.video_codec = result.metadata.videoCodec;
if (result.metadata.audioCodec) updateData.audio_codec = result.metadata.audioCodec;
if (result.metadata.width) updateData.width = result.metadata.width;
if (result.metadata.height) updateData.height = result.metadata.height;
// A thumbnail/probe failure must not fail the row: processPhoto's caller
// marks failed rows 'failed' and the guest gallery only lists 'complete',
// so the video would become permanently invisible. The image branch below
// already survives its thumbnail failures — mirror that: fall back to
// metadata-only plus the static play-button placeholder. A completed
// video with a NULL thumbnail would make the grid fetch the ORIGINAL
// video file as an <img> blob (thumbnail_url || url) — a multi-GB
// download for a broken tile (codex review of #845).
let videoResult = null;
try {
videoResult = await processUploadedVideo(localPath, videoThumbnailKey);
} catch (videoErr) {
logger.warn(`processPhoto: video processing failed for ${photoId}, using placeholder thumbnail`, { error: videoErr.message });
try {
videoResult = { metadata: await extractVideoMetadata(localPath) };
} catch (metaErr) {
logger.warn(`processPhoto: video metadata extraction also failed for ${photoId}`, { error: metaErr.message });
}
// ffmpeg-free (sharp-rendered SVG); returns null on failure.
const placeholderKey = await generateVideoPlaceholder(photo.filename);
if (placeholderKey) videoResult = { ...(videoResult || {}), thumbnailKey: placeholderKey };
}
if (videoResult?.thumbnailKey) updateData.thumbnail_path = videoResult.thumbnailKey;
if (videoResult?.metadata) {
const m = videoResult.metadata;
if (m.duration != null) updateData.duration = m.duration;
if (m.videoCodec) updateData.video_codec = m.videoCodec;
if (m.audioCodec) updateData.audio_codec = m.audioCodec;
if (m.width) updateData.width = m.width;
if (m.height) updateData.height = m.height;
}
} else {
// RAW/DNG can't be sharp-decoded directly — extract the embedded JPEG
// preview and thumbnail/measure that. Pass-through for ordinary images.
// This is the ASYNC worker path (backgroundProcessor → processPhoto), the
// one real uploads actually take; the synchronous processUploadedPhotos()
// has the same handling.
const proc = await withProcessableImage(localPath, photo.filename);
try {
const thumbnailPath = await generateThumbnail(localPath);
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
} catch (e) {
logger.warn(`processPhoto: thumbnail generation failed for ${photoId}`, { error: e.message });
}
try {
const sharp = require('sharp');
const metadata = await sharp(localPath).metadata();
if (metadata.width && metadata.height) {
updateData.width = metadata.width;
updateData.height = metadata.height;
try {
const thumbnailPath = await generateThumbnail(proc.path, { outputBasename: proc.outputBasename });
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
} catch (e) {
logger.warn(`processPhoto: thumbnail generation failed for ${photoId}`, { error: e.message });
}
} catch (e) {
logger.warn(`processPhoto: dimensions extraction failed for ${photoId}`, { error: e.message });
try {
const sharp = require('sharp');
const metadata = await sharp(proc.path).metadata();
if (metadata.width && metadata.height) {
updateData.width = metadata.width;
updateData.height = metadata.height;
}
} catch (e) {
logger.warn(`processPhoto: dimensions extraction failed for ${photoId}`, { error: e.message });
}
} finally {
await proc.cleanup();
}
}
});
+23 -15
View File
@@ -10,7 +10,7 @@ const path = require('path');
const fsp = require('fs/promises');
const sharp = require('sharp');
const { db } = require('../database/db');
const { generateThumbnail, extractCaptureDate } = require('./imageProcessor');
const { generateThumbnail, extractCaptureDate, withProcessableImage } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const watermarkGeneratorService = require('./watermarkGeneratorService');
const { getStorage } = require('./storage');
@@ -61,24 +61,32 @@ async function replacePhoto(existingPhoto, newFileTempPath, { originalFilename,
// No EXIF — keep null
}
let width = null;
let height = null;
try {
const metadata = await sharp(newFileTempPath).metadata();
width = metadata.width || null;
height = metadata.height || null;
} catch {
// Non-image or corrupt
}
const stats = await fsp.stat(newFileTempPath);
// Generate new thumbnail FROM the local temp before uploading the original.
// RAW/DNG isn't sharp-decodable — extract the embedded JPEG preview first
// (pass-through for ordinary images), then measure + thumbnail that. Mirrors
// the ingest paths (processPhoto / processUploadedPhotos).
let width = null;
let height = null;
let thumbnailPath = null;
// Detect/name by the unique stored filename (newFilename), not the
// client-supplied original, so RAW derivative keys can't collide.
const proc = await withProcessableImage(newFileTempPath, newFilename);
try {
thumbnailPath = await generateThumbnail(newFileTempPath);
} catch {
logger.warn('Failed to generate thumbnail for replaced photo', { photoId: existingPhoto.id });
try {
const metadata = await sharp(proc.path).metadata();
width = metadata.width || null;
height = metadata.height || null;
} catch {
// Non-image or corrupt
}
try {
thumbnailPath = await generateThumbnail(proc.path, { outputBasename: proc.outputBasename });
} catch {
logger.warn('Failed to generate thumbnail for replaced photo', { photoId: existingPhoto.id });
}
} finally {
await proc.cleanup();
}
// Delete old assets BEFORE uploading the new key — if they share the path
+181 -17
View File
@@ -18,10 +18,12 @@ const fsp = require('fs').promises;
const path = require('path');
const os = require('os');
const StreamZip = require('node-stream-zip');
const { assertZipEntriesWithin } = require('../utils/safePath');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const { getStoragePath } = require('../config/storage');
const { hasColumnCached } = require('../utils/schemaCache');
const { setSessionsValidAfter } = require('../utils/sessionCutoff');
const logger = require('../utils/logger');
const { PICPEAK_FORMAT_VERSION, EXCLUDED_TABLES, listDataTables } = require('./picpeakExportService');
@@ -80,25 +82,164 @@ function parseNdjson(filePath) {
}
// Re-insert the operator's account inside the restore transaction so they keep
// working credentials. If the backup already loaded an admin with the same
// email, overwrite that row's credentials with the current account's (current
// creds win); otherwise insert the snapshot with a fresh id.
// working credentials after the wipe.
//
// The operator's login + credentials + MFA must be restored, not just the
// password. A crafted backup can carry a row with the operator's email whose
// two_factor_* fields are attacker-chosen — leaving those in place would let
// the backup strip or hijack the operator's MFA, or (cross-instance) pin a TOTP
// secret encrypted with the source instance's key the operator can never
// satisfy. These columns are scalar/text (recovery codes are a JSON string in a
// TEXT column), so writing them needs no special json handling. Relationship/
// audit FKs (role_id, created_by) are deliberately NOT forced from the snapshot
// — see the update branch below.
//
// admin_users has UNIQUE constraints on BOTH email and username, and a restored
// backup can collide with the operator on either — possibly on two DIFFERENT
// rows (one shares the email, another shares the default `admin` username). We
// reconcile WITHOUT deleting any restored row: deleting would fire ON DELETE
// actions (SQLite) or dangle references such as events.created_by (Postgres,
// where replica mode suppresses cascades). Instead:
// - if a row already has the operator's email, overwrite it in place (its id
// is preserved, so every FK pointing at the operator stays valid);
// - if a DIFFERENT row holds the operator's username, rename that row (id
// preserved, its own FKs stay valid) to free the username;
// - only when no row has the operator's email do we insert a fresh row.
async function reinjectCurrentAdmin(trx, currentAdmin) {
if (!currentAdmin) return;
const existing = await trx('admin_users').whereRaw('lower(email) = lower(?)', [currentAdmin.email]).first();
if (existing) {
await trx('admin_users').where({ id: existing.id }).update({
password_hash: currentAdmin.password_hash,
is_active: currentAdmin.is_active,
must_change_password: currentAdmin.must_change_password,
});
if (!currentAdmin) return null;
const emailMatch = await trx('admin_users')
.whereRaw('lower(email) = lower(?)', [currentAdmin.email])
.first();
// Free the operator's username if a different row holds it (rename, not delete).
const usernameHolder = await trx('admin_users')
.whereRaw('lower(username) = lower(?)', [currentAdmin.username])
.first();
if (usernameHolder && (!emailMatch || usernameHolder.id !== emailMatch.id)) {
await trx('admin_users')
.where({ id: usernameHolder.id })
.update({ username: `${usernameHolder.username}__restored_${usernameHolder.id}` });
}
if (emailMatch) {
// Update in place — keeps emailMatch.id so restored FKs to the operator
// hold. Write only the AUTH-critical columns (login identity + credentials
// + MFA), never the relationship/audit FKs (role_id → roles, created_by →
// admin_users). Forcing the operator's pre-restore role_id/created_by here
// could reference rows absent from a cross-instance backup and dangle the
// FK (SQLite rolls back at commit); the row already carries the backup's
// own valid values for those. This still closes the MFA-hijack gap — a
// crafted backup can't strip or replace the operator's second factor.
const authUpdate = {};
for (const field of PRESERVED_AUTH_FIELDS) {
if (field in currentAdmin) authUpdate[field] = currentAdmin[field];
}
await trx('admin_users').where({ id: emailMatch.id }).update(authUpdate);
return emailMatch.id;
} else {
const row = { ...currentAdmin };
delete row.id; // let the engine assign a fresh id to avoid collision
await trx('admin_users').insert(row);
// The operator's email isn't in the backup, so nothing restored references
// their id — a fresh row can't dangle a reference TO the operator. Null the
// self-referential created_by (its target admin may be absent from this
// backup; ON DELETE SET NULL makes null the correct "unknown inviter"
// value) so the insert itself can't dangle. Use an explicit max(id)+1
// rather than the identity sequence, which batchInsert left unadvanced on
// Postgres (a sequence-based insert could collide with a restored id).
const snapshot = { ...currentAdmin };
delete snapshot.id;
if ('created_by' in snapshot) snapshot.created_by = null;
const maxRow = await trx('admin_users').max({ m: 'id' }).first();
snapshot.id = (Number(maxRow && maxRow.m) || 0) + 1;
await trx('admin_users').insert(snapshot);
return snapshot.id;
}
}
// Capture the operator's role and its granted permission NAMES before the wipe,
// so preserveOperatorRole() can re-establish the operator's authorization after
// the RBAC tables are replaced. Permission NAMES (not ids) are captured because
// the restored permissions table reassigns ids. Returns null if the operator
// has no role.
async function captureOperatorRole(roleId) {
if (!roleId) return null;
const role = await db('roles').where({ id: roleId }).first();
if (!role) return null;
const permissions = await db('role_permissions')
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
.where('role_permissions.role_id', roleId)
.pluck('permissions.name');
return { role, permissions };
}
// Restore the operator's authorization after roles/role_permissions are
// replaced. A restore rewrites the RBAC tables, so the operator's pre-restore
// role_id may now name a different (or missing) role — a crafted backup could
// silently downgrade them, and reinjectCurrentAdmin deliberately does NOT copy
// role_id (it could dangle). Here we resolve the role by NAME against the
// restored data: if a role with the operator's role name exists we trust it
// (it's the backup the operator chose to restore); otherwise we re-create the
// role from the captured snapshot and re-grant the captured permissions that
// still exist, so the operator can never be locked out of their own instance.
async function preserveOperatorRole(trx, operatorId, snapshot) {
if (!operatorId || !snapshot || !snapshot.role) return;
const { role, permissions } = snapshot;
let target = await trx('roles').whereRaw('lower(name) = lower(?)', [role.name]).first();
if (!target) {
const roleRow = { ...role };
delete roleRow.id;
const maxRole = await trx('roles').max({ m: 'id' }).first();
const newRoleId = (Number(maxRole && maxRole.m) || 0) + 1; // sequence resynced post-commit
roleRow.id = newRoleId;
await trx('roles').insert(roleRow);
if (permissions && permissions.length) {
const perms = await trx('permissions').whereIn('name', permissions).select('id');
if (perms.length) {
await trx('role_permissions').insert(
perms.map((p) => ({ role_id: newRoleId, permission_id: p.id }))
);
}
}
target = { id: newRoleId };
}
await trx('admin_users').where({ id: operatorId }).update({ role_id: target.id });
}
// Fast-forward each restored table's Postgres identity sequence to its current
// max(id). batchInsert writes explicit ids without advancing the sequence, so
// the next natural insert into any restored table (a new event, an accepted
// invitation, etc.) would otherwise collide on the primary key. Runs AFTER the
// restore transaction commits (setval is non-transactional and would survive a
// rollback) and guards every table with a column-existence check —
// pg_get_serial_sequence RAISES on a table lacking an `id` column (e.g. the
// composite-key role_permissions), so an unguarded call would abort here.
// No-op on SQLite, whose AUTOINCREMENT tracks the high-water mark itself.
async function resyncSequences(tables) {
if (!isPostgres()) return;
for (const table of tables) {
try {
if (!(await db.schema.hasColumn(table, 'id'))) continue;
const res = await db.raw('SELECT pg_get_serial_sequence(?, ?) AS seq', [table, 'id']);
const seq = res && res.rows && res.rows[0] && res.rows[0].seq;
if (!seq) continue; // `id` isn't a serial/identity column
await db.raw(
'SELECT setval(?, (SELECT COALESCE(MAX(id), 1) FROM ??), (SELECT MAX(id) IS NOT NULL FROM ??))',
[seq, table, table]
);
} catch (err) {
logger.warn(`[picpeak-import] could not resync sequence for ${table}: ${err.message}`);
}
}
}
// AUTH-critical admin_users columns preserved when overwriting a restored row
// that shares the operator's email. Deliberately excludes relationship/audit
// FKs (role_id, created_by) — see reinjectCurrentAdmin for why.
const PRESERVED_AUTH_FIELDS = [
'username', 'email', 'password_hash', 'is_active', 'must_change_password',
'two_factor_enabled', 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_enrolled_at',
];
// The json/jsonb columns of a table (Postgres only). The pg driver returns
// jsonb as parsed JS values, so on re-insert they must be serialised back to
// valid JSON text — otherwise a scalar like the string "PicPeak" is sent
@@ -127,7 +268,7 @@ function serialiseJsonColumns(rows, jsonCols) {
// session_replication_role=replica on the trx connection, reset before commit;
// sqlite: defer_foreign_keys so checks run at commit). knex_migrations is never
// in the data set, so the target's schema/migration state is left intact.
async function replaceAllTables(tables, dataDir, currentAdmin) {
async function replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot) {
await db.transaction(async (trx) => {
if (isPostgres()) {
try {
@@ -157,7 +298,10 @@ async function replaceAllTables(tables, dataDir, currentAdmin) {
await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100);
}
await reinjectCurrentAdmin(trx, currentAdmin);
const operatorId = await reinjectCurrentAdmin(trx, currentAdmin);
if (operatorId && roleSnapshot) {
await preserveOperatorRole(trx, operatorId, roleSnapshot);
}
// Reset the pg session flag BEFORE the connection returns to the pool.
if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'");
@@ -227,11 +371,18 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
const currentAdmin = currentAdminId
? await db('admin_users').where({ id: currentAdminId }).first()
: null;
// Capture the operator's role + granted permission names BEFORE the wipe so
// their authorization can be re-established after the RBAC tables are replaced.
const roleSnapshot = currentAdmin ? await captureOperatorRole(currentAdmin.role_id) : null;
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-import-'));
try {
const zip = new StreamZip.async({ file: picpeakPath });
try {
// Reject ZIP-slip entries before extracting — a crafted .picpeak could
// otherwise write outside the staging dir via `../` entry names
// (same class as GHSA-jfhw-fj23-fx6x).
assertZipEntriesWithin(Object.values(await zip.entries()), staging);
await zip.extract(null, staging);
} finally {
await zip.close();
@@ -251,7 +402,16 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
logger.warn(`[picpeak-import] ignoring ${skipped.length} backup table(s) not present in this DB (or protected): ${skipped.join(', ')}`);
}
await replaceAllTables(tables, dataDir, currentAdmin);
await replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot);
// Post-commit fixups (must NOT run inside the restore transaction):
// - resync Postgres identity sequences left behind by the explicit-id
// batchInsert, so the next natural insert doesn't collide;
// - stamp a global session cutoff so every JWT issued before this restore
// (admin, customer, gallery) stops authenticating — ids may have shifted.
await resyncSequences(tables);
await setSessionsValidAfter(Math.floor(Date.now() / 1000));
const filesRestored = await restoreFiles(staging);
const usesExternalMedia = await detectExternalMedia();
@@ -268,4 +428,8 @@ module.exports = {
importFromPicpeak,
readManifestFromZip,
validateManifest,
reinjectCurrentAdmin,
captureOperatorRole,
preserveOperatorRole,
resyncSequences,
};
+1 -19
View File
@@ -34,6 +34,7 @@ const { cleanNetMinor } = require('../utils/invoiceRounding');
const { AppError } = require('../utils/errors');
const { formatBoolean } = require('../utils/dbCompat');
const { nextDocumentNumber } = require('../utils/documentSequences');
const { resolveDefaultEventType } = require('./eventTypeService');
const { formatShortDate } = require('../utils/dateFormatter');
const businessProfileService = require('./businessProfileService');
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
@@ -308,25 +309,6 @@ async function nextQuoteNumber(trx) {
return nextDocumentNumber('quote', 'crm_quotes_number_format', 'Q-{YEAR}-{SEQ:04d}', trx);
}
/**
* Resolve the fallback event type for a quote→event conversion when the quote
* itself carries none. Never hardcodes a specific slug (any of them, incl.
* 'other', can be disabled by the admin): prefer the generic 'other' catch-all
* when it's active, else the first active type by display order, and only fall
* back to the literal 'other' if the catalog is somehow empty/unreadable.
*/
async function resolveDefaultEventType(conn) {
const q = conn || db;
try {
const other = await q('event_types').where({ slug_prefix: 'other', is_active: true }).first('slug_prefix');
if (other) return 'other';
const firstActive = await q('event_types').where({ is_active: true }).orderBy('display_order', 'asc').first('slug_prefix');
return firstActive?.slug_prefix || 'other';
} catch (_) {
return 'other';
}
}
function ensureCustomerFeatureEnabled(customer, feature) {
// Global toggle (`customer_feature_quotes_enabled` / `..._bills_enabled`)
// is checked at the route layer (feature flag); here we only enforce
+28 -1
View File
@@ -20,6 +20,26 @@ const { formatBoolean } = require('../utils/dbCompat');
// is permanently closed once setup is done — safe even on a public IP.
const SETUP_TOKEN_KEY = 'setup_token';
// One-way flag flipped when the setup wizard finishes (migration 161 marks it
// completed on installs that predate the wizard's event-types step). While it
// is unset — i.e. only during the first-run wizard — the seeded SYSTEM event
// types may be deleted (eventTypeService.deleteEventType), because nothing
// can reference them yet. Once true, system types are permanently protected.
const SETUP_WIZARD_COMPLETED_KEY = 'setup_wizard_completed';
async function isSetupWizardCompleted() {
// Fail closed: only an explicit stored `false` (seeded by migration 161 on
// a fresh, admin-less install) opens the deletion window. A missing row —
// e.g. app_settings replaced by a portable-backup restore that predates the
// migration, which will not rerun — means a configured instance, not a
// first run.
return (await getAppSetting(SETUP_WIZARD_COMPLETED_KEY)) !== false;
}
async function markSetupWizardCompleted() {
await upsertAppSetting(SETUP_WIZARD_COMPLETED_KEY, JSON.stringify(true), 'boolean');
}
async function noAdminExists() {
const row = await db('admin_users').count({ c: '*' }).first();
return Number(row?.c || 0) === 0;
@@ -173,4 +193,11 @@ async function createInitialAdmin({ token, email, password, ip }) {
};
}
module.exports = { getSetupStatus, ensureSetupToken, verifySetupToken, createInitialAdmin };
module.exports = {
getSetupStatus,
ensureSetupToken,
verifySetupToken,
createInitialAdmin,
isSetupWizardCompleted,
markSetupWizardCompleted,
};
+70
View File
@@ -5,9 +5,18 @@ const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
const MAX_ALLOWED_FILES_PER_UPLOAD = 2000;
const CACHE_TTL_MS = 60_000;
// Per-file upload size limit (general_max_file_size_mb). The admin sets this in
// Settings → General; the default mirrors the frontend's default (50 MB). A
// hard ceiling keeps a fat-fingered value from disabling multer's guard.
const DEFAULT_MAX_FILE_SIZE_MB = 50;
const MAX_ALLOWED_FILE_SIZE_MB = 10 * 1024; // 10 GB — matches the admin path's cap
let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
let cacheExpiresAt = 0;
let cachedFileSizeMb = DEFAULT_MAX_FILE_SIZE_MB;
let fileSizeCacheExpiresAt = 0;
// Map of file extension to MIME type(s)
const EXTENSION_TO_MIME = {
'jpg': 'image/jpeg',
@@ -20,6 +29,16 @@ const EXTENSION_TO_MIME = {
'webm': 'video/webm',
'mov': 'video/quicktime',
'avi': 'video/x-msvideo',
// HEIC/HEIF (iPhone). Sharp's bundled libvips decodes `heif` input, so
// thumbnails generate fine. (iOS Safari usually transcodes to JPEG at file
// selection, but a genuine .heic upload is handled when it does arrive.)
'heic': 'image/heic',
'heif': 'image/heif',
// Camera RAW / Apple ProRAW. Not sharp-decodable directly — the processing
// pipeline extracts the embedded JPEG preview (exiftool) for thumbnails/
// display, keeping the original for download. Browsers send DNG as
// image/x-adobe-dng, image/tiff, or an empty type, so accept the common set.
'dng': 'image/x-adobe-dng',
};
const DEFAULT_ALLOWED_FILE_TYPES = 'jpg,jpeg,png,webp';
@@ -99,6 +118,52 @@ const clearMaxFilesPerUploadCache = () => {
cacheExpiresAt = 0;
};
const normalizeFileSizeMb = (value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return DEFAULT_MAX_FILE_SIZE_MB;
}
const intValue = Math.floor(value);
if (intValue < 1) return DEFAULT_MAX_FILE_SIZE_MB;
if (intValue > MAX_ALLOWED_FILE_SIZE_MB) return MAX_ALLOWED_FILE_SIZE_MB;
return intValue;
};
/**
* Per-file upload size limit in MB (general_max_file_size_mb). Cached 60s, same
* as the other upload settings. Falls back to the default on a read error.
*/
const getMaxFileSizeMb = async () => {
if (Date.now() < fileSizeCacheExpiresAt) {
return cachedFileSizeMb;
}
try {
const setting = await db('app_settings')
.where({ setting_key: 'general_max_file_size_mb' })
.first();
const parsedValue = normalizeFileSizeMb(parseSettingValue(setting));
cachedFileSizeMb = parsedValue;
fileSizeCacheExpiresAt = Date.now() + CACHE_TTL_MS;
return parsedValue;
} catch (error) {
logger.error('Failed to read max file size setting:', error.message);
cachedFileSizeMb = DEFAULT_MAX_FILE_SIZE_MB;
fileSizeCacheExpiresAt = Date.now() + CACHE_TTL_MS;
return DEFAULT_MAX_FILE_SIZE_MB;
}
};
/** Per-file upload size limit in bytes — convenience for multer `limits.fileSize`. */
const getMaxFileSizeBytes = async () => {
const mb = await getMaxFileSizeMb();
return mb * 1024 * 1024;
};
const clearMaxFileSizeCache = () => {
fileSizeCacheExpiresAt = 0;
};
/**
* Convert a comma-separated list of file extensions into an array of MIME types.
* Unknown extensions are silently ignored.
@@ -164,11 +229,16 @@ const clearAllowedTypesCache = () => {
module.exports = {
getMaxFilesPerUpload,
clearMaxFilesPerUploadCache,
getMaxFileSizeMb,
getMaxFileSizeBytes,
clearMaxFileSizeCache,
getAllowedMimeTypes,
clearAllowedTypesCache,
extensionsToMimeTypes,
EXTENSION_TO_MIME,
DEFAULT_MAX_FILES_PER_UPLOAD,
MAX_ALLOWED_FILES_PER_UPLOAD,
DEFAULT_MAX_FILE_SIZE_MB,
MAX_ALLOWED_FILE_SIZE_MB,
DEFAULT_ALLOWED_FILE_TYPES
};
@@ -459,6 +459,13 @@ async function resetAdminPassword(id, resetById) {
throw new NotFoundError('Admin user', id);
}
// OIDC-owned accounts (#798) have no usable local password by design —
// minting one here would hand out a login that bypasses the IdP's MFA
// and access policies.
if (user.auth_provider === 'oidc') {
throw new ValidationError('This account is managed by your identity provider (SSO) — reset the password there.');
}
const newPassword = generateReadablePassword();
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
@@ -11,7 +11,7 @@
const { db } = require('../database/db');
const watermarkService = require('./watermarkService');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
const { withLocalCopy } = require('./imageProcessor');
const { withLocalCopy, isRawFilename } = require('./imageProcessor');
const logger = require('../utils/logger');
class WatermarkGeneratorService {
@@ -52,6 +52,14 @@ class WatermarkGeneratorService {
return { success: false, error: 'Videos do not support watermarks' };
}
// Skip RAW/DNG (experimental, #821). The watermark path opens the original
// with sharp, which can't decode RAW — proceeding would fall back to the
// original bytes and falsely record the copy as watermarked. Skipping keeps
// the watermark state honest until RAW watermarking is properly supported.
if (isRawFilename(photo.filename)) {
return { success: false, error: 'RAW/DNG files are not watermarked yet' };
}
// Get watermark settings
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
+63
View File
@@ -0,0 +1,63 @@
/**
* Category order resolution (#782).
*
* Resolves an event's categories into their effective display order, layering:
* 1. per-event override — event_category_order.position, when the event has
* been customised;
* 2. the global default — photo_categories.display_order (migration 159);
* 3. name.
*
* Globals and event-specific categories are ordered together so a custom order
* can interleave them into the flow of the day. Shared by the admin event view
* and the public gallery so the two never diverge.
*/
const { db } = require('../database/db');
const { formatBoolean } = require('./dbCompat');
const { hasColumnCached } = require('./schemaCache');
/**
* @param {number|string} eventId
* @param {object} [opts]
* @param {number[]|null} [opts.onlyIds] restrict to these category ids (the
* public gallery only shows categories that actually have photos).
* @param {string[]|null} [opts.select] qualified columns to select (default
* `c.*`). Always aliased to the `photo_categories as c` table.
* @returns rows with an added `override_position` (null when not customised).
*/
async function getEventCategoriesOrdered(eventId, { onlyIds = null, select = null } = {}) {
const eid = parseInt(eventId, 10);
const base = db('photo_categories as c').where(function () {
this.where('c.is_global', formatBoolean(true)).orWhere('c.event_id', eid);
});
if (onlyIds) base.whereIn('c.id', onlyIds);
// Fail safe: if the override table isn't present yet (half-applied migration),
// fall back to the global-default order so the public gallery never 500s.
const overrideReady = await hasColumnCached('event_category_order', 'position');
if (!overrideReady) {
return base
.select(select || 'c.*')
.orderBy('c.is_global', 'desc')
.orderBy('c.display_order', 'asc')
.orderBy('c.name', 'asc');
}
const cols = select ? [...select] : ['c.*'];
cols.push('o.position as override_position');
return base
.leftJoin('event_category_order as o', function () {
this.on('o.category_id', 'c.id').andOnVal('o.event_id', '=', eid);
})
.select(cols)
// Overridden categories first (in their pinned order), then the rest by the
// global default. CASE keeps NULL-ordering portable across SQLite + Postgres.
.orderByRaw('CASE WHEN o.position IS NULL THEN 1 ELSE 0 END ASC')
.orderBy('o.position', 'asc')
.orderBy('c.is_global', 'desc')
.orderBy('c.display_order', 'asc')
.orderBy('c.name', 'asc');
}
module.exports = { getEventCategoriesOrdered };
+33 -20
View File
@@ -79,6 +79,39 @@ const ALLOWED_IMAGE_TYPES = {
extensions: ['.svg'],
// SVG files are XML-based text files, so we skip magic number validation
magicNumbers: null
},
// HEIC/HEIF (iPhone). ISO-BMFF container: bytes 4-7 are the "ftyp" box marker,
// present in every HEIF/HEIC file (single entry — the magic check is `.every`,
// so alternatives can't be listed as separate entries). Sharp's libvips
// decodes these; extension + MIME are already gated by validateFileType.
'image/heic': {
extensions: ['.heic'],
magicNumbers: [
{ offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // "ftyp"
]
},
'image/heif': {
extensions: ['.heif'],
magicNumbers: [
{ offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // "ftyp"
]
},
// Camera RAW / Apple ProRAW (#821). DNG is a TIFF container, so it carries the
// TIFF magic (little-endian "II*\0" or big-endian "MM\0*"). The pipeline can't
// sharp-decode it directly — it extracts the embedded JPEG preview (exiftool)
// for thumbnails/display while storing the original for download. Only reached
// when an admin adds `dng` to the allowed types AND the browser reports the
// DNG MIME (Chrome does; browsers that send an empty type won't get this far).
'image/x-adobe-dng': {
extensions: ['.dng'],
// Single entry: the magic check is `.every`, so listing both endianness
// variants would require BOTH to match (impossible). DNG is TIFF; Apple
// ProRAW and virtually all camera DNGs are little-endian ("II*\0"). A rare
// big-endian DNG would fail this check and be rejected — acceptable, since
// the embedded-preview extraction validates the real content downstream.
magicNumbers: [
{ offset: 0, bytes: [0x49, 0x49, 0x2A, 0x00] } // little-endian TIFF (II*\0)
]
}
};
@@ -180,25 +213,6 @@ async function validateFileContent(filePath, expectedMimeType) {
}
}
/**
* Get safe filename for storage
* @param {string} originalFilename - Original filename
* @returns {string} - Safe filename
*/
function getSafeFilename(originalFilename) {
const timestamp = Date.now();
const randomString = Math.random().toString(36).substring(2, 15);
const ext = path.extname(originalFilename).toLowerCase();
// Validate extension - including both image and video extensions
const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico', '.mp4', '.m4v', '.webm', '.mov', '.avi'];
if (!validExtensions.includes(ext)) {
throw new Error('Invalid file extension');
}
return `upload_${timestamp}_${randomString}${ext}`;
}
/**
* Create a file upload validator middleware
* @param {Object} options - Validation options
@@ -262,7 +276,6 @@ module.exports = {
isPathSafe,
validateFileType,
validateFileContent,
getSafeFilename,
createFileUploadValidator,
ALLOWED_IMAGE_TYPES,
ALLOWED_VIDEO_TYPES,
+19 -7
View File
@@ -123,20 +123,32 @@ async function getPasswordComplexitySettings() {
// Use retry wrapper to handle connection failures
const settings = await withRetry(async () => {
// Key must match what the settings UI writes: `security_` prefix +
// `password_complexity` (useSettingsState.ts saveSecurityMutation).
// The old `security_password_complexity_level` key is written by
// nothing, so the admin's choice was silently ignored.
return await db('app_settings')
.where('setting_key', 'security_password_complexity_level')
.where('setting_key', 'security_password_complexity')
.first();
});
if (!settings || !settings.setting_value) {
return 'moderate'; // Default
}
const value = typeof settings.setting_value === 'string'
? JSON.parse(settings.setting_value)
: settings.setting_value;
return value;
// Parse with fallback, mirroring getAppSetting: on SQLite the TEXT
// column returns the JSON-stringified value ('"very_strong"'), but on
// Postgres the json column comes back already decoded ('very_strong')
// — a bare JSON.parse would throw there and the outer catch would
// silently fall back to 'moderate' again.
let value = settings.setting_value;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (_) { /* already-decoded plain string — keep as-is */ }
}
return value || 'moderate';
} catch (error) {
logger.error('Failed to get password complexity settings:', error);
return 'moderate'; // Default on error - ensures app continues working
+34
View File
@@ -118,7 +118,41 @@ function assertContractPdfPath(filePath) {
]);
}
/**
* ZIP-slip guard. `node-stream-zip`'s `extract(null, root)` writes each entry
* to `path.join(root, entry.name)` without neutralising `../` — a crafted
* archive with an entry named `../../uploads/logos/evil.svg` escapes `root`
* and overwrites arbitrary files (GHSA-jfhw-fj23-fx6x). Call this with the
* entry list BEFORE extract() to reject any entry that resolves outside the
* target directory.
*
* Purely lexical (path.resolve, no realpath) because the extraction target
* does not exist on disk yet. Absolute entry names (`/etc/passwd`) resolve
* away from `root` and are caught too. Throws AppError 400 on the first
* offending entry so the whole archive is refused.
*
* @param {Array<{name?: string}>} entries node-stream-zip entry objects
* @param {string} extractRoot directory extract() will write into
*/
function assertZipEntriesWithin(entries, extractRoot) {
const rootResolved = path.resolve(extractRoot);
const prefix = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep;
for (const entry of entries || []) {
const name = entry && entry.name;
if (!name) continue;
const target = path.resolve(rootResolved, name);
if (target !== rootResolved && !target.startsWith(prefix)) {
throw new AppError(
`Archive contains an entry that escapes the extraction directory: ${name}`,
400,
'ZIP_SLIP'
);
}
}
}
module.exports = {
assertPathInside,
assertContractPdfPath,
assertZipEntriesWithin,
};
+100
View File
@@ -0,0 +1,100 @@
/**
* Global session cutoff.
*
* A .picpeak restore rewrites admin_users / customer_accounts / events and can
* reassign their primary keys, so any JWT issued BEFORE the restore may now
* resolve to a different restored principal (auth middleware binds a token to
* `decoded.id`; IP is only logged and the backup controls each row's
* `password_changed_at`). Revoking the single importing token is not enough —
* every pre-restore admin, customer, and gallery session must stop being
* honoured.
*
* We record a single unix-second cutoff in app_settings and reject any token
* whose `iat` predates it, across all three JWT auth paths. The operator's
* forced re-login mints a token with `iat >= cutoff`, so it passes; everything
* issued earlier is refused. The value is cached briefly so the common auth
* path stays a single in-memory comparison.
*/
const { db } = require('../database/db');
const logger = require('./logger');
const CUTOFF_KEY = 'security_sessions_valid_after';
const CACHE_MS = 30 * 1000; // restores are rare; a short TTL keeps auth cheap
let cache = null; // { value: number, expiry: number }
async function readCutoffFromDb() {
const row = await db('app_settings')
.where('setting_key', CUTOFF_KEY)
.first()
.timeout(5000);
if (!row || row.setting_value == null) return 0;
let value = row.setting_value;
// pg `json` returns a parsed number; sqlite returns the stored string.
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch (_) { /* fall through to parseInt */ }
}
const seconds = parseInt(value, 10);
return Number.isFinite(seconds) ? seconds : 0;
}
/**
* Cutoff as unix seconds (0 = no cutoff set). Cached for CACHE_MS. On a
* transient DB error, returns the last known value (or 0) rather than blocking
* auth — the cutoff is defence-in-depth layered on top of per-token revocation.
*/
async function getSessionsValidAfter() {
const now = Date.now();
if (cache && now < cache.expiry) return cache.value;
try {
const value = await readCutoffFromDb();
cache = { value, expiry: now + CACHE_MS };
return value;
} catch (err) {
logger.warn('[sessionCutoff] failed to read cutoff:', err.message);
return cache ? cache.value : 0;
}
}
/** Persist a new cutoff (unix seconds) and refresh the in-process cache. */
async function setSessionsValidAfter(unixSeconds) {
await db('app_settings')
.insert({
setting_key: CUTOFF_KEY,
setting_value: JSON.stringify(unixSeconds),
setting_type: 'number',
updated_at: new Date(),
})
.onConflict('setting_key')
.merge({ setting_value: JSON.stringify(unixSeconds), setting_type: 'number', updated_at: new Date() });
cache = { value: unixSeconds, expiry: Date.now() + CACHE_MS };
}
/**
* True when this token was issued before the global cutoff. Fail-open on any
* error: the cutoff is defence-in-depth on top of per-token revocation and the
* post-restore cookie clear, and must never turn a transient read failure into
* an auth outage.
*/
async function isTokenBeforeCutoff(decoded) {
try {
if (!decoded || !decoded.iat) return false;
const cutoff = await getSessionsValidAfter();
if (!cutoff) return false;
return decoded.iat < cutoff;
} catch (err) {
logger.warn('[sessionCutoff] check failed, allowing token:', err.message);
return false;
}
}
/** Test-only: drop the in-process cache. */
function _resetCache() { cache = null; }
module.exports = {
CUTOFF_KEY,
getSessionsValidAfter,
setSessionsValidAfter,
isTokenBeforeCutoff,
_resetCache,
};
+2
View File
@@ -100,6 +100,8 @@ services:
- STORAGE_PATH=/app/storage
- PHOTOS_DIR=/app/storage/events
- PICPEAK_RELEASE_CHANNEL=${PICPEAK_CHANNEL:-stable}
# Watch-folder auto-import: max photos processed in parallel (default 2).
- FILE_WATCHER_CONCURRENCY=${FILE_WATCHER_CONCURRENCY:-2}
volumes:
- ${APP_STORAGE}:/app/storage
- ${LOGS}:/app/logs
+11
View File
@@ -60,9 +60,14 @@ services:
- SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM:-noreply@picpeak.local}
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
# Public API origin for split-origin deployments (#798 SSO redirect_uri).
# Empty = same origin as FRONTEND_URL (the standard proxied setup).
- API_URL=${API_URL:-}
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
- TZ=${TZ:-UTC}
- STORAGE_PATH=/app/storage
# Watch-folder auto-import: max photos processed in parallel (default 2).
- FILE_WATCHER_CONCURRENCY=${FILE_WATCHER_CONCURRENCY:-2}
# No `user:` directive — as of #484, the container starts as root,
# chowns the bind mounts to nodejs (UID 1001), then drops privileges
# via su-exec. PUID/PGID env vars are no longer read; if you need
@@ -141,10 +146,16 @@ services:
networks:
- picpeak-network
# Local mail catcher for development/testing only — never wanted in a real
# deployment. Gated behind the `dev` profile so a plain `docker compose up -d`
# does NOT start it; opt in with `docker compose --profile dev up -d`. Nothing
# depends on it (SMTP_HOST comes from .env), so gating is safe.
mailhog:
image: mailhog/mailhog:latest
container_name: picpeak-mailhog
restart: unless-stopped
profiles:
- dev
ports:
- "${MAILHOG_SMTP_PORT:-1025}:1025"
- "${MAILHOG_UI_PORT:-8025}:8025"
+17 -7
View File
@@ -29,14 +29,24 @@ COPY . .
# Build the application
RUN npm run build
# Production stage (Alpine 3.23 with OpenSSL 3.5.5, patched libexpat)
FROM nginx:1.28-alpine
# Production stage (nginx stable 1.30 on Alpine 3.24). The 1.28 base is a
# dead end for the nginx HTTP/2 + rewrite/charset CVEs (CVE-2026-42055 /
# -49975 / -9256 / -48142): nginx.org's nginx-module-* packages pin the exact
# nginx version, so `apk upgrade` can never pull Alpine's patched 1.28.3-r4 —
# nginx fixes have to come via the base image tag, not apk.
FROM nginx:1.30-alpine
# Upgrade all Alpine packages for security fixes. The explicit nginx upgrade
# closes the HTTP/2 + rewrite/charset CVEs (CVE-2026-42055 / -49975 / -9256 /
# -48142, fixed in nginx 1.28.3-r4) and busts any cached layer still carrying
# the vulnerable r1 build.
RUN apk upgrade --no-cache && apk add --no-cache --upgrade nginx
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
# stage's declaration never reached this stage. Consuming it in the RUN below
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
# image always picks up current Alpine security updates. Without this, the
# upgrade layer was cached indefinitely and builds kept shipping curl 8.19.0 /
# c-ares 1.34.6 for weeks after fixed packages landed in the Alpine repo.
ARG CACHEBUST=1
# Upgrade all Alpine packages for security fixes (nginx itself is version-
# pinned by its module packages — see the FROM comment above).
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
# Install runtime dependencies. `gettext` provides envsubst, used by
# docker-entrypoint.sh for the BRAND_TITLE / BRAND_DESCRIPTION runtime
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.45.0",
"version": "3.92.2-beta.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -15,11 +15,13 @@ import {
Workflow,
PanelLeftClose,
PanelLeftOpen,
Github,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { settingsService } from '../../services/settings.service';
import { VersionInfo } from './VersionInfo';
import { repoUrl } from '../../utils/githubReleaseUrl';
import { usePermissions } from '../../contexts/PermissionsContext';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
@@ -324,6 +326,20 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, col
{/* Storage Info */}
<StorageInfo />
{/* Link to the project on GitHub (#778). Subtle footer row so
admins can reach the repo — star, source, report an issue —
from anywhere in the dashboard, not just the setup screen. */}
<a
href={repoUrl}
target="_blank"
rel="noopener noreferrer"
className="mx-4 mb-3 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors"
title={t('admin.viewOnGithub', 'View PicPeak on GitHub')}
>
<Github className="w-3.5 h-3.5" />
<span>{t('admin.viewOnGithub', 'View PicPeak on GitHub')}</span>
</a>
</div>
)}
</div>
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
import { Plus, Edit2, Trash2, Loader2, ArrowUp, ArrowDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { Button } from '../common';
@@ -13,12 +13,36 @@ export const CategoryManager: React.FC = () => {
const [newCategoryName, setNewCategoryName] = useState('');
const [editingName, setEditingName] = useState('');
// Fetch global categories
// Fetch global categories (ordered by the global default display_order)
const { data: categories = [], isLoading } = useQuery({
queryKey: ['global-categories'],
queryFn: categoriesService.getGlobalCategories,
});
// Local copy so the up/down reorder buttons feel instant; resynced when the
// query data changes.
const [ordered, setOrdered] = useState<PhotoCategory[]>(categories);
useEffect(() => {
setOrdered(categories);
}, [categories]);
// Set the GLOBAL default order (#782). Applies to every gallery that hasn't
// set its own per-event override.
const reorderMutation = useMutationWithToast({
mutationFn: (orderedIds: number[]) => categoriesService.reorderGlobalCategories(orderedIds),
invalidateKeys: [['global-categories']],
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
});
const handleMove = (index: number, dir: -1 | 1) => {
const target = index + dir;
if (target < 0 || target >= ordered.length) return;
const next = [...ordered];
[next[index], next[target]] = [next[target], next[index]];
setOrdered(next); // optimistic
reorderMutation.mutate(next.map((c) => c.id));
};
// Create category mutation
const createMutation = useMutationWithToast({
mutationFn: (name: string) =>
@@ -144,12 +168,12 @@ export const CategoryManager: React.FC = () => {
{/* Categories list */}
<div className="space-y-2">
{categories.length === 0 ? (
{ordered.length === 0 ? (
<p className="text-neutral-500 dark:text-neutral-400 text-center py-8">
{t('categories.noCategoriesYet')}
</p>
) : (
categories.map((category) => (
ordered.map((category, index) => (
<div
key={category.id}
className="flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 transition-colors"
@@ -189,9 +213,33 @@ export const CategoryManager: React.FC = () => {
</div>
) : (
<>
<div>
<p className="font-medium text-neutral-900 dark:text-neutral-100">{category.name}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{category.slug}</p>
<div className="flex items-center gap-2 min-w-0">
{/* Global default order (#782). The gallery uses this order
unless a specific event overrides it. */}
<div className="flex flex-col -space-y-1">
<button
onClick={() => handleMove(index, -1)}
disabled={index === 0 || reorderMutation.isPending}
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
title={t('categories.moveUp', 'Move up')}
aria-label={t('categories.moveUp', 'Move up')}
>
<ArrowUp className="w-4 h-4" />
</button>
<button
onClick={() => handleMove(index, 1)}
disabled={index === ordered.length - 1 || reorderMutation.isPending}
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
title={t('categories.moveDown', 'Move down')}
aria-label={t('categories.moveDown', 'Move down')}
>
<ArrowDown className="w-4 h-4" />
</button>
</div>
<div className="min-w-0">
<p className="font-medium text-neutral-900 dark:text-neutral-100 truncate">{category.name}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400 truncate">/{category.slug}</p>
</div>
</div>
<div className="flex gap-1">
<button
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud } from 'lucide-react';
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { photosService } from '../../services/photos.service';
import { Button, Card, AuthenticatedImage } from '../common';
@@ -17,7 +17,8 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
const [newCategoryName, setNewCategoryName] = useState('');
const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null);
// Fetch categories for this event
// Fetch this event's categories (globals + event-specific), already resolved
// to the event's effective order by the backend (#782).
const { data: categories = [], isLoading } = useQuery({
queryKey: ['event-categories', eventId],
queryFn: () => categoriesService.getEventCategories(eventId),
@@ -30,17 +31,21 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
enabled: heroPickerCategoryId !== null,
});
// Filter to show only event-specific categories
const eventCategories = categories.filter(cat => !cat.is_global);
// Combined list (globals + event-specific) in the resolved order, kept in
// local state so the up/down reorder buttons feel instant; resynced whenever
// the query data changes (e.g. after a reorder or reset persists).
const [ordered, setOrdered] = useState<PhotoCategory[]>(categories);
useEffect(() => {
setOrdered(categories);
}, [categories]);
// Create category mutation
// The event is "customised" when it has its own per-event override.
const isCustomised = ordered.some((c) => c.override_position != null);
// Create category mutation (always event-specific)
const createMutation = useMutationWithToast({
mutationFn: (name: string) =>
categoriesService.createCategory({
name,
is_global: false,
event_id: eventId
}),
categoriesService.createCategory({ name, is_global: false, event_id: eventId }),
invalidateKeys: [['event-categories', eventId]],
successMessage: t('categories.categoryCreatedSuccess'),
onSuccess: () => {
@@ -71,9 +76,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
errorMessage: t('categories.failedToSetCoverPhoto'),
});
// Toggle per-category download permission (#640). The backend AND's this
// with the event-level `allow_downloads`, so disabling at either level
// blocks downloads for this category's photos.
// Toggle per-category download permission (#640). Event-specific only.
const downloadToggleMutation = useMutationWithToast({
mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) =>
categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }),
@@ -85,6 +88,32 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
errorMessage: t('categories.failedToToggleDownloads', 'Failed to update download permission'),
});
// Per-event order override (#782). Sends the full ordered id list; the backend
// pins it for this gallery only. Up/down buttons match the invoice line-item
// convention (no drag-and-drop dependency).
const reorderMutation = useMutationWithToast({
mutationFn: (orderedIds: number[]) => categoriesService.reorderCategories(eventId, orderedIds),
invalidateKeys: [['event-categories', eventId]],
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
});
// Revert this gallery to the global default order.
const resetMutation = useMutationWithToast({
mutationFn: () => categoriesService.resetEventOrder(eventId),
invalidateKeys: [['event-categories', eventId]],
successMessage: t('categories.orderReset', 'Reverted to the default order'),
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
});
const handleMove = (index: number, dir: -1 | 1) => {
const target = index + dir;
if (target < 0 || target >= ordered.length) return;
const next = [...ordered];
[next[index], next[target]] = [next[target], next[index]];
setOrdered(next); // optimistic — instant feedback
reorderMutation.mutate(next.map((c) => c.id));
};
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
@@ -105,6 +134,8 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
heroMutation.mutate({ categoryId, photoId: null });
};
const busy = reorderMutation.isPending || resetMutation.isPending;
if (isLoading) {
return (
<div className="flex justify-center items-center py-4">
@@ -115,23 +146,38 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3>
{!addingModal.isOpen && (
<Button
variant="outline"
size="sm"
onClick={addingModal.open}
leftIcon={<Plus className="w-3 h-3" />}
>
{t('common.add')}
</Button>
)}
<div className="flex justify-between items-center gap-2">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.galleryOrder', 'Gallery order')}</h3>
<div className="flex items-center gap-2">
{isCustomised && (
<Button
variant="outline"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={busy}
leftIcon={<RotateCcw className="w-3 h-3" />}
>
{t('categories.resetToDefault', 'Reset to default')}
</Button>
)}
{!addingModal.isOpen && (
<Button
variant="outline"
size="sm"
onClick={addingModal.open}
leftIcon={<Plus className="w-3 h-3" />}
>
{t('common.add')}
</Button>
)}
</div>
</div>
{/* Hint about hero photo fallback */}
{/* Explain the two ordering layers */}
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
{t('categories.categoryHeroHint')}
{isCustomised
? t('categories.orderCustomisedHint', 'This gallery uses a custom order. Reset to follow the global default (Settings → Photo Categories).')
: t('categories.orderDefaultHint', 'Use the arrows to set the order for this gallery. Otherwise it follows the global default (Settings → Photo Categories).')}
</p>
{/* Add new category form */}
@@ -152,11 +198,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
onClick={handleCreate}
disabled={!newCategoryName.trim() || createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
t('common.add')
)}
{createMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : t('common.add')}
</Button>
<Button
variant="outline"
@@ -171,14 +213,14 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
</div>
)}
{/* Event categories list */}
{eventCategories.length === 0 ? (
{/* Combined, reorderable category list (globals + event-specific) */}
{ordered.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
{t('categories.noEventSpecificCategories')}
</p>
) : (
<div className="space-y-2">
{eventCategories.map((category) => {
{ordered.map((category, index) => {
const heroPhoto = category.hero_photo_id
? photos.find(p => p.id === category.hero_photo_id)
: null;
@@ -187,7 +229,30 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
key={category.id}
className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* Reorder controls (#782). The gallery renders categories in
this order; changes here override the global default for
this event only. */}
<div className="flex flex-col -space-y-1">
<button
onClick={() => handleMove(index, -1)}
disabled={index === 0 || busy}
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
title={t('categories.moveUp', 'Move up')}
aria-label={t('categories.moveUp', 'Move up')}
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleMove(index, 1)}
disabled={index === ordered.length - 1 || busy}
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
title={t('categories.moveDown', 'Move down')}
aria-label={t('categories.moveDown', 'Move down')}
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
{/* Hero photo thumbnail */}
<button
onClick={() => setHeroPickerCategoryId(category.id)}
@@ -207,49 +272,56 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
)}
</button>
<span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span>
{category.is_global && (
<span className="flex-shrink-0 text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-neutral-500 dark:text-neutral-400">
{t('categories.sharedBadge', 'Shared')}
</span>
)}
</div>
<div className="flex items-center gap-1">
{/* Per-category downloads toggle (#640). Green DownloadCloud
icon when on, struck-through outline when off. The
event-level `allow_downloads` AND's with this — if the
whole event has downloads off, this toggle is cosmetic. */}
<button
onClick={() => downloadToggleMutation.mutate({
category,
allow: category.allow_downloads === false,
})}
className={`p-1 transition-colors ${
category.allow_downloads === false
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
}`}
title={
category.allow_downloads === false
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
}
disabled={downloadToggleMutation.isPending}
>
{downloadToggleMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : category.allow_downloads === false ? (
<Download className="w-3 h-3" />
) : (
<DownloadCloud className="w-3 h-3" />
)}
</button>
<button
onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('categories.deleteCategoryTitle')}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3" />
)}
</button>
{/* Download toggle + delete apply to event-specific categories
only. Global categories are managed in Settings. */}
{!category.is_global && (
<>
<button
onClick={() => downloadToggleMutation.mutate({
category,
allow: category.allow_downloads === false,
})}
className={`p-1 transition-colors ${
category.allow_downloads === false
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
}`}
title={
category.allow_downloads === false
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
}
disabled={downloadToggleMutation.isPending}
>
{downloadToggleMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : category.allow_downloads === false ? (
<Download className="w-3 h-3" />
) : (
<DownloadCloud className="w-3 h-3" />
)}
</button>
<button
onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('categories.deleteCategoryTitle')}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3" />
)}
</button>
</>
)}
</div>
</div>
);
@@ -257,41 +329,10 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
</div>
)}
{/* Show available global categories */}
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
<div className="space-y-2">
{categories
.filter(cat => cat.is_global)
.map(cat => {
const heroPhoto = cat.hero_photo_id
? photos.find(p => p.id === cat.hero_photo_id)
: null;
return (
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md">
<button
onClick={() => setHeroPickerCategoryId(cat.id)}
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-accent-dark transition-colors flex items-center justify-center"
title={t('categories.setCoverPhoto')}
>
{heroPhoto ? (
<AuthenticatedImage
src={heroPhoto.thumbnail_url || heroPhoto.url}
alt={cat.name}
className="w-full h-full object-cover"
/>
) : cat.hero_photo_id ? (
<ImageIcon className="w-4 h-4 text-accent" />
) : (
<ImageIcon className="w-4 h-4 text-neutral-300" />
)}
</button>
<span className="text-sm text-neutral-600 dark:text-neutral-400">{cat.name}</span>
</div>
);
})}
</div>
</div>
{/* Hint about hero photo fallback */}
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
{t('categories.categoryHeroHint')}
</p>
{/* Hero Photo Picker Modal */}
{heroPickerCategoryId !== null && (
@@ -317,7 +358,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{photos.map((photo) => {
const currentCategory = categories.find(c => c.id === heroPickerCategoryId);
const currentCategory = ordered.find(c => c.id === heroPickerCategoryId);
const isSelected = photo.id === currentCategory?.hero_photo_id;
return (
<div
@@ -337,7 +378,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
/>
</div>
{isSelected && (
<div className="absolute top-2 right-2 bg-accent-dark/150 text-white rounded-full p-1">
<div className="absolute top-2 right-2 bg-accent-dark text-white rounded-full p-1">
<Check className="w-4 h-4" />
</div>
)}
@@ -352,7 +393,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
</div>
<div className="p-6 border-t border-neutral-200 dark:border-neutral-700 flex justify-between gap-3">
{categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
{ordered.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
<Button
variant="outline"
onClick={() => handleRemoveHeroPhoto(heroPickerCategoryId)}
+11 -2
View File
@@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query';
import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../../utils/fileTypes';
import { useUploadProgress } from '../../hooks/useUploadProgress';
interface PhotoUploadProps {
@@ -118,6 +118,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
[settings?.general_allowed_file_types]
);
const formatsLabel = useMemo(
() => extensionsToLabel(settings?.general_allowed_file_types),
[settings?.general_allowed_file_types]
);
const maxFileSizeMb = Number.isFinite(Number(settings?.general_max_file_size_mb))
? Number(settings?.general_max_file_size_mb)
: 50;
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
const [isDragOver, setIsDragOver] = useState(false);
@@ -511,7 +520,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{t('upload.clickToUpload')}
</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
{t('upload.fileRequirements', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
</p>
<p
className={clsx(
@@ -16,6 +16,7 @@ interface RestoreResult {
tables: number;
filesRestored: number;
usesExternalMedia: boolean;
sessionInvalidated?: boolean;
}
// ── Download half (Dashboard) ────────────────────────────────────────────────
@@ -114,6 +115,13 @@ export const PicpeakRestoreCard: React.FC = () => {
setResult(res.data);
setPendingFile(null);
toast.success(t('backup.picpeak.restoreDone', 'Backup restored.'));
// The restore rewrote admin_users and the backend revoked our session
// (ids may have shifted). Send the operator to a fresh login rather than
// letting the now-stale token resolve to a different restored account.
if (res.data?.sessionInvalidated) {
toast.success(t('backup.picpeak.reloginRequired', 'Restore complete — please sign in again.'));
setTimeout(() => { window.location.href = '/admin/login'; }, 1500);
}
} catch (e: any) {
const msg = e.response?.data?.error || t('backup.picpeak.restoreFailed', 'Restore failed.');
toast.error(msg);
@@ -0,0 +1,240 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Plus, X } from 'lucide-react';
import { Button, Input, Loading } from '../common';
import { eventTypesService, EventType } from '../../services/eventTypes.service';
interface Props {
onDone: () => void;
}
// One editable row of the wizard's event-type list. Existing rows carry the
// catalog id; rows added in the wizard have no id until Continue POSTs them.
interface RowState {
id?: number;
name: string;
slug_prefix: string;
emoji: string;
}
const normalizeSlug = (value: string) => value.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// First-run event-types step (#800). Shown once, during the setup wizard —
// the only window in which the seeded SYSTEM types may be deleted (nothing
// references them yet; the backend re-locks them when the wizard finishes).
// Deliberately lean: name + URL prefix only. Icons, themes and ordering are
// tunable later in Settings → Event Types.
export const SetupEventTypesStep: React.FC<Props> = ({ onDone }) => {
const { t } = useTranslation();
const [rows, setRows] = useState<RowState[] | null>(null);
const [original, setOriginal] = useState<Map<number, EventType>>(new Map());
const [deletedIds, setDeletedIds] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const { isLoading, isError } = useQuery({
queryKey: ['setup-event-types'],
queryFn: async () => {
const types = await eventTypesService.getEventTypes();
// Initialize once — a re-run (remount) must not clobber in-progress edits.
setOriginal((prev) => (prev.size > 0 ? prev : new Map(types.map((et) => [et.id, et]))));
setRows((prev) => prev ?? types.map((et) => ({ id: et.id, name: et.name, slug_prefix: et.slug_prefix, emoji: et.emoji })));
return types;
},
staleTime: Infinity,
});
const setRow = (index: number, patch: Partial<RowState>) => {
setRows((prev) => (prev ? prev.map((r, i) => (i === index ? { ...r, ...patch } : r)) : prev));
};
const removeRow = (index: number) => {
// No side effects inside the setRows updater — StrictMode double-invokes
// updaters, which would enqueue the same id twice (one DELETE 404s and
// shows a false "could not save" warning).
const row = rows?.[index];
if (!row) return;
if (row.id !== undefined) {
setDeletedIds((ids) => (ids.includes(row.id!) ? ids : [...ids, row.id!]));
}
setRows((prev) => (prev ? prev.filter((_, i) => i !== index) : prev));
};
const addRow = () => {
setRows((prev) => (prev ? [...prev, { name: '', slug_prefix: '', emoji: '📷' }] : prev));
};
// Apply the diff, then advance. Ordering matters twice over: deletes first
// frees a default's slug for a rename/re-create ("replace Wedding with my
// own 'wedding'"), but deleting everything BEFORE a replacement exists could
// empty the catalog if the creation then fails. So: when at least one
// existing row is kept the catalog can never go empty → delete first; when
// the user replaces ALL types → create first and only delete once at least
// one replacement actually persisted. (The backend additionally refuses
// deleting the last remaining type.) Best-effort like the other wizard
// steps — a partial failure warns but never traps the user; everything here
// is editable later in Settings → Event Types.
const handleContinue = async () => {
if (!rows) return;
const kept = rows.filter((r) => r.name.trim() && r.slug_prefix.trim());
if (kept.length === 0) {
toast.error(t('setup.eventTypes.atLeastOne'));
return;
}
setSaving(true);
let failures = 0;
let deleteFailures = 0;
let createdOk = 0;
const applyDeletes = async () => {
for (const id of deletedIds) {
try {
await eventTypesService.deleteEventType(id);
} catch {
deleteFailures += 1;
}
}
};
const applyCreatesAndUpdates = async () => {
for (const row of kept) {
try {
if (row.id !== undefined) {
const before = original.get(row.id);
const updates: { name?: string; slug_prefix?: string } = {};
if (before && row.name.trim() !== before.name) updates.name = row.name.trim();
if (before && row.slug_prefix !== before.slug_prefix) updates.slug_prefix = row.slug_prefix;
if (Object.keys(updates).length > 0) {
await eventTypesService.updateEventType(row.id, updates);
}
} else {
await eventTypesService.createEventType({
name: row.name.trim(),
slug_prefix: row.slug_prefix,
emoji: row.emoji,
});
createdOk += 1;
}
} catch {
failures += 1;
}
}
};
const keptExisting = kept.filter((r) => r.id !== undefined).length;
if (keptExisting > 0) {
await applyDeletes();
await applyCreatesAndUpdates();
} else {
await applyCreatesAndUpdates();
if (deletedIds.length > 0 && createdOk === 0) {
// Every replacement failed — deleting now would empty the catalog.
// Keep the seeded types and stay on the step.
setSaving(false);
toast.error(t('setup.eventTypes.atLeastOne'));
return;
}
await applyDeletes();
}
// A failed DELETE must not slip past this step: system types are only
// deletable inside this window, so once the wizard finishes the request
// can never be retried. Reload the live catalog and stay for a retry.
if (deleteFailures > 0) {
try {
const types = await eventTypesService.getEventTypes();
setOriginal(new Map(types.map((et) => [et.id, et])));
setRows(types.map((et) => ({ id: et.id, name: et.name, slug_prefix: et.slug_prefix, emoji: et.emoji })));
} catch { /* keep the local rows if the reload fails */ }
setDeletedIds([]);
setSaving(false);
toast.error(t('setup.eventTypes.deleteFailed'));
return;
}
setSaving(false);
if (failures > 0) toast.warn(t('setup.eventTypes.saveFailed'));
onDone();
};
if (isLoading || rows === null) {
return isError ? (
// Catalog unreadable — don't trap the user; the defaults stay seeded and
// remain editable later in Settings → Event Types.
<div className="space-y-6">
<p className="text-sm text-neutral-600">{t('setup.eventTypes.loadFailed')}</p>
<Button type="button" variant="primary" size="lg" className="w-full" onClick={onDone}>
{t('setup.continue')}
</Button>
</div>
) : (
<Loading />
);
}
return (
<div className="space-y-6">
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
{t('setup.eventTypes.intro')}
</p>
<div className="space-y-2">
{rows.map((row, index) => (
<div key={row.id ?? `new-${index}`} className="flex items-center gap-2">
<span className="w-8 text-center text-xl flex-shrink-0" aria-hidden="true">{row.emoji}</span>
<div className="flex-1 min-w-0">
<Input
value={row.name}
onChange={(e) => setRow(index, { name: e.target.value })}
placeholder={t('setup.eventTypes.namePlaceholder')}
aria-label={t('setup.eventTypes.nameLabel')}
/>
</div>
<div className="w-32 flex-shrink-0">
<Input
value={row.slug_prefix}
onChange={(e) => setRow(index, { slug_prefix: normalizeSlug(e.target.value) })}
placeholder={t('setup.eventTypes.slugPlaceholder')}
aria-label={t('setup.eventTypes.slugLabel')}
/>
</div>
<button
type="button"
onClick={() => removeRow(index)}
className="flex-shrink-0 p-2 rounded-lg text-neutral-400 hover:text-red-600 hover:bg-red-50 transition-colors"
aria-label={t('common.delete', 'Delete')}
title={t('common.delete', 'Delete')}
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
<button
type="button"
onClick={addRow}
className="w-full rounded-lg border border-dashed border-neutral-300 p-3 text-left hover:bg-neutral-50 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-800">{t('setup.eventTypes.add')}</span>
</button>
<p className="text-xs text-neutral-500">{t('setup.eventTypes.hint')}</p>
<Button
type="button"
variant="primary"
size="lg"
isLoading={saving}
className="w-full"
onClick={handleContinue}
>
{t('setup.continue')}
</Button>
</div>
);
};
SetupEventTypesStep.displayName = 'SetupEventTypesStep';
@@ -16,11 +16,13 @@
* POST .../slideshow/{generate,disable}.
*/
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { MonitorPlay, Copy, CheckCircle, RotateCw, Trash2, Save } from 'lucide-react';
import { Button, Card } from '../common';
import { eventsService } from '../../services/events.service';
import { categoriesService } from '../../services/categories.service';
import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service';
import { SlideshowStyleFields } from './SlideshowStyleFields';
@@ -35,6 +37,8 @@ export interface SlideshowSettingsCardProps {
show_transition_ms?: number;
show_watermark?: boolean | null;
show_colorfilter?: string;
show_order?: string;
show_category_id?: number | null;
};
onChanged?: () => void;
}
@@ -52,6 +56,8 @@ function styleFromInitial(initial: SlideshowSettingsCardProps['initial']): Slide
transition_ms: initial.show_transition_ms ?? DEFAULT_SLIDESHOW_STYLE.transition_ms,
watermark: watermarkMode(initial.show_watermark),
colorfilter: (initial.show_colorfilter as SlideshowStyle['colorfilter']) ?? DEFAULT_SLIDESHOW_STYLE.colorfilter,
order: (initial.show_order as SlideshowStyle['order']) ?? DEFAULT_SLIDESHOW_STYLE.order,
category_id: initial.show_category_id ?? null,
};
}
@@ -68,6 +74,14 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
const link = token ? `${window.location.origin}/gallery/${slug}/show/${token}` : '';
// Event categories for the slideshow content filter (#202). Global + this
// event's own categories; empty for events without any → picker hides.
const { data: categories = [] } = useQuery({
queryKey: ['event-categories', eventId],
queryFn: () => categoriesService.getEventCategories(eventId),
staleTime: 60_000,
});
const generate = async () => {
setBusy(true);
try {
@@ -129,6 +143,8 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
// is global-only (Settings → Slideshow); we only send the mode here.
show_watermark: style.watermark === 'inherit' ? null : style.watermark === 'on',
show_colorfilter: style.colorfilter,
show_order: style.order,
show_category_id: style.category_id,
});
toast.success(t('slideshow.settingsSaved', 'Slideshow settings saved'));
onChanged?.();
@@ -208,7 +224,7 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
{/* Live style settings */}
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<SlideshowStyleFields value={style} onChange={setStyle} />
<SlideshowStyleFields value={style} onChange={setStyle} categories={categories} />
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('slideshow.liveHint', 'Changes apply to a running slideshow within a few seconds — no need to regenerate the link.')}
@@ -15,12 +15,17 @@ import {
SLIDESHOW_TRANSITIONS,
SLIDESHOW_COLORFILTERS,
SLIDESHOW_WATERMARK_MODES,
SLIDESHOW_ORDERS,
type SlideshowStyle,
} from '../../services/slideshow.service';
import type { PhotoCategory } from '../../services/categories.service';
export interface SlideshowStyleFieldsProps {
value: SlideshowStyle;
onChange: (next: SlideshowStyle) => void;
/** Event categories for the content filter (#202). Omitted/empty the
* category picker is hidden (e.g. events without any categories). */
categories?: PhotoCategory[];
}
const inputClass =
@@ -29,7 +34,7 @@ const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral
const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ value, onChange }) => {
export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ value, onChange, categories = [] }) => {
const { t } = useTranslation();
const set = (patch: Partial<SlideshowStyle>) => onChange({ ...value, ...patch });
@@ -92,6 +97,39 @@ export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ valu
</select>
</div>
{/* Play order + content filter (#202) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelClass}>{t('slideshow.orderLabel', 'Play order')}</label>
<select
value={value.order}
onChange={(e) => set({ order: e.target.value as SlideshowStyle['order'] })}
className={inputClass}
>
{SLIDESHOW_ORDERS.map((o) => (
<option key={o} value={o}>
{t(`slideshow.order.${o}`, o === 'random' ? 'Random (shuffle)' : 'Chronological')}
</option>
))}
</select>
</div>
{categories.length > 0 && (
<div>
<label className={labelClass}>{t('slideshow.categoryLabel', 'Show only category')}</label>
<select
value={value.category_id ?? ''}
onChange={(e) => set({ category_id: e.target.value === '' ? null : parseInt(e.target.value, 10) })}
className={inputClass}
>
<option value="">{t('slideshow.categoryAll', 'All photos')}</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
</div>
{/* Watermark MODE only. The look (logo/position/opacity/style/size)
lives in Settings Slideshow, so it isn't duplicated here. */}
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
@@ -154,39 +154,45 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
{/* Category and Feedback Filters */}
<div className="space-y-3">
{/* Categories Row */}
{categories && categories.length > 0 && (
{/* Categories + desktop feedback row. Rendered whenever EITHER part
has content: the desktop feedback chips must not depend on the
(optional) categories existing, or category-less galleries show
no feedback filter at all on desktop (#802 the lg:hidden
fallback block below only covers mobile/tablet). */}
{((categories && categories.length > 0) || (feedbackEnabled && !!onFilterChange)) && (
<div className="flex items-start lg:items-center justify-between flex-col lg:flex-row gap-3">
{/* Categories: keep in a horizontal scroll container */}
<div className="w-full overflow-x-auto pb-2 lg:pb-0">
<div className="flex items-center gap-2 min-w-max">
<Button
variant={selectedCategoryId === null ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(null)}
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
>
{showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length})
</Button>
{categories.map((category) => {
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
if (categoryPhotoCount === 0) return null;
return (
<Button
key={category.id}
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(category.id)}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
>
{category.name} ({categoryPhotoCount})
</Button>
);
})}
{categories && categories.length > 0 && (
<div className="w-full overflow-x-auto pb-2 lg:pb-0">
<div className="flex items-center gap-2 min-w-max">
<Button
variant={selectedCategoryId === null ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(null)}
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
>
{showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length})
</Button>
{categories.map((category) => {
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
if (categoryPhotoCount === 0) return null;
return (
<Button
key={category.id}
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(category.id)}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
>
{category.name} ({categoryPhotoCount})
</Button>
);
})}
</div>
</div>
</div>
)}
{/* Desktop: compact horizontal feedback filter with headline (icons only) */}
{feedbackEnabled && onFilterChange && (
@@ -244,7 +250,10 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
</div>
)}
<p className="text-xs md:text-sm text-muted-theme flex-shrink-0 ml-auto">
{/* Without categories this row only carries desktop content (the
chips are lg-only; mobile has its own block below), so hide
the count below lg to keep the mobile layout unchanged. */}
<p className={`text-xs md:text-sm text-muted-theme flex-shrink-0 ml-auto ${categories && categories.length > 0 ? '' : 'hidden lg:block'}`}>
{photoCount} {t('common.media', 'media')}
</p>
</div>
@@ -5,7 +5,7 @@ import { toast } from 'react-toastify';
import { Button } from '../common';
import { api } from '../../config/api';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../../utils/fileTypes';
interface UserPhotoUploadProps {
eventId: number;
@@ -43,6 +43,14 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
? Number(publicSettings?.general_max_files_per_upload)
: 500;
// Per-file size limit (MB). Was hardcoded to 50MB below, so the admin's
// "Max File Size" setting never applied to guests (#613 follow-up). Surfaced
// via publicSettings (default 50); the backend enforces the same value.
const maxFileSizeMb = Number.isFinite(Number(publicSettings?.general_max_file_size_mb))
? Number(publicSettings?.general_max_file_size_mb)
: 50;
const maxFileSizeBytes = maxFileSizeMb * 1024 * 1024;
const allowedMimeTypes = useMemo(
() => extensionsToMimeTypes(publicSettings?.allowed_file_types),
[publicSettings?.allowed_file_types]
@@ -53,6 +61,13 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
[publicSettings?.allowed_file_types]
);
// #821 — the requirements hint used to hardcode "JPEG, PNG or WebP"; render
// the actually-configured formats so it never contradicts what's accepted.
const formatsLabel = useMemo(
() => extensionsToLabel(publicSettings?.allowed_file_types),
[publicSettings?.allowed_file_types]
);
// Shared filter pipeline for both <input> change and drag-and-drop (#504).
const addFiles = (incoming: File[]) => {
const validFiles = incoming.filter((file) => {
@@ -60,9 +75,9 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
toast.error(`Invalid file type: ${file.name}`);
return false;
}
// Check file size (50MB max)
if (file.size > 50 * 1024 * 1024) {
toast.error(`File too large: ${file.name}`);
// Check file size against the configured per-file limit.
if (file.size > maxFileSizeBytes) {
toast.error(t('upload.fileTooLarge', { name: file.name, limit: maxFileSizeMb }));
return false;
}
return true;
@@ -228,7 +243,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
{/* #613 pass { limit } so `{{limit}}` interpolates
with the real number from settings instead of
rendering literally. */}
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
{t('upload.fileRequirements', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
</p>
<input
type="file"

Some files were not shown because too many files have changed in this diff Show More