Compare commits

..

31 Commits

Author SHA1 Message Date
Paul Nothaft c5907a0833 fix(backend): set multer's fieldArrayIndexLimit to actually close CVE-2026-82333
The advisory is explicit that the 2.3.0 version bump alone doesn't
remediate the array-index DoS — an app must also set
limits.fieldArrayIndexLimit. Set it on every multer instance, sized to
what each route's form actually needs.
2026-09-10 23:37:03 +02:00
Paul Nothaft f13d163c74 fix(backend): bump sharp, nodemailer, multer, js-yaml, joi for security fixes
Resolves 12 open code-scanning alerts (#589-600): sharp libheif RCE,
nodemailer address-parser ReDoS + domain-validation bypasses, multer
upload DoS/race conditions, js-yaml parsing DoS, and joi prototype
pollution. All patch/minor bumps within the currently used major
version.
2026-09-10 17:44:21 +02:00
Paul Nothaft c4d89c9d64 chore(main): release 3.131.3-beta.0 (#1373)
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 / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-09-10 11:54:50 +00:00
Paul Nothaft a2bf1f644c fix(video): try metadata extraction and thumbnail generation independently (#1371)
* fix(video): try metadata extraction and thumbnail generation independently

processUploadedVideo() gated everything behind isValidVideo(), which
rejects the whole video if ffprobe can't read even one of
duration/width/height -- common on some iPhone/Lightroom-exported
MP4s (issue 1370). Callers (photoProcessor.js's processPhoto and
processUploadedPhotos) already catch that throw and fall back to a
static placeholder thumbnail plus a metadata-only retry (codex
review of #845), but that fallback never got a REAL thumbnail even
when generateVideoThumbnail() would have succeeded on its own --
thumbnailing doesn't need valid duration/width/height, it just seeks
and grabs a frame.

processUploadedVideo now tries metadata extraction and thumbnail
generation independently, keeping whichever succeeds instead of
discarding both on a single failed field. The callers' existing
throw handling stays as a backstop.

Also: extractVideoMetadata stored duration as 0 (not null) whenever
ffprobe had no duration field, masking "unknown" as a fake real
zero-second clip and defeating downstream `duration != null` checks
meant to skip an untrustworthy value.

Relates to issue 1370

* fix(video): fall back to the SVG placeholder when thumbnail generation fails

processUploadedVideo could return success with thumbnailKey: null
when only thumbnail generation failed. The gallery grid
(GridGalleryLayout/JustifiedGalleryLayout) falls back to
`photo.thumbnail_url || photo.url` when there's no thumbnail, so
AuthenticatedImage downloaded the full original video and tried to
render it as an <img> -- a broken tile and a potentially huge
fetch just from opening the gallery.

Falls back to the same ffmpeg-free SVG placeholder the callers
already generate for a total processing failure, so a bare
thumbnail-generation failure degrades to that placeholder too,
never to "no thumbnail at all".

Found by codex review.

* fix(video): avoid a SQLite connection deadlock in the placeholder fallback

generateVideoPlaceholder() unconditionally called getThumbnailSettings(),
which queries the database directly (not through any active transaction).
videoProcessor.js's new placeholder fallback can run from inside
processUploadedPhotos' open per-file SQLite transaction (chunked video
upload) -- knex's default SQLite pool has exactly one connection, so
that second, un-transacted query deadlocks against the transaction
holding it, timing out after acquireConnectionTimeout (60s). Reproduced
directly against an isolated SQLite db.

generateVideoPlaceholder now skips the settings lookup entirely when
the caller supplies explicit width/height, and the video fallback
passes the same DEFAULT_THUMBNAIL_WIDTH/HEIGHT the settings lookup
would have fallen back to anyway (now exported for reuse).

Found by codex review.

* fix(video): throw when neither a real thumbnail nor the placeholder can be produced

processUploadedVideo returned success with thumbnailKey: null when
both the real thumbnail AND the SVG placeholder failed -- a total,
systemic failure (storage backend down, disk full), not a quirk of
one file. On stable, which doesn't have the #845 call-site fallback,
this silently completed the video with no thumbnail at all instead
of the retryable 'failed' status a throw here produces. On main,
the pre-existing #845 fallback already absorbed this exact case
(no behavior change there) -- verified against codex's own
git-blame check of the pre-PR stable code before applying this.

Now throws in that case, restoring the pre-existing "let the caller
mark it failed and retryable" behavior for a genuinely unrecoverable
video, while keeping every partial-failure case (the vast majority)
resolving with whatever succeeded.

Found by codex review.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-10 13:48:48 +02:00
Paul Nothaft 443ec91de9 chore(main): release 3.131.2-beta.0 (#1368)
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
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 / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-09-09 20:35:16 +00:00
Paul Nothaft 15cd5ede82 fix(backup): honor the configured database-backup destination path (#1366)
* fix(backup): stop ignoring the configured database-backup destination path

databaseBackupService.getBackupConfig() returns the raw
database_backup_*-prefixed setting keys, but backup() and
startScheduledBackups() destructured unprefixed names off that
object (destinationPath, compress, enabled, schedule,
retentionDays, emailOnSuccess/Failure). None of those keys ever
existed on the config object, so every read silently fell through
to its hardcoded default.

The visible symptom (reported in issue 1365): the inline database
dump that runs before every file backup (default ON) always tried
to create /backup/database, regardless of what an admin configured,
and died with EACCES on the read-only default path — before the
file backup's own (correctly wired) backup_destination_path was
ever reached. The standalone scheduled database-backup runner had
the same bug: config.enabled was always undefined, so it silently
never started regardless of database_backup_enabled.

Also fixes saveManifestToLocal's manifest-directory fallback,
which hardcoded /backup instead of matching the sane
getStoragePath()/backups default used everywhere else for a
missing backup_destination_path.

Relates to issue 1365

* fix(backup): reject a database-backup destination inside a public static mount

Making database_backup_destination_path actually take effect
reopens a GHSA-jw8m-43r2-jqrm-class exfiltration path: that
setting is writable via PUT /api/admin/database-backup/config
under backup.create alone (the built-in admin role has it
without settings.edit or backup.restore), with no path
validation. Before this fix the setting was silently ignored
(the destructuring bug), so pointing it at the public
uploads/logos or fonts mount was harmless; now that it is
honored, it needed the same defense GHSA-jw8m already applies
to the per-request override.

Rejects the setting at both the config write (immediate 400)
and, defensively, at backup() time before mkdir.

Found by codex review.

* fix(backup): close two gaps codex round 2 found in the destination guard

- The public-roots list missed the bundled fallback fonts dir
  (backend/assets/fonts, also mounted at /fonts, and nodejs-owned
  per the Dockerfile's COPY --chown so it's writable at runtime).
- The comparison was case-sensitive; on a case-insensitive-but-
  preserving filesystem (APFS, NTFS, Docker Desktop bind mounts of
  either) STORAGE_PATH/UPLOADS/Logos names the same directory as
  uploads/logos on disk. Now compares lowercased.
- database_backup_retention_days reached cleanupOldBackups
  unvalidated. A value <= 0 pushes the cutoff to today or the
  future, deleting every completed backup on the next scheduled
  run -- a backup.create holder achieving what backup.delete
  gates on the manual /cleanup route. Rejected at config-write
  time (400) and defensively inside cleanupOldBackups itself.
- The scheduled-backup cron callback closed over retention_days
  from schedule-start time; a retention-only /config update
  (which doesn't restart the schedule) ran stale until restart.
  Re-reads it on every tick instead.

Found by codex review, round 2.

* fix(backup): resolve symlinks and add the all-in-one frontend dir to the destination guard

Codex round 3 found two more bypasses of the public-root guard,
both specific to the all-in-one image (Dockerfile.aio):

- /app/frontend/dist (FRONTEND_DIR) ships nodejs-owned and is
  served unauthenticated as the built SPA -- missing from the
  protected-roots list.
- /app/storage is a symlink to /data/storage (the actual
  STORAGE_PATH). A destination given as /app/storage/uploads/logos
  passed the guard's lexical path.resolve() comparison while
  resolving, on disk, to the exact same directory as the protected
  STORAGE_PATH/uploads/logos. isUnderPubliclyServableRoot now
  resolves symlinks in whatever prefix of each path already
  exists (resolveRealish) before comparing, rather than relying on
  path.resolve() alone.

Also restores three fs.mkdir spies in the test file that were
never un-spied, which silently leaked a rejected mock into any
later test doing a real fs.mkdir -- exactly what the new symlink
test needed to set up its fixture.

Found by codex review, round 3.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-09 22:27:18 +02:00
Paul Nothaft 6d717544ec chore(main): release 3.131.1-beta.0 (#1364)
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 / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
2026-09-08 20:08:38 +00:00
Paul Nothaft 9f4b9bab46 fix(usage): explain and de-emphasize the pending-packet button lock (#1363)
* fix(usage): explain and de-emphasize the pending-packet button lock

An admin whose report delivery is stuck (old schema, network issue,
etc.) saw the v5-upgrade and portal buttons greyed out with no
indication why, or what to do about it — the backend's single-packet-
in-flight guard is correct, but silent. Add an inline note pointing at
"Retry / send if due" when a pending packet is the actual cause.

Also: "Review expanded usage.v5 scope" didn't read as an upgrade
action — renamed to "Upgrade to usage.v5" / "Auf usage.v5 upgraden".
"Open usage portal" is now a primary (green) button in both its
signed-in and pre-participation forms, matching the visual weight of
the other primary actions on this tab instead of blending in as a
secondary outline button.

* fix(usage): scope the pending-packet note to controls it actually gates

The note added in the previous commit rendered whenever pending_action
was truthy, regardless of participation status. Outside `active`
(activation_pending, deletion_pending) the portal renders as a plain
un-gated link and no v5-upgrade section exists at all, so the note
named two controls that either weren't blocked or weren't on screen.
And when active but already on the current schema, it wrongly implied
a v5-upgrade button existed.

Gate on `active` (nothing is actually blocked outside it), and choose
between the existing two-control message and a new portal-only one
based on whether consent_update_available — which the v5-upgrade
section itself is gated on — is true. Four new regression tests cover
each shape: activation_pending, deletion_pending, active+current-schema
(portal-only), and active+outdated-schema (both, the original case).

Found by code review.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-08 22:00:35 +02:00
Paul Nothaft de3ae1f176 chore(main): release 3.131.0-beta.0 (#1362)
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 / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-09-08 17:32:31 +00:00
Paul Nothaft 59ef2ee9af Merge pull request #1361 from PicPeak/feat/usage-reporting-update-prompt
feat(usage): prompt existing admins once for usage reporting after an update
2026-09-08 19:23:54 +02:00
Paul Nothaft 539f5db2b5 Merge pull request #1360 from PicPeak/feat/setup-usage-reporting-optin
feat(setup): add product usage consent to the first-run wizard
2026-09-08 19:23:06 +02:00
Paul Nothaft 411d459338 Merge pull request #1359 from PicPeak/fix/qa-shutdown-and-revocation
fix: complete graceful shutdown and revoke tokens without expiry
2026-09-08 19:22:50 +02:00
Paul Nothaft ff23efec81 chore(usage): renumber prompt_shown migration to 212
#1359 independently added 211_revocations_without_expiry.js against
the same main baseline. Renumbering this one to 212 keeps the
migrations directory sequentially numbered once both land, regardless
of merge order. No functional change — same up()/down(), same column.
2026-09-08 19:22:01 +02:00
Paul Nothaft fb2f8333dc fix(usage): classify prompt acknowledgement in privacy coverage 2026-09-08 17:31:32 +02:00
Paul Nothaft 77b4aab61a fix(usage): synchronize setup consent and dismissal state 2026-09-08 17:26:47 +02:00
Paul Nothaft a5f7b38e02 fix(setup): refresh usage state after accepting consent 2026-09-08 17:25:20 +02:00
Paul Nothaft 9a437ee9e1 fix(usage): preserve consent choices and make the prompt accessible 2026-09-08 17:22:47 +02:00
Paul Nothaft 81b10f4e31 Merge setup consent fixes from PR 1360
# Conflicts:
#	frontend/src/pages/SetupPage.tsx
2026-09-08 17:15:56 +02:00
Paul Nothaft 9168bdd504 fix(setup): require the full usage reporting disclosure 2026-09-08 17:14:37 +02:00
Paul Nothaft c61a6b089e fix(usage): cap the update-prompt modal height so it scrolls on short viewports
Found testing against WebKit at an iPhone-sized viewport: the fixed
overlay + flex-center wrapper has no height limit, so on a short
screen the title is clipped off the top and the "Maybe later" button
off the bottom, with no way to reach either. Capping the card at
max-h-[90vh] with its own overflow-y-auto keeps the backdrop static
and makes the card scroll internally instead.
2026-09-08 16:55:09 +02:00
Paul Nothaft d20f80112f feat(usage): prompt existing admins once for usage reporting after an update
An admin who already had PicPeak installed before the opt-in reporting
feature existed never gets asked — the setup wizard only runs once, on
a brand-new instance. Adds a one-time modal, shown on the admin's next
dashboard visit after updating, offering the same choice the wizard
gives a new install.

- New `product_usage_state.prompt_shown` column (migration 211) and
  UsageService.markPromptShown(), set on either outcome (enable or
  decline) from both this modal and the wizard step, so an
  installation is never asked twice regardless of which path it took.
- New POST /admin/usage/prompt-seen endpoint.
- Extracted the wizard's three-point pitch (UsageReportingPitch.tsx)
  so the modal and the wizard step share identical copy instead of
  drifting apart.
- The modal never shows once participation is already active, and
  never shows a second time after either the wizard or the modal has
  been through it once.

Depends on #1360 (the setup wizard step this reuses).
2026-09-08 16:25:51 +02:00
Paul Nothaft 8d0c32902d feat(setup): add anonymous usage-reporting opt-in to the first-run wizard
Adds a step between the SMTP/site-URL config step and the final
thank-you screen, asking whether to participate in the existing
Product usage & feedback reporting. Kept short — three points on how
it differs from typical telemetry (one-way only, no personal data,
and participants can browse the same shared feature-adoption dataset
other installations report) plus an explicit consent checkbox — and
calls the same enable() endpoint the post-install Settings page uses.
Skipping does nothing; participation can be toggled anytime from
Settings → Product usage.
2026-09-08 15:58:32 +02:00
Paul Nothaft 662516a5ad fix: retain revocations for tokens without expiry 2026-09-08 15:54:01 +02:00
Paul Nothaft a31a2e25e2 fix: interrupt idle worker waits during shutdown 2026-09-08 15:54:01 +02:00
Paul Nothaft 20a291eb34 chore(main): release 3.130.2-beta.0 (#1358)
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
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 / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-09-08 13:40:19 +00:00
Paul Nothaft f0e6d2dfb1 fix: enforce gallery access and consolidate gallery workflows (#1357)
Harden gallery authentication and authorization, consolidate gallery workflows, and prevent token-bearing URLs from leaking through nginx request error logs.
2026-09-08 15:34:09 +02:00
Paul Nothaft 895e5ab3cc chore(main): release 3.130.1-beta.0 (#1354)
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-ml (push) Blocked by required conditions
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 / build-aio (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-aio (push) Blocked by required conditions
Build and Push Docker Images / smoke-aio (push) Waiting to run
Build and Push Docker Images / dockerhub-descriptions (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-09-08 06:53:51 +00:00
Paul Nothaft acb25a9a1c fix(images): probe and clean up preview tiers under the extension the encoder actually wrote (#1355)
generatePreviewImage rewrites the output extension to match the encoding
it chose, .jpg or .webp for alpha and multi-frame sources. The tier
lookup in ensurePreviewImageAtWidth and the cleanup list in
previewTierKeys kept the SOURCE extension instead, so for anything but a
lowercase .jpg source the stat never matched: every tier request for a
.png, .JPG, .jpeg, .heic or RAW photo re-ran Sharp, and cleanup never
found the files it left behind, which accumulated for the life of the
install.

Both now derive every key the tier can live under: the .jpg and .webp
candidates, plus the source-extension key last so tiers written before
the rewrite are still found by lookup and by cleanup.

Follow-up to issue 1020, where the mismatch was identified during review.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-08 08:41:53 +02:00
Paul Nothaft c97341e454 fix(images): single-flight lazy rendition generation and keep the old rendition during replacement (#1350)
* fix(images): single-flight lazy rendition generation and keep the old rendition during replacement

The lazy generators in imageProcessor are check-then-generate, and the
check reads the path off the photo row the route already fetched. N
concurrent cold requests for one photo all held a snapshot with the path
still null, all missed, and all ran the same sharp pipeline. Only the
thumbnail tier path had a guard; the canonical thumbnail it falls back
to, heroes, previews and preview tiers had none.

One process-local map now covers every rendition, keyed by photo id and
rendition (`thumbnail:<id>`, `thumbnail:<id>:w<width>`, `hero:<id>`,
`preview:<id>`, `preview:<id>:w<width>`). Concurrent callers share one
promise; the entry is cleared in a finally on success and failure alike
so a rejection cannot poison the key. The tier stat moved inside the
flight so a request arriving as the previous flight clears finds the
written tier instead of missing on a stale probe.

Heroes and previews also deleted the existing object before generating
its replacement, and again in the catch. Both are gone, mirroring what
the thumbnail generator already does: put is the last statement in the
try and replaces atomically on local storage and by key on S3, so the
delete only ever opened a window with no rendition at all, and a source
that failed to read stripped the old rendition with the row still
pointing at it.

No re-read of the photo row inside the flight: the admin regenerate
endpoints force a rebuild by passing a row with the path nulled, and a
re-read would hand back the persisted rendition untouched.

Fixes the single-flight half of issue 1020. The preview cache-key
extension mismatch and any server-wide work queue remain separate.

* fix(images): keep the snapshot validity check outside the single-flight

With the check inside the flight, an admin regeneration (row passed with
the path nulled) could join a viewer's flight for the same photo that was
merely confirming an already-good rendition, and be handed back the very
file it was asked to replace while the endpoint counted a success. Only a
miss enters the flight now; inside it everything is a regeneration.

* fix(images): forced rebuilds run after an in-flight lazy generation instead of adopting it

The admin regenerate endpoints could still join a lazy flight that was
already generating for the same photo. That flight read the thumbnail
settings when it started, so after a settings change it produces exactly
the rendition the regenerate was invoked to replace; adopting it counted
a success while the old size stayed cached.

ensureThumbnail and ensurePreviewImage take `{ force: true }`: skip the
snapshot check and, if a flight is pending, start after it settles. Lazy
misses arriving meanwhile join the forced flight, and an older flight
settling late no longer evicts the newer entry from the map.

* fix(images): key rendition flights by source as well as photo id

replacePhoto keeps the photo id and changes path and filename. Keyed by
id and width alone, a request carrying the replacement row joined a
flight still rendering the file it replaced and was handed the old
image, which the gallery caches for 30 minutes. The tier map this
replaced was keyed by storage key and so already told the two apart.

* test(thumbnails): wait for the regenerate loop's completion line instead of a fixed 150 ms

The loop runs in setImmediate after the response. Under a loaded machine
(fifteen suites in parallel, each booting a migrated SQLite) it took
longer than 150 ms once and the assertions ran against a half-finished
mock call list. Poll the logger spy for the "regeneration complete" line
with a 10 s deadline; the suite also finishes sooner because the wait
ends as soon as the loop does.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-08 08:33:04 +02:00
Paul Nothaft 0e459b3293 docs: define security support across stable and main (#1351)
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-08 08:32:54 +02:00
Paul Nothaft f83cbe9109 docs: refresh repository support and community links (#1349)
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-08 08:32:50 +02:00
191 changed files with 10591 additions and 9070 deletions
+11
View File
@@ -125,6 +125,11 @@ DB_NAME=picpeak_prod
# address is refused by the SSRF check otherwise. Running n8n beside PicPeak is
# normal, so set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true for that.
#
# Webhook and email-webhook deliveries connect to the DNS answer they just
# validated and ignore HTTP_PROXY / HTTPS_PROXY. Behind a mandatory egress
# proxy set the *_ALLOW_PRIVATE_URLS flag, which sends through the proxy
# without pinning.
#
# A mail account with its own SMTP host (Settings -> Mail accounts) keeps
# sending through it; this replaces the global transport only.
#
@@ -353,3 +358,9 @@ LOGS=./logs
# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET.
# Keep this value stable until participation has been deleted.
# USAGE_ENCRYPTION_KEY=
# Graceful shutdown budget in milliseconds. On SIGTERM the server stops
# accepting requests, drains workers and closes the pool; whatever is still
# running after this long is abandoned so the process exits before Docker's
# 10 s stop grace period (raise stop_grace_period together with this value).
#SHUTDOWN_TIMEOUT_MS=8000
+11 -8
View File
@@ -24,17 +24,20 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. Ubuntu 22.04]
- Browser: [e.g. Chrome 120, Safari 17]
- PicPeak Version: [e.g. 1.0.22]
- Deployment Method: [e.g. Docker Compose, Manual]
- Database: [e.g. PostgreSQL 15, SQLite]
- OS and version:
- Browser and version:
- PicPeak version and Docker image tag (if applicable):
- Deployment method: [Docker Compose, all-in-one container, manual]
- Database and version: [PostgreSQL, SQLite]
**Logs**
Please include relevant logs:
```
# Backend logs
docker-compose logs backend | tail -50
# Backend logs (Docker Compose)
docker compose logs --tail=50 backend
# Or all-in-one container logs (replace picpeak if your container has another name)
docker logs --tail=50 picpeak
# Frontend console errors
[paste any browser console errors]
@@ -44,4 +47,4 @@ docker-compose logs backend | tail -50
Add any other context about the problem here.
**Possible Solution**
If you have an idea how to fix the issue, please describe it here.
If you have an idea how to fix the issue, please describe it here.
+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 📚 Documentation
url: https://github.com/PicPeak/picpeak/blob/main/DEPLOYMENT.md
about: Please read the documentation before opening an issue
url: https://docs.picpeak.app
about: Installation, configuration and feature guides
- name: 💬 Discussions
url: https://github.com/PicPeak/picpeak/discussions
about: Ask questions and discuss with the community
- name: 🔒 Security Issues
url: https://github.com/PicPeak/picpeak/blob/main/SECURITY.md
about: Please review our security policy for reporting vulnerabilities
about: Please review our security policy for reporting vulnerabilities
+4 -1
View File
@@ -9,6 +9,7 @@ assignees: ''
**What documentation needs improvement?**
Please specify which document or section needs attention:
- [ ] Documentation website (https://docs.picpeak.app)
- [ ] README.md
- [ ] DEPLOYMENT.md
- [ ] CONTRIBUTING.md
@@ -16,6 +17,8 @@ Please specify which document or section needs attention:
- [ ] Code Comments
- [ ] Other: ___________
Link to the affected page or file:
**Describe the issue**
What's wrong or missing in the documentation?
@@ -30,4 +33,4 @@ Who is this documentation for?
- [ ] End users (photographers/clients)
**Additional context**
Add any other context, examples, or references here.
Add any other context, examples, or references here.
+21 -17
View File
@@ -6,11 +6,6 @@ name: Tests
# calendar) plus the photo / settings / OG / auth surface — wiring them
# into CI makes regressions visible at PR time instead of post-merge.
#
# Six backend suites are excluded via --testPathIgnorePatterns. They
# fail on `upstream/beta` too (pre-existing mock/infra issues, NOT CRM
# regressions). Excluding them here keeps CI green from day 1; revisit
# each individually as its own fix.
#
# Triggers on any change that could affect either suite. The backend
# job intentionally omits frontend paths and vice versa so unrelated
# PRs don't pay both build costs.
@@ -89,18 +84,7 @@ jobs:
# Un-gates the real-Postgres cases in the .picpeak restore suites
# (see the `services:` note above). Absent it they silently skip.
PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test'
run: |
# Excluded suites — fail on upstream/beta too, tracked
# separately as test-infra debt:
# adminSettings.logo — supertest fixture
# integration/adminPhotos.reference — supertest fixture
# integration/webhookDelivery — supertest fixture
# services/backupService.enhanced — knex mock chain
# routes/__tests__/adminAuth — supertest fixture
# (adminNotifications was excluded; #597 fix re-enables it.)
npx jest \
--testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth' \
--ci
run: npx jest --ci
frontend:
runs-on: ubuntu-latest
@@ -121,10 +105,30 @@ jobs:
working-directory: ./frontend
run: npm ci
- name: Lint frontend (including Rules of Hooks)
working-directory: ./frontend
run: npm run lint
- name: Run Vitest suite
working-directory: ./frontend
run: npm test -- --run
nginx:
runs-on: ubuntu-latest
timeout-minutes: 5
strategy:
matrix:
# Match the two shipped frontend Dockerfiles.
image: ['nginx:1.28-alpine', 'nginx:1.30-alpine']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify token-safe nginx logging
env:
NGINX_TEST_IMAGE: ${{ matrix.image }}
run: python3 tests/nginx/test_request_logging.py
# Optional face-detection sidecar (#1074). Runs on every PR regardless of
# whether the feature is enabled anywhere — these tests need no model
# weights (they stub the pipeline out) and cover the auth boundary, the
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.130.0-beta.0"
".": "3.131.3-beta.0"
}
+65
View File
@@ -5,6 +5,71 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.131.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.2-beta.0...v3.131.3-beta.0) (2026-09-10)
### Bug Fixes
* **video:** try metadata extraction and thumbnail generation independently ([#1371](https://github.com/PicPeak/picpeak/issues/1371)) ([a2bf1f6](https://github.com/PicPeak/picpeak/commit/a2bf1f644c78734fc9a86a24d441b7a70bdb38fb))
## [3.131.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.1-beta.0...v3.131.2-beta.0) (2026-09-09)
### Bug Fixes
* **backup:** honor the configured database-backup destination path ([#1366](https://github.com/PicPeak/picpeak/issues/1366)) ([15cd5ed](https://github.com/PicPeak/picpeak/commit/15cd5ede82171f5869342de869903f55c73f3871))
## [3.131.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.0-beta.0...v3.131.1-beta.0) (2026-09-08)
### Bug Fixes
* **usage:** explain and de-emphasize the pending-packet button lock ([#1363](https://github.com/PicPeak/picpeak/issues/1363)) ([9f4b9ba](https://github.com/PicPeak/picpeak/commit/9f4b9bab46264d83dcdf698ce5bc318703eb10ee))
## [3.131.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.130.2-beta.0...v3.131.0-beta.0) (2026-09-08)
### Features
* **setup:** add anonymous usage-reporting opt-in to the first-run wizard ([8d0c329](https://github.com/PicPeak/picpeak/commit/8d0c32902dd78324286a7585188d0c292692c8ec))
* **setup:** add product usage consent to the first-run wizard ([539f5db](https://github.com/PicPeak/picpeak/commit/539f5db2b5e28dc42742fbbbe082fc3c23a7263e))
* **usage:** prompt existing admins once for usage reporting after an update ([59ef2ee](https://github.com/PicPeak/picpeak/commit/59ef2ee9af74e97e042934e6ea5fab3b0b687617))
* **usage:** prompt existing admins once for usage reporting after an update ([d20f801](https://github.com/PicPeak/picpeak/commit/d20f80112f95c7d718f936ea14aadf7eb0accdea))
### Bug Fixes
* complete graceful shutdown and revoke tokens without expiry ([411d459](https://github.com/PicPeak/picpeak/commit/411d459338289cae7dce8cddbe7c78dae3d4f449))
* interrupt idle worker waits during shutdown ([a31a2e2](https://github.com/PicPeak/picpeak/commit/a31a2e25e2666989ee226c0d7884e23f50fe58da))
* retain revocations for tokens without expiry ([662516a](https://github.com/PicPeak/picpeak/commit/662516a5ad2a0aadd87dfff3fba4f2456e88a69d))
* **setup:** refresh usage state after accepting consent ([a5f7b38](https://github.com/PicPeak/picpeak/commit/a5f7b38e02f20e66047431c5cb04e6f34c60c689))
* **setup:** require the full usage reporting disclosure ([9168bdd](https://github.com/PicPeak/picpeak/commit/9168bdd5048b4419db7b0f4444375f54c06f5bbb))
* **usage:** cap the update-prompt modal height so it scrolls on short viewports ([c61a6b0](https://github.com/PicPeak/picpeak/commit/c61a6b089e56beec1de5c106440764518f507012))
* **usage:** classify prompt acknowledgement in privacy coverage ([fb2f833](https://github.com/PicPeak/picpeak/commit/fb2f8333dca79b9cada9f349671920e34db38d5d))
* **usage:** preserve consent choices and make the prompt accessible ([9a437ee](https://github.com/PicPeak/picpeak/commit/9a437ee9e19f6ecb8bff8747de71d3a9527d9512))
* **usage:** synchronize setup consent and dismissal state ([77b4aab](https://github.com/PicPeak/picpeak/commit/77b4aab61a54d92b46fdc93fce5a075e3dc1d794))
## [3.130.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.130.1-beta.0...v3.130.2-beta.0) (2026-09-08)
### Bug Fixes
* enforce gallery access and consolidate gallery workflows ([#1357](https://github.com/PicPeak/picpeak/issues/1357)) ([f0e6d2d](https://github.com/PicPeak/picpeak/commit/f0e6d2dfb12460cb1d003802f346e2026fa1c016))
## [3.130.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.130.0-beta.0...v3.130.1-beta.0) (2026-09-08)
### Bug Fixes
* **images:** probe and clean up preview tiers under the extension the encoder actually wrote ([#1355](https://github.com/PicPeak/picpeak/issues/1355)) ([acb25a9](https://github.com/PicPeak/picpeak/commit/acb25a9a1ce9887e51ff769d98f65379a5a1b803))
* **images:** single-flight lazy rendition generation and keep the old rendition during replacement ([#1350](https://github.com/PicPeak/picpeak/issues/1350)) ([c97341e](https://github.com/PicPeak/picpeak/commit/c97341e4547257aab57fb746bad4203a1adaf560))
### Documentation
* define security support across stable and main ([#1351](https://github.com/PicPeak/picpeak/issues/1351)) ([0e459b3](https://github.com/PicPeak/picpeak/commit/0e459b3293ce9132ee8bb8324c6e76692e580cc5))
* refresh repository support and community links ([#1349](https://github.com/PicPeak/picpeak/issues/1349)) ([f83cbe9](https://github.com/PicPeak/picpeak/commit/f83cbe9109c5c7b25a0b50e0f0e3244d32421e7e))
## [3.130.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.129.0-beta.0...v3.130.0-beta.0) (2026-09-07)
+1 -1
View File
@@ -59,7 +59,7 @@ Unsure where to begin? You can start by looking through these issues:
### Prerequisites
- Node.js 18+
- Node.js 22.12.0 or later (matches `backend/package.json`)
- Docker & Docker Compose
- Git
+1 -1
View File
@@ -149,7 +149,7 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** —
| 🧾 CRM & Accounting | [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm) · [disclaimers](https://docs.picpeak.app/features/crm/disclaimers) |
| 🗺️ Roadmap | [GitHub Issues](https://github.com/PicPeak/picpeak/issues) |
**Project meta:** [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
**Project meta:** [Support](SUPPORT.md) · [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
## 📊 Comparison with Alternatives
+33
View File
@@ -0,0 +1,33 @@
# Getting help with PicPeak
## Documentation
Start with [docs.picpeak.app](https://docs.picpeak.app) for installation,
configuration, gallery features and administration guides.
- [Getting started](https://docs.picpeak.app/getting-started)
- [Deployment](https://docs.picpeak.app/deployment)
- [Admin settings](https://docs.picpeak.app/guides/admin-settings)
- [Release channels](https://docs.picpeak.app/deployment/release-channels)
## Questions and troubleshooting
Use [GitHub Discussions](https://github.com/PicPeak/picpeak/discussions) for
setup questions, troubleshooting and advice from the community. Include your
PicPeak version, deployment method and what you have already tried.
## Bugs and feature requests
Search [existing issues](https://github.com/PicPeak/picpeak/issues) first, then
[choose an issue template](https://github.com/PicPeak/picpeak/issues/new/choose)
to report a bug, suggest a feature or identify a documentation problem.
For bugs, include the exact version, reproduction steps and relevant logs.
See [Contributing](CONTRIBUTING.md) for development and pull request guidance.
## Security vulnerabilities
Follow the [security policy](SECURITY.md) and use
[private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
or email **info@picpeak.app**. Do not report vulnerabilities in public issues
or discussions.
@@ -23,7 +23,7 @@ const express = require('express');
const request = require('supertest');
describe('admin thumbnail regeneration (#1129)', () => {
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage;
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage; let logInfo;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
@@ -54,6 +54,10 @@ describe('admin thumbnail regeneration (#1129)', () => {
deletePreviewTiers: jest.fn().mockResolvedValue(undefined),
}));
// Same module registry as the route, so the spy sees its calls. The
// completion line is what drain() below waits for.
logInfo = jest.spyOn(require('../../src/utils/logger'), 'info');
// bootCrmDb, not run-migrations: the latter calls process.exit(0) on
// success, which ends the jest worker mid-suite.
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
@@ -94,8 +98,20 @@ describe('admin thumbnail regeneration (#1129)', () => {
return typeof row === 'object' ? row.id : row;
}
/** The work runs in setImmediate; give it room to finish. */
const drain = () => new Promise((resolve) => setTimeout(resolve, 150));
/**
* The work runs in setImmediate, after the response. Wait for the loop's
* "regeneration complete" log line rather than a fixed 150 ms: under a
* loaded machine (fifteen suites in parallel, each booting a migrated
* SQLite) the loop occasionally took longer than that, and the assertions
* then ran against a half-finished mock call list.
*/
const drain = async () => {
const deadline = Date.now() + 10000;
const done = () => logInfo.mock.calls.some((c) => /regeneration complete/.test(String(c[0])));
while (!done() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
};
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
const eventId = await seedEvent();
@@ -129,8 +129,15 @@ describe('preview tiers (#1095)', () => {
it('derives every non-default tier key for cleanup', () => {
// Tiers live outside preview_path, so delete/archive/regenerate have no
// other way to find them. 1920 is excluded because that IS preview_path.
// Two candidates per width — the encoder picks `.jpg` or `.webp` and the
// cleanup list cannot know which without probing the source.
const keys = imageProcessor.previewTierKeys({ id: 5, path: 'e/a.jpg', source_origin: 'managed' });
expect(keys).toHaveLength(imageProcessor.PREVIEW_WIDTHS.length - 1);
const widths = imageProcessor.PREVIEW_WIDTHS.filter((w) => w !== 1920);
expect(keys).toHaveLength(widths.length * 2);
for (const w of widths) {
expect(keys).toContain(`previews/preview_w${w}_p5_a.jpg`);
expect(keys).toContain(`previews/preview_w${w}_p5_a.webp`);
}
expect(keys.some((k) => k.includes('w1920'))).toBe(false);
expect(keys.every((k) => k.includes('p5_'))).toBe(true);
});
@@ -54,6 +54,7 @@ maybe('product usage on Postgres', () => {
await require('../../migrations/core/204_product_usage_privacy_receipts').up(db);
await require('../../migrations/core/205_product_usage_consent_version').up(db);
await require('../../migrations/core/206_product_usage_delivery_backoff').up(db);
await require('../../migrations/core/212_product_usage_prompt_shown').up(db);
await db.schema.createTable('app_settings', (t) => {
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
@@ -120,6 +121,24 @@ maybe('product usage on Postgres', () => {
// back as a STRING — the tick() gate compares it against a number.
expect(cols.attempts).toBeDefined();
expect(cols.next_attempt_at).toBeDefined();
expect(cols.prompt_shown).toBeDefined();
});
it('backfills the prompt for existing participation using PostgreSQL booleans', async () => {
const migration = require('../../migrations/core/212_product_usage_prompt_shown');
await migration.down(db);
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v2' });
await migration.up(db);
await migration.up(db);
expect(await service().status()).toMatchObject({ status: 'active', prompt_shown: true, consent_version: 'usage-consent.v2' });
await db('product_usage_state').where({ id: 1 }).update({ status: 'disabled' });
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true });
});
it('persists a fresh installation declining without opting in on PostgreSQL', async () => {
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: false });
await service().markPromptShown();
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true, notice_dismissed: false });
});
it('reruns the backoff migration safely', async () => {
@@ -0,0 +1,96 @@
const knex = require('knex');
const jwt = require('jsonwebtoken');
const { randomUUID } = require('crypto');
const migration = require('../../migrations/core/211_revocations_without_expiry');
for (const client of ['sqlite3', 'pg']) {
const enabled = client !== 'pg' || process.env.PICPEAK_PG_TEST_URL;
(enabled ? describe : describe.skip)(`token revocation expiry (${client})`, () => {
let db, owner, schema, revocation;
const sign = claims => jwt.sign({ id: 1, type: 'admin', jti: randomUUID(), ...claims }, process.env.JWT_SECRET);
beforeAll(async () => {
if (client === 'pg') {
schema = `revocation_${randomUUID().replace(/-/g, '')}`;
owner = knex({ client, connection: process.env.PICPEAK_PG_TEST_URL });
await owner.schema.createSchema(schema);
db = knex({ client, connection: process.env.PICPEAK_PG_TEST_URL, searchPath: [schema] });
} else {
db = knex({ client, connection: { filename: ':memory:' }, useNullAsDefault: true });
}
// Exercise the upgrade from the real legacy NOT NULL schema as well as
// repeated migration runs, without sharing another test's database.
await require('../../migrations/legacy/017_add_token_revocation_tables').up(db);
await db('revoked_tokens').insert({ token_id: 'existing', expires_at: '2099-01-01T00:00:00.000Z' });
await migration.up(db);
await migration.up(db);
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db }));
revocation = require('../../src/utils/tokenRevocation');
});
afterAll(async () => {
await db?.destroy();
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
jest.dontMock('../../src/database/db');
});
it('preserves existing revocations and their unique key during upgrade', async () => {
expect(await db('revoked_tokens').where({ token_id: 'existing' }).first()).toBeTruthy();
await expect(db('revoked_tokens').insert({ token_id: 'existing', expires_at: null })).rejects.toThrow();
});
it.each([true, false])('permanently revokes a token without exp (jti: %s)', async withJti => {
const token = sign(withJti ? {} : { jti: undefined });
const payload = jwt.verify(token, process.env.JWT_SECRET);
expect(await revocation.isTokenRevoked(payload)).toBe(false);
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
await revocation.cleanupExpiredRevocations();
expect(await revocation.isTokenRevoked(payload)).toBe(true);
const rows = await db('revoked_tokens').where({ token_id: revocation.buildTokenId(payload) });
expect(rows).toHaveLength(1);
expect(rows[0].expires_at).toBeNull();
});
it('cleans up expired revocations and retains future ones', async () => {
const expired = sign({ exp: Math.floor(Date.now() / 1000) - 60 });
const future = sign({ exp: Math.floor(Date.now() / 1000) + 3600 });
expect(await revocation.revokeToken(expired, 'logout')).toBe(true);
expect(await revocation.revokeToken(future, 'logout')).toBe(true);
await revocation.cleanupExpiredRevocations();
expect(await revocation.isTokenRevoked(jwt.decode(expired))).toBe(false);
expect(await revocation.isTokenRevoked(jwt.decode(future))).toBe(true);
});
it.each([true, false])('upgrades an expiring entry with the same key permanently (jti: %s)', async withJti => {
const claims = { id: 99, iat: Math.floor(Date.now() / 1000), jti: withJti ? randomUUID() : undefined };
const expiring = sign({ ...claims, exp: claims.iat - 60 });
const permanent = sign(claims);
expect(await revocation.revokeToken(expiring, 'logout')).toBe(true);
expect(await revocation.revokeToken(permanent, 'logout')).toBe(true);
expect(await revocation.revokeToken(expiring, 'logout')).toBe(true);
await revocation.cleanupExpiredRevocations();
expect(await revocation.isTokenRevoked(jwt.decode(permanent))).toBe(true);
});
it('retains a signed token whose numeric expiry cannot fit a database timestamp', async () => {
const token = sign({ exp: 1e100 });
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
await revocation.cleanupExpiredRevocations();
expect(await revocation.isTokenRevoked(jwt.decode(token))).toBe(true);
});
it('refuses a rollback that would remove permanent revocations', async () => {
await expect(migration.down(db)).rejects.toThrow('permanent token revocations');
expect((await db('revoked_tokens').columnInfo('expires_at')).nullable).toBe(true);
// A rollback with only expiring records remains supported and reversible.
await db('revoked_tokens').whereNull('expires_at').delete();
await migration.down(db);
await migration.down(db);
expect((await db('revoked_tokens').columnInfo('expires_at')).nullable).toBe(false);
await migration.up(db);
expect(await db('revoked_tokens').where({ token_id: 'existing' }).first()).toBeTruthy();
});
});
}
@@ -5,9 +5,10 @@ process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true';
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
const http = require('http');
const { db } = require('../../src/database/db');
const webhookService = require('../../src/services/webhookService');
const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
let db, cleanup, adminId;
let webhookService;
let __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker;
// Local-only test stub: matches what dev/webhook-receiver/server.js does
// in the docker-compose flow but spun up inside the Jest process so the
@@ -43,7 +44,7 @@ async function insertWebhook(url, events = ['event.published'], extras = {}) {
secret_preview: preview,
events: JSON.stringify(events),
active: extras.active !== false,
created_by: 1,
created_by: adminId,
}).returning('id');
const id = insert[0]?.id || insert[0];
return { id, secret: plaintext };
@@ -56,16 +57,15 @@ async function clearWebhooks() {
describe('webhook delivery worker (#327)', () => {
beforeAll(async () => {
// Schema is expected to already be applied by `npm run migrate`. We
// just verify the webhooks tables exist; if not, the test harness has
// missed running migration 082.
const ok = await db.schema.hasTable('webhooks');
if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first');
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
webhookService = require('../../src/services/webhookService');
({ __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker'));
}, 30000);
afterAll(async () => {
stopWebhookDeliveryWorker();
await db.destroy();
await stopWebhookDeliveryWorker();
await cleanup();
});
beforeEach(async () => {
@@ -20,6 +20,7 @@ const fake = { maintenance: 'true', revoked: false, beforeCutoff: false, admin:
jest.mock('../../src/database/db', () => {
const db = jest.fn((table) => {
const q = {
leftJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
first: jest.fn(async () => {
@@ -27,6 +28,7 @@ jest.mock('../../src/database/db', () => {
return { setting_key: 'general_maintenance_mode', setting_value: fake.maintenance };
}
if (table === 'admin_users') return fake.admin;
if (table === 'events') return { id: 1, slug: 'preview', created_by: 1, is_active: 1 };
return null;
}),
};
@@ -34,6 +36,7 @@ jest.mock('../../src/database/db', () => {
});
return { db, withRetry: (fn) => fn() };
});
jest.mock('../../src/middleware/permissions', () => ({ userHasAllPermissions: jest.fn().mockResolvedValue(true) }));
jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }));
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn(async () => fake.revoked) }));
jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn(async () => fake.beforeCutoff) }));
@@ -75,7 +78,7 @@ describe('general rate limiter skip', () => {
});
describe('admin preview requires a live admin session', () => {
const req = (token) => ({ query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} });
const req = (token) => ({ params: { slug: 'preview' }, query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} });
beforeEach(() => { fake.revoked = false; fake.beforeCutoff = false; fake.admin = { id: 1, password_changed_at: null }; });
it('passes for a live session and sets req.isAdminPreview', async () => {
@@ -108,13 +111,23 @@ describe('multipart origin gate', () => {
const req = (headers) => ({ headers: { host: 'photos.example.com', ...headers } });
it('accepts same-origin, same-site and non-browser requests', () => {
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-origin' }))).toBe(true);
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(true);
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(false);
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'none' }))).toBe(true);
expect(multipartOriginAllowed(req({}))).toBe(true);
expect(multipartOriginAllowed(req({ origin: 'https://photos.example.com' }))).toBe(true);
// Same-origin install without FRONTEND_URL: Origin matches the Host.
expect(multipartOriginAllowed({ headers: { host: 'gallery.local', origin: 'http://gallery.local' } })).toBe(true);
});
it('trusts Fetch Metadata same-origin before the Origin/scheme comparison', () => {
// TLS terminated upstream without X-Forwarded-Proto: req.protocol is http
// while the browser's Origin is https. Login must still work.
// gallery.local is not in the configured allowlist, so only the Host/scheme
// comparison or Fetch Metadata can admit it.
const proxied = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local', 'sec-fetch-site': 'same-origin' } };
expect(multipartOriginAllowed(proxied)).toBe(true);
const legacyBrowser = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local' } };
expect(multipartOriginAllowed(legacyBrowser)).toBe(false);
});
it('rejects cross-site form posts', () => {
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'cross-site' }))).toBe(false);
expect(multipartOriginAllowed(req({ origin: 'https://evil.example' }))).toBe(false);
@@ -0,0 +1,80 @@
const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { UsageService } = require('../../src/usage/UsageService');
const { generateIdentity, digest, canonical } = require('../../src/usage/protocol.cjs');
const migration = require('../../migrations/core/212_product_usage_prompt_shown');
let db;
let directory;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-prompt-test-'));
for (const name of [
'201_product_usage', '202_product_usage_cancel_requested', '203_product_usage_cancel_seq',
'204_product_usage_privacy_receipts', '205_product_usage_consent_version', '206_product_usage_delivery_backoff'
]) await require(`../../migrations/core/${name}`).up(db);
});
afterEach(async () => {
await db.destroy();
fs.rmSync(directory, { recursive: true, force: true });
});
test.each(['active', 'activation_pending', 'deletion_pending', 'identity_conflict'])(
'preserves an existing %s participation without altering its consent or pending packet', async (status) => {
await db('product_usage_state').where({ id: 1 }).update({
status, consent_version: 'usage-consent.v2', pending_packet: 'retained-packet',
});
await migration.up(db);
await migration.up(db);
const state = await db('product_usage_state').where({ id: 1 }).first();
expect(state).toMatchObject({ status, consent_version: 'usage-consent.v2', pending_packet: 'retained-packet', prompt_shown: 1 });
}
);
test('a previously participating installation stays acknowledged after a confirmed withdrawal', async () => {
const actions = [];
const service = new UsageService(db, {
secret: 'test-only-prompt-encryption-secret-32-characters',
endpoint: 'https://collector.example.test',
bindingPath: path.join(directory, 'instance.key'),
fetch: async (_url, init) => {
const { packet } = JSON.parse(init.body);
actions.push(packet.action);
return new Response(JSON.stringify({
packet_id: packet.packet_id, installation_id: packet.installation_id,
packet_digest: digest(canonical(packet)), action: packet.action,
sequence: packet.sequence, status: 'deleted',
}));
},
});
const identity = generateIdentity();
await db('product_usage_state').where({ id: 1 }).update({
status: 'active', notice_dismissed: 1, consent_version: 'usage-consent.v5', sequence: 1,
installation_id: identity.installation_id, public_key: identity.public_key,
private_key_encrypted: service.encrypt(identity.private_key), instance_binding: await service.binding(true),
});
await migration.up(db);
const state = await service.disable();
expect(actions).toEqual(['delete']);
expect(state).toMatchObject({ status: 'disabled', prompt_shown: true, installation_id: null });
expect(state.privacy_receipts.last_deletion.status).toBe('collector-confirmed');
});
test('a fresh installation can decline once without changing consent or the separate banner', async () => {
await migration.up(db);
const fetch = jest.fn();
const service = new UsageService(db, { fetch });
expect(await service.status()).toMatchObject({ status: 'disabled', prompt_shown: false, notice_dismissed: false });
await service.markPromptShown();
await migration.up(db);
expect(await service.status()).toMatchObject({ status: 'disabled', prompt_shown: true, notice_dismissed: false });
expect(fetch).not.toHaveBeenCalled();
});
test('migration guards tolerate a missing table', async () => {
await db.schema.dropTable('product_usage_state');
await expect(migration.up(db)).resolves.toBeUndefined();
await expect(migration.down(db)).resolves.toBeUndefined();
});
@@ -0,0 +1,40 @@
const knex = require('knex');
const migration = require('../../migrations/core/210_events_updated_at');
const { toTimestamp } = require('../../src/utils/dateNormalize');
const { randomUUID } = require('crypto');
const engines = [['sqlite', null], ...(process.env.PICPEAK_PG_TEST_URL ? [['pg', process.env.PICPEAK_PG_TEST_URL]] : [])];
describe.each(engines)('event timestamp migration contract (%s)', (engine, connection) => {
let db, owner, schema;
beforeEach(async () => {
if (engine === 'pg') {
schema = `event_contract_${randomUUID().replace(/-/g, '')}`;
owner = knex({ client: 'pg', connection });
await owner.schema.createSchema(schema);
db = knex({ client: 'pg', connection, searchPath: [schema] });
} else db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
});
afterEach(async () => {
await db.destroy();
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
});
it('upgrades legacy data, is repeatable and preserves subsequent edits', async () => {
await db.schema.createTable('events', table => {
table.increments('id'); table.timestamp('created_at').defaultTo(db.fn.now()); table.boolean('is_active').defaultTo(true);
});
const created = '2026-01-02T03:04:05.000Z';
await db('events').insert({ created_at: created });
await migration.up(db); await migration.up(db);
let row = await db('events').first();
expect(toTimestamp(row.updated_at)).toBe(Date.parse(created));
await db('events').where({ id: row.id }).update({ updated_at: db.fn.now(), is_active: engine === 'pg' ? false : 0 });
const changed = (await db('events').first()).updated_at;
await migration.up(db); row = await db('events').first();
expect(toTimestamp(row.updated_at)).toBe(toTimestamp(changed)); expect([false, 0]).toContain(row.is_active);
});
it('handles a fresh table and an already present updated_at column', async () => {
await db.schema.createTable('events', table => { table.increments('id'); table.timestamp('created_at'); table.timestamp('updated_at'); });
await migration.up(db);
expect(await db.schema.hasColumn('events', 'updated_at')).toBe(true);
});
});
@@ -0,0 +1,57 @@
const knex = require('knex');
const { randomUUID } = require('crypto');
const fs = require('fs/promises');
const path = require('path');
const os = require('os');
const request = require('supertest');
const pgUrl = process.env.PICPEAK_PG_TEST_URL;
(pgUrl ? describe : describe.skip)('fresh PostgreSQL gallery contract', () => {
let owner, db, schema, tmpDir, cleanup, previousClient;
beforeAll(async () => {
schema = `fresh_gallery_${randomUUID().replace(/-/g, '')}`;
owner = knex({ client: 'pg', connection: pgUrl });
await owner.schema.createSchema(schema);
previousClient = process.env.DATABASE_CLIENT;
process.env.DATABASE_CLIENT = 'pg';
process.env.JWT_SECRET = 'fresh-pg-gallery-test-secret-at-least-32-characters';
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-fresh-pg-'));
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
jest.doMock('../../knexfile', () => ({ client: 'pg', connection: pgUrl, searchPath: [schema] }));
({ db } = require('../../src/database/db'));
// bootCrmDb runs the complete core chain against the shared db singleton.
({ cleanup } = await require('../integration/helpers/crmDb').bootCrmDb());
}, 120000);
afterAll(async () => {
await require('../../src/services/serviceShutdown').stopServices();
if (cleanup) await cleanup(); else if (db) await db.destroy();
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
if (previousClient === undefined) delete process.env.DATABASE_CLIENT; else process.env.DATABASE_CLIENT = previousClient;
jest.dontMock('../../knexfile');
});
it('creates through the real admin route, then toggles a typed boolean and timestamp', async () => {
const { seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp } = require('../integration/helpers/crmDb');
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId);
const app = buildRouteApp('/api/admin/events', require('../../src/routes/adminEvents'));
const bearer = `Bearer ${mintAdminToken(adminId)}`;
const created = await request(app).post('/api/admin/events').set('Authorization', bearer).send({
event_type: 'wedding', event_name: 'Fresh PostgreSQL', event_date: '2026-10-01',
customer_name: 'Customer', customer_email: 'customer@example.test', admin_email: 'admin@example.test',
password: 'Strong-Test-Photo-Pass-924!', expiration_days: 30, feedback_enabled: true,
});
expect(created.status).toBe(200);
const event = await db('events').where({ event_name: 'Fresh PostgreSQL' }).first();
expect(event.created_by).toBe(adminId);
expect(event.is_active).toBe(true);
expect(event.updated_at).toBeInstanceOf(Date);
expect(await db('event_feedback_settings').where({ event_id: event.id }).first()).toBeTruthy();
const toggled = await request(app).post(`/api/admin/events/${event.id}/toggle-status`).set('Authorization', bearer).send({});
expect(toggled.status).toBe(200);
const row = await db('events').where({ id: event.id }).first();
expect(row.is_active).toBe(false);
expect(row.updated_at).toBeInstanceOf(Date);
await require('../../migrations/core/210_events_updated_at').up(db);
expect((await db('events').where({ id: event.id }).first()).updated_at).toEqual(row.updated_at);
});
});
@@ -29,6 +29,7 @@ jest.mock('../../src/services/productUsageService', () =>
'tick',
'status',
'dismiss',
'markPromptShown',
'enable',
'disable',
'abandon',
@@ -129,6 +130,7 @@ const ROUTES = [
['post', '/abandon'],
['post', '/retry'],
['post', '/dismiss'],
['post', '/prompt-seen'],
['get', '/preview'],
['get', '/export'],
['put', '/feedback-preferences'],
@@ -178,6 +180,15 @@ test('owner sees no-store status and supplies consent to the service', async ()
.expect(200);
expect(service.enable).toHaveBeenCalledWith('usage-consent.v1');
});
test('only a settings editor can acknowledge the prompt without opting in', async () => {
await request(app)
.post('/api/admin/usage/prompt-seen')
.set('Authorization', `Bearer ${token('admin')}`)
.expect('Cache-Control', 'no-store')
.expect(200);
expect(service.markPromptShown).toHaveBeenCalledTimes(1);
expect(service.enable).not.toHaveBeenCalled();
});
test('public/gallery paths and failed/unauthenticated admin operations never set feature markers', async () => {
const { EventEmitter } = require('events');
const simulate = (path, admin, statusCode) => {
@@ -1,460 +1,82 @@
/**
* Regression test for the /admin/login → /admin/dashboard → /admin/login
* redirect loop reported on v3.32.4-beta.0.
*
* Cause: GET /auth/session was less strict than the adminAuth middleware.
* The session endpoint accepted tokens that the protected endpoints
* subsequently rejected with 401, which the frontend's interceptor
* translated into a hard redirect to /admin/login. /auth/session then
* said "valid: true" again on the next page load and the cycle closed.
*
* /auth/session must reject the same admin tokens adminAuth would
* reject, specifically: deactivated admin user, deleted admin user,
* password changed since iat. Same for gallery: archived event.
*/
const express = require('express');
/** Session restoration uses the same live policy as protected routes. */
const request = require('supertest');
const jwt = require('jsonwebtoken');
process.env.JWT_SECRET = 'session-symmetry-test-secret';
const fakeDb = {
adminUsers: [],
events: [],
revokedTokens: [],
};
jest.mock('../../src/database/db', () => {
const formatBoolean = (v) => (v ? 1 : 0);
void formatBoolean;
function dbFn(table) {
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(([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;
});
};
return this;
},
select(...cols) {
this._cols = cols;
return this;
},
async first() {
const row = fakeDb.adminUsers.find(rowFilter);
if (!row) return undefined;
if (!this._cols) return row;
const out = {};
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;
},
};
}
if (table === 'events') {
let rowFilter = () => true;
return {
where(criteria) {
rowFilter = (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 fakeDb.events.find(rowFilter);
},
};
}
throw new Error(`Unexpected table: ${table}`);
}
return { db: dbFn, formatBoolean: () => 1 };
const crypto = require('crypto');
const { bootCrmDb, seedMinimal, assignAdminRole, buildRouteApp } = require('../integration/helpers/crmDb');
process.env.JWT_SECRET = 'session-symmetry-test-secret-with-at-least-32-characters';
let db, cleanup, app, adminId, customerId, eventId, cutoff;
const slug = 'session-symmetry';
const sign = (claims = {}) => jwt.sign({ type: 'admin', id: adminId, username: 'tester',
iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims },
process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '4h' });
const gallery = (claims = {}) => sign({ type: 'gallery', eventId, eventSlug: slug, ...claims });
const session = bearer => request(app).get(`/api/auth/session?slug=${slug}`).set('Authorization', `Bearer ${bearer}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
await assignAdminRole(db, adminId);
const row = await require('../../src/services/eventCreationService').createEvent({
event_type: 'wedding', event_name: 'Session symmetry', event_date: '2026-10-01',
slug, password: 'Session-Strong-Password-924!', expiration_days: 30,
customer_email: 'customer@example.test', admin_email: 'admin@example.test',
}, { actor: { id: adminId }, source: 'v1' });
eventId = row.id;
await db('events').where({ id: eventId }).update({ slug });
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
cutoff = require('../../src/utils/sessionCutoff');
app = buildRouteApp('/api/auth', require('../../src/routes/auth'));
}, 120000);
beforeEach(async () => {
await db('admin_users').where({ id: adminId }).update({ is_active: 1, password_changed_at: null });
await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null });
await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0,
expires_at: new Date(Date.now() + 86400000).toISOString() });
await cutoff.setSessionsValidAfter(0);
});
jest.mock('../../src/utils/dbCompat', () => ({
formatBoolean: (v) => (v ? 1 : 0),
}));
jest.mock('../../src/utils/tokenRevocation', () => ({
isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)),
revokeToken: jest.fn(),
}));
jest.mock('../../src/utils/tokenUtils', () => ({
getAdminTokenFromRequest: (req) => {
const auth = req.headers.authorization;
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
return null;
},
getGalleryTokenFromRequest: () => null,
setAdminAuthCookie: jest.fn(),
setGalleryAuthCookies: jest.fn(),
clearAdminAuthCookie: jest.fn(),
clearGalleryAuthCookies: jest.fn(),
buildCookieOptionsWithExpiry: () => ({}),
}));
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) }));
// Mock sessionTimeout's isSessionExpired so each test controls the return.
// Default: not expired (so existing tests keep passing without setup).
jest.mock('../../src/middleware/sessionTimeout', () => ({
endSession: jest.fn(),
isSessionExpired: jest.fn(() => Promise.resolve(false)),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));
const authRouter = require('../../src/routes/auth');
function makeApp() {
const app = express();
app.use(express.json());
app.use('/auth', authRouter);
return app;
}
function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
const issuedAt = iat ?? Math.floor(Date.now() / 1000);
// Note: do NOT pass noTimestamp:true here — that strips iat from the
// payload entirely, defeating the password-change comparison. Provide
// iat (and exp) via the payload directly instead.
return jwt.sign(
{ id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 },
process.env.JWT_SECRET,
{ issuer: 'picpeak-auth' }
);
}
function signGalleryToken({ eventId = 100, eventSlug = 'wedding', ...extra } = {}) {
return jwt.sign(
{ eventId, eventSlug, type: 'gallery', ...extra },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
}
describe('GET /auth/session — symmetry with protected middleware', () => {
beforeEach(() => {
fakeDb.adminUsers = [];
fakeDb.events = [];
fakeDb.revokedTokens = [];
});
it('returns valid:true for an active admin token', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: true,
password_changed_at: null,
});
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(res.body.type).toBe('admin');
});
it('returns valid:false when the admin user has been deactivated', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: false,
password_changed_at: null,
});
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false when the admin user no longer exists', async () => {
// adminUsers is empty
const token = signAdminToken({ id: 999 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false when password was changed after the token was issued', async () => {
// iat must be in the past, exp must be in the future so jwt.verify
// doesn't reject the token before /auth/session even gets to look
// at password_changed_at.
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
const tokenExp = tokenIssuedAt + 86400;
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: true,
password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat
});
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:true when password was changed BEFORE the token was issued', async () => {
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60;
const tokenExp = tokenIssuedAt + 86400;
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: true,
password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat
});
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
it('returns valid:false for a gallery token whose event is archived', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: true,
expires_at: null,
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false for a gallery token whose event is expired', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: false,
expires_at: new Date(Date.now() - 86400_000),
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:true for an active gallery token', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: false,
expires_at: new Date(Date.now() + 86400_000),
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
/**
* What KIND of gallery session this is (#1149).
*
* The frontend used to keep this in sessionStorage, which is per-TAB while
* the cookie is per-browser: a gallery reopened in a second tab lost
* 'client' even though the backend still served it as one, and the UI hid
* the only control that clears the privileged cookie. Reported from the
* token so a restored session knows what it actually is.
*/
describe('gallery session kind', () => {
beforeEach(() => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: false,
expires_at: new Date(Date.now() + 86400_000),
});
});
it('reports a PIN-client session as client', async () => {
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${signGalleryToken({ accessLevel: 'client' })}`);
expect(res.body.valid).toBe(true);
expect(res.body.accessLevel).toBe('client');
expect(res.body.viaCustomer).toBe(false);
});
it('reports a customer-portal session, which looks like a guest', async () => {
// via:'customer' runs at accessLevel 'guest' but bypasses reveal mode,
// so it is a credential that does not look like one.
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${signGalleryToken({ via: 'customer', customerId: 7 })}`);
expect(res.body.valid).toBe(true);
expect(res.body.accessLevel).toBe('guest');
expect(res.body.viaCustomer).toBe(true);
});
it('reports a plain guest as neither', async () => {
// The flags have to discriminate, or they would just hand every visitor
// a Logout button back.
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${signGalleryToken()}`);
expect(res.body.valid).toBe(true);
expect(res.body.accessLevel).toBe('guest');
expect(res.body.viaCustomer).toBe(false);
});
});
it('returns valid:false when the token is revoked', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
is_active: true,
password_changed_at: null,
});
fakeDb.revokedTokens.push(1);
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(401);
expect(res.body.valid).toBe(false);
});
// Session-timeout symmetry — issue #350 recurrence on v3.39.1-beta.0.
// sessionTimeoutMiddleware (mounted on /api/admin) rejects idle/old-iat
// tokens with 401 SESSION_TIMEOUT, but /auth/session previously didn't.
// The new isSessionExpired helper closes that asymmetry.
describe('session-timeout symmetry', () => {
const { isSessionExpired } = require('../../src/middleware/sessionTimeout');
beforeEach(() => {
isSessionExpired.mockReset();
// Default to "active session" so the other admin checks above also
// pass when this branch runs.
isSessionExpired.mockResolvedValue(false);
});
it('returns valid:false when isSessionExpired reports the token has timed out', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
is_active: true,
password_changed_at: null,
});
isSessionExpired.mockResolvedValue(true);
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
expect(res.body.error).toBe('Session expired');
});
it('returns valid:true for an active admin token (helper says not expired)', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
is_active: true,
password_changed_at: null,
});
isSessionExpired.mockResolvedValue(false);
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(isSessionExpired).toHaveBeenCalledTimes(1);
});
it('does not call isSessionExpired for gallery tokens', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: false,
expires_at: new Date(Date.now() + 86400_000),
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(isSessionExpired).not.toHaveBeenCalled();
});
it('falls through (treats as valid) if the helper itself throws', async () => {
// Defensive: the require() in auth.js is wrapped in try/catch so a
// missing/broken helper doesn't fail-closed during early bootstrap.
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
is_active: true,
password_changed_at: null,
});
isSessionExpired.mockRejectedValue(new Error('boom'));
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
});
afterAll(async () => {
await require('../../src/services/serviceShutdown').stopServices();
if (cleanup) await cleanup();
});
it('hydrates an active admin and its role', async () => {
const res = await session(sign());
expect(res.body).toMatchObject({ valid: true, type: 'admin', adminUser: { id: adminId, role: { name: 'super_admin' } } });
});
it.each(['disabled', 'password', 'deleted', 'idle'])('rejects an admin after %s', async reason => {
let bearer = sign();
if (reason === 'disabled') await db('admin_users').where({ id: adminId }).update({ is_active: 0 });
if (reason === 'password') await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date().toISOString() });
if (reason === 'deleted') bearer = sign({ id: 999999 });
if (reason === 'idle') bearer = sign({ iat: Math.floor(Date.now() / 1000) - 7200 });
expect((await session(bearer)).body.valid).toBe(false);
});
it('accepts a session issued after a previous password change', async () => {
await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date(Date.now() - 120000).toISOString() });
expect((await session(sign())).body.valid).toBe(true);
});
it.each(['archived', 'expired', 'draft', 'inactive'])('rejects a gallery that is %s', async reason => {
await db('events').where({ id: eventId }).update({
...(reason === 'archived' && { is_archived: 1 }), ...(reason === 'draft' && { is_draft: 1 }),
...(reason === 'inactive' && { is_active: 0 }), ...(reason === 'expired' && { expires_at: new Date(Date.now() - 1000).toISOString() }),
});
expect((await session(gallery())).body.valid).toBe(false);
});
it.each(['guest', 'client', 'customer'])('restores the %s gallery session kind', async kind => {
const res = await session(gallery(kind === 'customer' ? { via: 'customer', customerId } : { accessLevel: kind }));
expect(res.body).toMatchObject({ valid: true, accessLevel: kind === 'client' ? 'client' : 'guest', viaCustomer: kind === 'customer' });
});
it.each(['revoked', 'restore'])('invalidates both admin and gallery sessions after %s', async reason => {
const tokens = [sign(), gallery()];
if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
else for (const bearer of tokens) await require('../../src/utils/tokenRevocation').revokeToken(bearer, 'test');
for (const bearer of tokens) expect((await session(bearer)).body.valid).toBe(false);
});
it('refuses a deactivated customer gallery session', async () => {
const bearer = gallery({ via: 'customer', customerId });
expect((await session(bearer)).body.valid).toBe(true);
await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 });
expect((await session(bearer)).body.valid).toBe(false);
});
it('refuses an unrelated JWT type', async () => {
const res = await session(sign({ type: 'password-reset' }));
expect(res.status).toBe(403); expect(res.body.valid).toBe(false);
});
@@ -0,0 +1,122 @@
/**
* PUT /api/admin/database-backup/config must reject a
* database_backup_destination_path that resolves inside a publicly served
* directory (GHSA-jw8m-43r2-jqrm class, #1365).
*
* Before #1365, database_backup_destination_path was silently ignored by
* databaseBackupService.backup() (a destructuring bug always fell back to
* the hardcoded /backup/database), so this setting being freely writable by
* any backup.create holder — the built-in `admin` role has it without
* settings.edit or backup.restore — was harmless. Making the setting
* actually take effect reopens the exact exfiltration path GHSA-jw8m fixed
* for the per-request override, through the persisted setting instead.
*/
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-dbbackup-config-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dbbackup-config-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('database backup destination-path config guard (GHSA-jw8m class, #1365)', () => {
let db; let cleanup; let app; let adminToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'admin' }).first();
const r = await db('admin_users').insert({
username: 'limited-admin',
email: 'limited-admin-config@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
adminToken = jwt.sign(
{ id, username: 'limited-admin', type: 'admin', role: 'admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
app = express();
app.use(express.json());
app.use('/api/admin/database-backup', require('../../src/routes/adminDatabaseBackup'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('rejects a destination inside the public uploads/logos mount', async () => {
const res = await request(app)
.put('/api/admin/database-backup/config')
.set('Authorization', `Bearer ${adminToken}`)
.send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'uploads', 'logos') });
expect(res.status).toBe(400);
// The seeded default must survive untouched — the rejected value never lands.
const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first();
expect(JSON.parse(row.setting_value)).toBe('/backup/database');
});
it('rejects a destination inside the public fonts mount', async () => {
const res = await request(app)
.put('/api/admin/database-backup/config')
.set('Authorization', `Bearer ${adminToken}`)
.send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'fonts') });
expect(res.status).toBe(400);
});
it('accepts a destination outside any public mount', async () => {
const safePath = path.join(process.env.STORAGE_PATH, 'db-backups');
const res = await request(app)
.put('/api/admin/database-backup/config')
.set('Authorization', `Bearer ${adminToken}`)
.send({ database_backup_destination_path: safePath });
expect(res.status).toBe(200);
const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first();
expect(JSON.parse(row.setting_value)).toBe(safePath);
});
// A retention of 0 or less pushes cleanupOldBackups' cutoff to today or
// the future, deleting every completed backup on the next scheduled run
// — a backup.create holder achieving what backup.delete gates on /cleanup.
it.each([-1, 0])('rejects database_backup_retention_days=%s', async (bad) => {
const res = await request(app)
.put('/api/admin/database-backup/config')
.set('Authorization', `Bearer ${adminToken}`)
.send({ database_backup_retention_days: bad });
expect(res.status).toBe(400);
});
it('accepts a positive database_backup_retention_days', async () => {
const res = await request(app)
.put('/api/admin/database-backup/config')
.set('Authorization', `Bearer ${adminToken}`)
.send({ database_backup_retention_days: 90 });
expect(res.status).toBe(200);
const row = await db('app_settings').where({ setting_key: 'database_backup_retention_days' }).first();
expect(JSON.parse(row.setting_value)).toBe(90);
});
});
@@ -0,0 +1,203 @@
/** Real routes + migrated SQLite: the same session policy protects lists and media. */
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const fs = require('fs/promises');
const path = require('path');
process.env.JWT_SECRET = 'gallery-policy-regression-secret-at-least-32-characters';
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
secureImageAccess: (req, _res, next) => {
req.clientInfo = { fingerprint: 'policy-test', ip: '127.0.0.1', userAgent: 'jest' };
next();
},
getSecurityStatus: (_req, res) => res.json({}),
}));
let db, cleanup, app, adminId, customerId, foreignId, event, secure, cutoff, revokeToken;
const eventId = 70001, photoId = 70002, slug = 'policy-test';
const token = (claims = {}) => jwt.sign({ type: 'gallery', eventId, eventSlug: slug,
iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims },
process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '1h' });
const get = (url, bearer) => {
const req = request(app).get(url);
return bearer ? req.set('Authorization', `Bearer ${bearer}`) : req;
};
const endpoints = [`/api/gallery/${slug}/photos`, `/api/gallery/${slug}/photo/${photoId}`,
`/api/gallery/${slug}/thumbnail/${photoId}`, `/api/gallery/${slug}/download/${photoId}`];
const expectDirect = async (bearer, status, suffix = '') => {
for (const url of endpoints) expect((await get(url + suffix, bearer)).status).toBe(status);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
await assignAdminRole(db, adminId);
const [row] = await db('admin_users').insert({ username: 'foreign', email: 'foreign@example.test', password_hash: 'unused', is_active: 1 }).returning('id');
foreignId = row.id ?? row;
await assignAdminRole(db, foreignId, 'viewer');
await db('events').insert({ id: eventId, slug, event_type: 'wedding', event_name: 'Policy test',
event_date: '2026-01-01', host_email: 'h@example.test', admin_email: 'a@example.test', password_hash: 'unused',
share_link: '/gallery/policy-test', created_by: adminId });
const file = path.join(process.env.STORAGE_PATH, `events/active/${slug}/individual/fixture.jpg`);
await fs.mkdir(path.dirname(file), { recursive: true });
await require('sharp')({ create: { width: 8, height: 8, channels: 3, background: '#228844' } }).jpeg().toFile(file);
await db('photos').insert({ id: photoId, event_id: eventId, filename: 'fixture.jpg', path: `${slug}/individual/fixture.jpg`,
type: 'individual', mime_type: 'image/jpeg', processing_status: 'complete', size_bytes: (await fs.stat(file)).size });
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
secure = require('../../src/services/secureImageService');
jest.spyOn(secure, 'createClientFingerprint').mockReturnValue('policy-test');
cutoff = require('../../src/utils/sessionCutoff');
({ revokeToken } = require('../../src/utils/tokenRevocation'));
app = express(); app.use(express.json()); app.use(cookieParser());
app.use('/api', require('../../src/middleware/csrf'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/images', require('../../src/routes/protectedImages'));
app.use('/api/secure-images', require('../../src/routes/secureImages'));
}, 120000);
beforeEach(async () => {
await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0, require_password: 1,
expires_at: new Date(Date.now() + 86400000).toISOString(), reveal_mode: 0 });
await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null });
if (!await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).first()) {
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
}
await cutoff.setSessionsValidAfter(0);
event = await db('events').where({ id: eventId }).first();
});
afterAll(async () => { secure?.dispose(); if (cleanup) await cleanup(); });
it('serves a valid session as a real list and JPEG', async () => {
const bearer = token();
const list = await get(endpoints[0], bearer);
expect(list.status).toBe(200);
expect(list.body.photos).toEqual(expect.arrayContaining([expect.objectContaining({ id: photoId })]));
const image = await get(endpoints[1], bearer);
expect(image.status).toBe(200); expect(image.headers['content-type']).toMatch(/image\/jpeg/);
expect(image.body.length).toBeGreaterThan(100);
});
it('scopes draft previews to the owner and current read permissions', async () => {
await db('events').where({ id: eventId }).update({ is_draft: 1 });
await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1');
await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1');
// Ownership alone does not grant a user without a role read access.
const role = (await db('admin_users').where({ id: adminId }).first()).role_id;
await db('admin_users').where({ id: adminId }).update({ role_id: null });
try { await expectDirect(mintAdminToken(adminId), 403, '?admin_preview=1'); }
finally { await db('admin_users').where({ id: adminId }).update({ role_id: role }); }
});
it.each(['revocation', 'restore'])('rejects gallery sessions after %s', async (reason) => {
const bearer = token();
if (reason === 'revocation') expect(await revokeToken(bearer, 'test')).toBe(true);
else await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
await expectDirect(bearer, 401);
});
it.each(['deactivated', 'password changed'])('rejects an assigned customer when %s', async (reason) => {
const bearer = token({ via: 'customer', customerId });
await expectDirect(bearer, 200);
await db('customer_accounts').where({ id: customerId }).update(reason === 'deactivated'
? { is_active: 0 } : { password_changed_at: new Date().toISOString() });
await expectDirect(bearer, 401);
});
it.each(['ISO', 'epoch'])('enforces expiry immediately for public and JWT access (%s)', async (format) => {
const expiry = Date.now() - 1000;
await db('events').where({ id: eventId }).update({ require_password: 0, expires_at: format === 'ISO' ? new Date(expiry).toISOString() : expiry });
await expectDirect(undefined, 404); await expectDirect(token(), 404);
await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1');
});
it.each(['revocation', 'restore', 'expiry', 'customer'])('rechecks signed and secure image grants after %s', async (reason) => {
const bearer = token(reason === 'customer' ? { via: 'customer', customerId } : {});
const signed = await request(app).post(`/api/images/${slug}/photo/${photoId}/generate-url`).set('Authorization', `Bearer ${bearer}`).send({});
expect(signed.status).toBe(200);
const minted = await request(app).post(`/api/secure-images/${slug}/generate-token`).set('Authorization', `Bearer ${bearer}`).send({ photoId });
expect(minted.status).toBe(200);
const secureUrl = `/api/secure-images/${slug}/secure/${photoId}/${minted.body.token}`;
expect((await get(signed.body.url)).status).toBe(200);
expect((await get(secureUrl)).status).toBe(200);
if (reason === 'revocation') await revokeToken(bearer, 'test');
if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
if (reason === 'expiry') await db('events').where({ id: eventId }).update({ expires_at: new Date(Date.now() - 1000).toISOString() });
if (reason === 'customer') await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 });
const status = reason === 'expiry' ? 404 : 401;
expect((await get(signed.body.url)).status).toBe(status);
expect((await get(secureUrl)).status).toBe(status);
});
it('blocks an empty cross-site cookie POST before the reveal state changes', async () => {
await db('events').where({ id: eventId }).update({ reveal_mode: 1, revealed_at: null });
const cookie = `admin_token=${mintAdminToken(adminId)}`;
const url = `/api/admin/events/${eventId}/reveal`;
const blocked = await request(app).post(url).set('Cookie', cookie).set('Origin', 'https://attacker.example')
.set('Sec-Fetch-Site', 'cross-site').set('Content-Type', 'application/x-www-form-urlencoded').send('');
expect(blocked.status).toBe(403);
expect((await db('events').where({ id: eventId }).first()).revealed_at).toBeNull();
process.env.ADMIN_URL = 'https://admin.example.test';
try {
const allowed = await request(app).post(url).set('Cookie', cookie).set('Origin', process.env.ADMIN_URL)
.set('Sec-Fetch-Site', 'cross-site').send({});
expect(allowed.status).toBe(200);
expect((await db('events').where({ id: eventId }).first()).revealed_at).not.toBeNull();
} finally { delete process.env.ADMIN_URL; }
});
it('toggles status on a fully migrated fresh database and records updated_at', async () => {
const response = await request(app).post(`/api/admin/events/${eventId}/toggle-status`)
.set('Authorization', `Bearer ${mintAdminToken(adminId)}`).send({});
expect(response.status).toBe(200);
const row = await db('events').where({ id: eventId }).first();
expect([false, 0]).toContain(row.is_active);
expect(Number.isFinite(require('../../src/utils/dateNormalize').toTimestamp(row.updated_at))).toBe(true);
});
it('paginates after feedback filtering, with a total independent of page size', async () => {
const ids = [70003, 70004, 70005];
await db('photos').insert(ids.map(id => ({ id, event_id: eventId, filename: `${id}.jpg`, path: 'unused',
type: 'individual', like_count: 1, processing_status: 'complete' })));
await db('event_feedback_settings').insert({ event_id: eventId, feedback_enabled: 1, show_feedback_to_guests: 1 });
try {
const bearer = token();
const first = await get(`${endpoints[0]}?filter=liked&limit=2&page=1&sort=filename&order=asc`, bearer);
const second = await get(`${endpoints[0]}?filter=liked&limit=2&page=2&sort=filename&order=asc`, bearer);
expect(first.status).toBe(200); expect(second.status).toBe(200);
expect(first.body.pagination).toMatchObject({ total: 3, has_more: true });
expect(second.body.pagination).toMatchObject({ total: 3, has_more: false });
expect([...first.body.photos, ...second.body.photos].map(photo => photo.id)).toEqual(ids);
} finally {
await db('photos').whereIn('id', ids).del();
await db('event_feedback_settings').where({ event_id: eventId }).del();
}
});
it.each(['assignment removed', 'anonymized'])('rejects an existing customer grant after %s', async reason => {
const bearer = token({ via: 'customer', customerId });
await expectDirect(bearer, 200);
if (reason === 'assignment removed') await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).del();
else await require('../../src/services/customerAccountsService').eraseCustomer(customerId, adminId);
await expectDirect(bearer, reason === 'assignment removed' ? 403 : 401);
});
it('denies foreign editors and allows the editor who owns the gallery', async () => {
await assignAdminRole(db, foreignId, 'editor');
try {
await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1');
await db('events').where({ id: eventId }).update({ created_by: foreignId });
await expectDirect(mintAdminToken(foreignId), 200, '?admin_preview=1');
} finally {
await db('events').where({ id: eventId }).update({ created_by: adminId });
await assignAdminRole(db, foreignId, 'viewer');
}
});
it('bounds a large gallery response while retaining the complete count', async () => {
const rows = Array.from({ length: 5000 }, (_, index) => ({ id: 80000 + index, event_id: eventId,
filename: `large-${index}.jpg`, path: 'unused', type: 'individual', processing_status: 'complete' }));
try {
await db.batchInsert('photos', rows, 100);
const response = await get(`${endpoints[0]}?limit=999999&page=1`, token());
expect(response.status).toBe(200);
expect(response.body.photos).toHaveLength(250);
expect(response.body.pagination).toMatchObject({ total: 5001, limit: 250, has_more: true });
} finally { await db('photos').where('id', '>=', 80000).where({ event_id: eventId }).del(); }
});
@@ -0,0 +1,40 @@
/**
* Gallery tokens must carry a per-token `jti`. tokenRevocation falls back to
* `${eventId}-${iat}-gallery` without one, so a guest logging out would revoke
* every other guest whose token was minted for the same event in the same
* second (QR-code share links at an event make that routine).
*/
const fs = require('fs');
const path = require('path');
const jwt = require('jsonwebtoken');
const mintSites = [
'src/routes/auth.js',
'src/routes/customer.js',
'src/routes/gallery/slideshow.js',
];
describe('gallery token mint sites', () => {
it.each(mintSites)('%s sets a unique jti on every gallery token', (file) => {
const source = fs.readFileSync(path.join(__dirname, '../../', file), 'utf8');
const payloads = source.split('jwt.sign(').slice(1)
.map((chunk) => chunk.split('process.env.JWT_SECRET')[0])
.filter((payload) => payload.includes("type: 'gallery'"));
expect(payloads.length).toBeGreaterThan(0);
for (const payload of payloads) expect(payload).toContain('jti: crypto.randomUUID()');
});
});
describe('revocation key', () => {
beforeAll(() => { process.env.JWT_SECRET = process.env.JWT_SECRET || 'jti-regression-secret-at-least-32-characters-long'; });
it('is distinct for two same-second gallery logins of the same event', () => {
const { buildTokenId } = require('../../src/utils/tokenRevocation');
const crypto = require('crypto');
const iat = Math.floor(Date.now() / 1000);
const mint = () => jwt.decode(jwt.sign({ eventId: 7, type: 'gallery', iat, jti: crypto.randomUUID() }, process.env.JWT_SECRET));
expect(buildTokenId(mint())).not.toBe(buildTokenId(mint()));
// Without a jti the key collapses to eventId + login second.
const bare = jwt.decode(jwt.sign({ eventId: 7, type: 'gallery', iat }, process.env.JWT_SECRET));
expect(buildTokenId(bare)).toBe(buildTokenId({ ...bare }));
});
});
@@ -0,0 +1,75 @@
const request = require('supertest');
const jwt = require('jsonwebtoken');
const { randomUUID } = require('crypto');
const { bootCrmDb, seedMinimal, assignAdminRole, buildRouteApp } = require('../integration/helpers/crmDb');
let db, cleanup, adminId, customerId, eventId, apps, revocation;
const slug = 'logout-revocation';
const cases = [
['auth', '/logout', 'admin', 'admin_token'],
['auth', '/gallery/logout', 'gallery', `gallery_token_${slug}`],
['customerAuth', '/logout', 'customer', 'customer_token'],
['adminAuth', '/logout', 'admin', 'admin_token'],
];
const sign = type => jwt.sign({
type, ...(type === 'admin' ? { id: adminId } : type === 'customer' ? { customerId } : { eventId, eventSlug: slug }),
jti: randomUUID(),
}, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
await assignAdminRole(db, adminId);
const event = await require('../../src/services/eventCreationService').createEvent({
event_type: 'wedding', event_name: 'Logout revocation', event_date: '2026-10-01',
slug, password: 'Logout-Strong-Password-924!', expiration_days: 30,
customer_email: 'customer@example.test', admin_email: 'admin@example.test',
}, { actor: { id: adminId }, source: 'v1' });
eventId = event.id;
await db('events').where({ id: eventId }).update({ slug });
revocation = require('../../src/utils/tokenRevocation');
apps = Object.fromEntries(['auth', 'adminAuth', 'customerAuth'].map(name => [
name, buildRouteApp('/', require(`../../src/routes/${name}`)),
]));
});
afterEach(() => jest.restoreAllMocks());
afterAll(async () => {
await require('../../src/services/serviceShutdown').stopServices();
if (cleanup) await cleanup();
});
it.each(cases)('%s%s revokes a no-expiry %s cookie session', async (route, path, type, cookie) => {
const token = sign(type);
const sessionApp = type === 'customer' ? apps.customerAuth : apps.auth;
const sessionPath = type === 'gallery' ? `/session?slug=${slug}` : '/session';
const session = () => request(sessionApp).get(sessionPath).set('Cookie', `${cookie}=${token}`);
const before = await session();
expect(before.status).toBe(200);
if (type !== 'customer') expect(before.body.valid).toBe(true);
const res = await request(apps[route]).post(path).set('Cookie', `${cookie}=${token}`).send({ slug });
expect(res.status).toBe(200);
expect(res.headers['set-cookie'].some(value => value.startsWith(`${cookie}=;`))).toBe(true);
await revocation.cleanupExpiredRevocations();
expect(await revocation.isTokenRevoked(jwt.decode(token))).toBe(true);
const after = await session();
if (type === 'customer') expect(after.status).toBe(401);
else expect(after.body.valid).toBe(false);
});
it.each(cases)('%s%s reports failed persistence for %s logout and clears its cookie', async (route, path, type, cookie) => {
const token = sign(type);
const realQuery = db.client.query;
jest.spyOn(db.client, 'query').mockImplementation(function (connection, query) {
if (/^insert into [`"]revoked_tokens[`"]/.test(query.sql)) {
return Promise.reject(new Error('simulated revocation write failure'));
}
return realQuery.call(this, connection, query);
});
const res = await request(apps[route]).post(path).set('Cookie', `${cookie}=${token}`).send({ slug });
expect(res.status).toBe(500);
expect(res.body.error).toBeTruthy();
expect(res.body.message).not.toBe('Logged out successfully');
expect(res.headers['set-cookie'].some(value => value.startsWith(`${cookie}=;`))).toBe(true);
expect(await revocation.isTokenRevoked(jwt.decode(token))).toBe(false);
});
@@ -25,14 +25,18 @@ process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
const tokenGuards = require('../../src/utils/publicTokenGuards');
const { errorHandler } = require('../../src/middleware/errorHandler');
describe('publicContracts routes', () => {
let db;
let cleanup;
let app;
let appWithErrorHandler;
let customerId;
let contractId;
@@ -51,6 +55,17 @@ describe('publicContracts routes', () => {
contractId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
// A second app instance wired to the REAL production error handler
// (buildRouteApp's is a simplified stand-in that only reads
// err.statusCode/err.status, which a bare MulterError doesn't set).
// Used below to verify the actual 4xx contract end-to-end, not just
// that multer aborted the request.
appWithErrorHandler = express();
appWithErrorHandler.use(express.json());
appWithErrorHandler.use(cookieParser());
appWithErrorHandler.use('/api/public/contracts', require('../../src/routes/publicContracts'));
appWithErrorHandler.use(errorHandler);
}, 120000);
afterAll(async () => {
@@ -131,6 +146,40 @@ describe('publicContracts routes', () => {
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(404);
});
// CVE-2026-82333 regression (#1374 follow-up): multer 2.3.0 added an
// opt-in `fieldArrayIndexLimit` that must be set to actually close the
// field-parser DoS — the version bump alone does nothing. This route is
// unauthenticated (token-in-URL only), so it's the sharpest place to
// prove a crafted request with an oversized array-index field name
// (`evil[999999999]`) is rejected rather than accepted or left to hang.
it('rejects a multipart request with an oversized array-index field name', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(app)
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
.field('evil[999999999]', 'x')
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
// multer aborts the request before the handler runs; buildRouteApp's
// generic error handler falls back to 500 for a bare MulterError
// (see appWithErrorHandler test below for the real 4xx contract), so
// here we only assert the upload was NOT accepted/processed.
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.body.error).not.toBe(undefined);
});
it('maps the oversized array-index rejection to a 400 through the real error handler', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(appWithErrorHandler)
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
.field('evil[999999999]', 'x')
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(400);
expect(res.body.code).toBe('VALIDATION_ERROR');
});
});
describe('GET /:token/pdf', () => {
@@ -89,7 +89,8 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
photoId,
`gallery_public_${eventId}_${Date.now()}`,
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600,
galleryAccess: require('../../src/services/galleryAccessService').grant({ id: eventId }, 'public') },
);
const view = (slug, photoId, token) => request(app)
@@ -114,7 +115,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-private-b', photoB, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this photo/i);
expect(res.body.code).toBe('INVALID_GALLERY_GRANT');
});
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
@@ -123,7 +124,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
// check (sessionId gallery A != URL gallery B) must catch it.
const res = await view('secimg-private-b', photoA, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this gallery/i);
expect(res.body.code).toBe('INVALID_GALLERY_GRANT');
});
it('lets a token read its own gallery + photo (binding passes)', async () => {
@@ -15,7 +15,7 @@
jest.mock('axios', () => ({ post: jest.fn() }));
jest.mock('../../src/utils/networkValidation', () => ({
validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok' })),
validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] })),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
@@ -48,7 +48,7 @@ beforeEach(() => {
process.env.EMAIL_WEBHOOK_SECRET = SECRET;
transport.__testing.setAllowPrivateUrls(false);
transport.__testing.resetSecretWarning();
validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok' });
validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] });
axios.post.mockResolvedValue({ status: 200, data: streamOf('') });
});
@@ -0,0 +1,497 @@
/**
* Lazy rendition generation is single-flight per photo and rendition (#1020).
*
* ensureThumbnail / ensureHeroImage / ensurePreviewImage and the tier
* variants are check-then-generate, and the check reads the path off the row
* the caller already fetched. N concurrent cold requests for one photo all
* missed and all ran the same Sharp pass; worse, the hero and preview
* generators deleted the existing object before writing its replacement, so
* a reader landing between B's delete and B's write was redirected to the
* full original, and a regeneration whose source could not be read left the
* old rendition gone with the row still pointing at it.
*
* Driven against real Sharp output and the real LocalFsStorage, plus an
* in-memory backend with the S3 contract (no local paths, download on read),
* because the single-flight sits around withLocalCopy and the difference
* between one download and eight is the whole point.
*/
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
const sharp = require('sharp');
const EXTERNAL_ROOT = path.join(os.tmpdir(), `picpeak-sf-ext-${process.pid}`);
process.env.EXTERNAL_MEDIA_ROOT = EXTERNAL_ROOT;
jest.mock('../../src/database/db', () => {
const state = { events: {}, updates: [] };
const api = (table) => {
if (table === 'events') {
return { where: (_col, id) => ({ first: async () => state.events[id] || null }) };
}
if (table === 'photos') {
return {
where: (criteria) => ({
update: async (values) => { state.updates.push({ criteria, values }); return 1; },
}),
};
}
if (table === 'app_settings') {
return { whereIn: () => ({ select: async () => [] }) };
}
throw new Error(`unexpected table in test: ${table}`);
};
api.__state = state;
return { db: api };
});
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const storageModule = require('../../src/services/storage');
const { db } = require('../../src/database/db');
const MANAGED_EVENT = { id: 11, slug: 'managed-ev', source_mode: 'managed' };
const EXTERNAL_EVENT = { id: 7, slug: 'nas-ev', source_mode: 'reference', external_path: 'weddings/sf' };
let nextId = 1000;
async function writeJpeg(absPath, { width = 2400, height = 1600 } = {}) {
await fs.mkdir(path.dirname(absPath), { recursive: true });
await sharp({ create: { width, height, channels: 3, background: { r: 30, g: 120, b: 200 } } })
.jpeg({ quality: 85 }).toFile(absPath);
}
async function jpegBuffer({ width = 2400, height = 1600 } = {}) {
return sharp({ create: { width, height, channels: 3, background: { r: 200, g: 60, b: 30 } } })
.jpeg({ quality: 85 }).toBuffer();
}
/**
* The S3 contract as imageProcessor sees it: kind() !== 'local', so validity
* is a stat only, and withLocalCopy has to download the source through
* getToFile before Sharp can open it.
*/
class MemoryObjectStore {
constructor() { this.objects = new Map(); this.puts = []; this.downloads = []; this.failNextPut = false; }
kind() { return 's3'; }
async init() {}
async put(key, body) {
if (this.failNextPut) { this.failNextPut = false; throw new Error('simulated upload failure'); }
this.puts.push(key);
this.objects.set(key, Buffer.from(body));
}
async stat(key) {
const b = this.objects.get(key);
return b ? { size: b.length, mtime: new Date() } : null;
}
async exists(key) { return this.objects.has(key); }
async getToFile(key, localPath) {
this.downloads.push(key);
const b = this.objects.get(key);
if (!b) throw new Error(`NoSuchKey: ${key}`);
await fs.mkdir(path.dirname(localPath), { recursive: true });
await fs.writeFile(localPath, b);
}
async delete(key) { this.objects.delete(key); }
}
describe('single-flight rendition generation (#1020)', () => {
let imageProcessor;
beforeAll(() => {
delete require.cache[require.resolve('../../src/services/imageProcessor')];
imageProcessor = require('../../src/services/imageProcessor');
});
beforeEach(() => {
db.__state.events = { [MANAGED_EVENT.id]: MANAGED_EVENT, [EXTERNAL_EVENT.id]: EXTERNAL_EVENT };
db.__state.updates = [];
});
afterAll(async () => {
storageModule.resetStorage();
await fs.rm(EXTERNAL_ROOT, { recursive: true, force: true }).catch(() => {});
});
describe('local storage', () => {
let storage; let storageRoot; let puts;
beforeAll(async () => {
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-sf-store-'));
storage = new LocalFsStorage({ root: storageRoot });
await storage.init();
const origPut = storage.put.bind(storage);
storage.put = async (key, ...rest) => {
if (storage.failNextPut) { storage.failNextPut = false; throw new Error('simulated write failure'); }
if (storage.holdNextPut) { const gate = storage.holdNextPut; storage.holdNextPut = null; await gate; }
puts.push(key);
return origPut(key, ...rest);
};
storageModule.setStorageForTesting(storage);
}, 30000);
beforeEach(() => { puts = []; storage.failNextPut = false; storage.holdNextPut = null; });
afterAll(async () => {
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
});
async function managedPhoto() {
const id = nextId++;
const name = `managed-${id}.jpg`;
const rel = `${MANAGED_EVENT.slug}/${name}`;
await writeJpeg(path.join(storageRoot, 'events/active', rel));
return { id, event_id: MANAGED_EVENT.id, source_origin: 'managed', path: rel, filename: name };
}
async function externalPhoto({ write = true } = {}) {
const id = nextId++;
const name = `external-${id}.jpg`;
const relpath = path.join(EXTERNAL_EVENT.external_path, name);
if (write) await writeJpeg(path.join(EXTERNAL_ROOT, relpath));
return { id, event_id: EXTERNAL_EVENT.id, source_origin: 'external', external_relpath: relpath, filename: name };
}
const putsUnder = (prefix) => puts.filter((k) => k.startsWith(prefix));
it('ensurePreviewImage: eight concurrent cold requests share one generation', async () => {
const photo = await managedPhoto();
const results = await Promise.all(Array.from({ length: 8 }, () => imageProcessor.ensurePreviewImage(photo)));
expect(results[0]).toMatch(/^previews\/preview_/);
expect(new Set(results).size).toBe(1);
expect(putsUnder('previews/')).toHaveLength(1);
// One flight, one row write — not eight identical updates.
expect(db.__state.updates).toHaveLength(1);
expect(await storage.stat(results[0])).toBeTruthy();
});
it('ensureHeroImage: eight concurrent cold requests share one generation', async () => {
const photo = await managedPhoto();
const results = await Promise.all(Array.from({ length: 8 }, () => imageProcessor.ensureHeroImage(photo)));
expect(results[0]).toMatch(/^heroes\/hero_/);
expect(new Set(results).size).toBe(1);
expect(putsUnder('heroes/')).toHaveLength(1);
expect(db.__state.updates).toHaveLength(1);
});
it('ensureThumbnail: eight concurrent cold requests for an external photo share one generation', async () => {
const photo = await externalPhoto();
const results = await Promise.all(Array.from({ length: 8 }, () => imageProcessor.ensureThumbnail(photo)));
expect(results[0]).toBe(`thumbnails/thumb_ext${photo.id}_${photo.filename}`);
expect(new Set(results).size).toBe(1);
expect(putsUnder('thumbnails/')).toHaveLength(1);
expect(db.__state.updates).toHaveLength(1);
});
it('a tier request that resolves to the canonical thumbnail shares the canonical flight', async () => {
// ensureThumbnailAtWidth hands the canonical width, and every video, to
// ensureThumbnail. That used to be the one unguarded path a guarded
// request could fall through into.
const photo = await managedPhoto();
const canonical = 300; // DEFAULT_THUMBNAIL_WIDTH; the settings mock returns no override
const results = await Promise.all([
imageProcessor.ensureThumbnailAtWidth(photo, canonical),
imageProcessor.ensureThumbnailAtWidth(photo, canonical),
imageProcessor.ensureThumbnail(photo),
imageProcessor.ensureThumbnail(photo),
]);
expect(results[0]).toMatch(/^thumbnails\/thumb_/);
expect(new Set(results).size).toBe(1);
expect(putsUnder('thumbnails/')).toHaveLength(1);
});
it.each([
['ensureThumbnail', 'thumbnail_path', 'thumbnails/'],
['ensureHeroImage', 'hero_path', 'heroes/'],
['ensurePreviewImage', 'preview_path', 'previews/'],
])('%s: a forced rebuild is never satisfied by joining a viewer\'s hot-path check', async (fn, column, prefix) => {
// adminThumbnails.js forces a rebuild by passing the row with the path
// nulled. If the validity check ran inside the flight, that call could
// join a viewer's flight for the same photo — one that was merely
// stat-ing an already good rendition — and be handed back the very
// file it was asked to replace, with the endpoint counting a success.
const photo = await managedPhoto();
const existing = await imageProcessor[fn](photo);
expect(existing).toMatch(new RegExp(`^${prefix}`));
expect(putsUnder(prefix)).toHaveLength(1);
const [viewer, forced] = await Promise.all([
imageProcessor[fn]({ ...photo, [column]: existing }),
imageProcessor[fn]({ ...photo, [column]: null }),
]);
expect(viewer).toBe(existing);
expect(forced).toBe(existing);
// The forced call wrote a fresh rendition; the viewer's did not.
expect(putsUnder(prefix)).toHaveLength(2);
});
it.each([
['ensureThumbnail', 'thumbnail_path', 'thumbnails/'],
['ensurePreviewImage', 'preview_path', 'previews/'],
])('%s: a forced rebuild runs after a lazy generation already in flight instead of adopting it', async (fn, column, prefix) => {
// The lazy flight read the settings when it started; after a settings
// change it is producing exactly what the admin's regenerate exists to
// replace. Joining it would count a success and leave the old size
// cached — the validity check only asks whether the file parses.
const photo = await managedPhoto();
let release;
storage.holdNextPut = new Promise((r) => { release = r; });
const lazy = imageProcessor[fn](photo); // blocks inside put
const forced = imageProcessor[fn]({ ...photo, [column]: null }, { force: true });
const joiner = imageProcessor[fn](photo); // lazy miss after the forced call
let forcedSettled = false;
forced.then(() => { forcedSettled = true; });
await new Promise((r) => setTimeout(r, 60));
expect(putsUnder(prefix)).toHaveLength(0);
expect(forcedSettled).toBe(false);
release();
const results = await Promise.all([lazy, forced, joiner]);
expect(results.every((k) => k === results[0])).toBe(true);
expect(results[0]).toMatch(new RegExp(`^${prefix}`));
// Lazy wrote once, the forced rebuild wrote once more after it; the
// later lazy miss joined the forced flight rather than starting a third.
expect(putsUnder(prefix)).toHaveLength(2);
});
it('a replaced photo (same id, new path) does not join a flight still rendering the old source', async () => {
// replacePhoto keeps the id and changes path/filename. Keyed by id and
// width alone, a request carrying the replacement row would join the
// old flight, be handed the old image, and the gallery would cache it.
const before = await managedPhoto();
const after = await managedPhoto();
const replacement = { ...after, id: before.id };
let release;
storage.holdNextPut = new Promise((r) => { release = r; });
const stale = imageProcessor.ensureThumbnailAtWidth(before, 600); // blocks inside put
const fresh = imageProcessor.ensureThumbnailAtWidth(replacement, 600);
release();
const [oldKey, newKey] = await Promise.all([stale, fresh]);
expect(oldKey).toBe(`thumbnails/thumb_w600_p${before.id}_${before.filename}`);
expect(newKey).toBe(`thumbnails/thumb_w600_p${before.id}_${after.filename}`);
expect(putsUnder('thumbnails/').sort()).toEqual([oldKey, newKey].sort());
// Same shape for the canonical preview, which had no guard at all on
// base and must not gain a cross-source one now.
storage.holdNextPut = new Promise((r) => { release = r; });
const staleP = imageProcessor.ensurePreviewImage(before);
const freshP = imageProcessor.ensurePreviewImage(replacement);
release();
const [oldP, newP] = await Promise.all([staleP, freshP]);
expect(oldP).not.toBe(newP);
expect(putsUnder('previews/')).toHaveLength(2);
});
it('different photos and different widths are separate flights', async () => {
const a = await managedPhoto();
const b = await externalPhoto();
const calls = [
...Array.from({ length: 4 }, () => imageProcessor.ensurePreviewImageAtWidth(a, 640)),
...Array.from({ length: 4 }, () => imageProcessor.ensurePreviewImageAtWidth(a, 1280)),
...Array.from({ length: 4 }, () => imageProcessor.ensurePreviewImageAtWidth(b, 640)),
...Array.from({ length: 4 }, () => imageProcessor.ensureThumbnailAtWidth(a, 600)),
...Array.from({ length: 4 }, () => imageProcessor.ensureThumbnailAtWidth(b, 600)),
];
const results = await Promise.all(calls);
expect(results.every(Boolean)).toBe(true);
expect(new Set(results).size).toBe(5);
expect(results.slice(0, 4).every((k) => k === results[0])).toBe(true);
expect(results.slice(4, 8).every((k) => k === results[4])).toBe(true);
expect(putsUnder('previews/')).toHaveLength(3);
expect(putsUnder('thumbnails/')).toHaveLength(2);
// Tiers are pure cache: never written to the row.
expect(db.__state.updates).toHaveLength(0);
});
it('a warm tier is served from storage without a second generation', async () => {
const photo = await managedPhoto();
const first = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
const again = await Promise.all([
imageProcessor.ensurePreviewImageAtWidth(photo, 640),
imageProcessor.ensurePreviewImageAtWidth(photo, 640),
]);
expect(again).toEqual([first, first]);
expect(putsUnder('previews/')).toHaveLength(1);
});
it('a failed flight is cleared so the next request retries instead of adopting the failure', async () => {
const photo = await externalPhoto({ write: false });
const cold = await Promise.all(Array.from({ length: 4 }, () => imageProcessor.ensurePreviewImage(photo)));
expect(cold).toEqual([null, null, null, null]);
expect(putsUnder('previews/')).toHaveLength(0);
// The source appears (mount came back, file finished copying).
await writeJpeg(path.join(EXTERNAL_ROOT, photo.external_relpath));
const warm = await imageProcessor.ensurePreviewImage(photo);
expect(warm).toBe(`previews/preview_ext${photo.id}_external-${photo.id}.jpg`);
expect(putsUnder('previews/')).toHaveLength(1);
});
it('a flight that returns null is shared by every waiter and cleared afterwards', async () => {
const photo = await managedPhoto();
db.__state.events = {}; // the event lookup inside the flight finds nothing
const results = await Promise.all(Array.from({ length: 3 }, () => imageProcessor.ensureThumbnail(photo)));
expect(results).toEqual([null, null, null]);
db.__state.events = { [MANAGED_EVENT.id]: MANAGED_EVENT };
const key = await imageProcessor.ensureThumbnail(photo);
expect(key).toMatch(/^thumbnails\/thumb_/);
expect(putsUnder('thumbnails/')).toHaveLength(1);
});
it('a flight that throws rejects every waiter identically and is cleared afterwards', async () => {
const photo = await managedPhoto();
const boom = new Error('db down');
const realEvents = db.__state.events;
db.__state.events = new Proxy({}, { get: () => { throw boom; } });
// ensureThumbnail's event lookup is not wrapped in try/catch, so this
// propagates — to every caller of the shared flight, not just the first.
const settled = await Promise.allSettled(Array.from({ length: 3 }, () => imageProcessor.ensureThumbnail(photo)));
expect(settled.map((s) => s.status)).toEqual(['rejected', 'rejected', 'rejected']);
expect(settled.every((s) => s.reason === boom)).toBe(true);
db.__state.events = realEvents;
const key = await imageProcessor.ensureThumbnail(photo);
expect(key).toMatch(/^thumbnails\/thumb_/);
});
it('the existing hero survives a regeneration whose source cannot be read', async () => {
// generateHeroImage used to delete the target before Sharp had opened
// the source, so a corrupt file or a blipped mount stripped the old
// hero and returned null with the row still pointing at it.
const src = path.join(storageRoot, 'events/active', MANAGED_EVENT.slug, 'hero-src.jpg');
await writeJpeg(src);
const key = await imageProcessor.generateHeroImage(src, { outputBasename: 'survive.jpg' });
expect(key).toBe('heroes/hero_survive.jpg');
const before = await storage.stat(key);
const junk = path.join(storageRoot, 'events/active', MANAGED_EVENT.slug, 'hero-junk.jpg');
await fs.writeFile(junk, Buffer.from('this is not a jpeg'));
const result = await imageProcessor.generateHeroImage(junk, { regenerate: true, outputBasename: 'survive.jpg' });
expect(result).toBeNull();
const after = await storage.stat(key);
expect(after).toBeTruthy();
expect(after.size).toBe(before.size);
await expect(sharp(storage.resolveLocalPath(key)).metadata()).resolves.toMatchObject({ width: 1920 });
});
it('the existing preview survives a regeneration whose write fails', async () => {
// Probe succeeds, the pipeline runs, the put throws: the catch used to
// delete the key, which by then only ever held the PREVIOUS good file.
const src = path.join(storageRoot, 'events/active', MANAGED_EVENT.slug, 'preview-src.jpg');
await writeJpeg(src);
const key = await imageProcessor.generatePreviewImage(src, { outputBasename: 'survive.jpg' });
expect(key).toBe('previews/preview_survive.jpg');
const before = await storage.stat(key);
storage.failNextPut = true;
const result = await imageProcessor.generatePreviewImage(src, { regenerate: true, outputBasename: 'survive.jpg' });
expect(result).toBeNull();
const after = await storage.stat(key);
expect(after).toBeTruthy();
expect(after.size).toBe(before.size);
});
it('the existing hero survives a regeneration whose write fails', async () => {
const src = path.join(storageRoot, 'events/active', MANAGED_EVENT.slug, 'hero-src2.jpg');
await writeJpeg(src);
const key = await imageProcessor.generateHeroImage(src, { outputBasename: 'survive2.jpg' });
const before = await storage.stat(key);
storage.failNextPut = true;
const result = await imageProcessor.generateHeroImage(src, { regenerate: true, outputBasename: 'survive2.jpg' });
expect(result).toBeNull();
expect((await storage.stat(key)).size).toBe(before.size);
});
});
describe('S3 contract', () => {
let store;
beforeAll(() => {
store = new MemoryObjectStore();
storageModule.setStorageForTesting(store);
});
beforeEach(() => { store.puts = []; store.downloads = []; store.failNextPut = false; });
async function managedPhoto() {
const id = nextId++;
const name = `s3-${id}.jpg`;
const rel = `${MANAGED_EVENT.slug}/${name}`;
store.objects.set(`events/active/${rel}`, await jpegBuffer());
return { id, event_id: MANAGED_EVENT.id, source_origin: 'managed', path: rel, filename: name };
}
it('ensurePreviewImage: concurrent cold requests download the source once and upload once', async () => {
const photo = await managedPhoto();
const results = await Promise.all(Array.from({ length: 6 }, () => imageProcessor.ensurePreviewImage(photo)));
expect(new Set(results).size).toBe(1);
expect(results[0]).toMatch(/^previews\/preview_/);
expect(store.downloads).toEqual([`events/active/${photo.path}`]);
expect(store.puts).toHaveLength(1);
expect(await store.stat(results[0])).toBeTruthy();
expect(db.__state.updates).toHaveLength(1);
});
it('ensureHeroImage: concurrent cold requests download the source once and upload once', async () => {
const photo = await managedPhoto();
const results = await Promise.all(Array.from({ length: 6 }, () => imageProcessor.ensureHeroImage(photo)));
expect(new Set(results).size).toBe(1);
expect(store.downloads).toHaveLength(1);
expect(store.puts).toHaveLength(1);
});
it('ensureThumbnailAtWidth: concurrent cold tier requests download the source once and upload once', async () => {
const photo = await managedPhoto();
const results = await Promise.all(Array.from({ length: 6 }, () => imageProcessor.ensureThumbnailAtWidth(photo, 600)));
expect(new Set(results).size).toBe(1);
expect(results[0]).toBe(`thumbnails/thumb_w600_p${photo.id}_${photo.filename}`);
expect(store.downloads).toHaveLength(1);
expect(store.puts).toEqual([results[0]]);
});
it('the existing preview object survives a regeneration whose upload fails', async () => {
// Fixed outputBasename, as the external and RAW branches pass: the key
// is the same on both runs, so the pre-fix delete would have hit the
// good object. (Through withLocalCopy the basename carries a random
// temp prefix and the two runs never share a key, which is why this
// drives the generator directly.)
const srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-sf-s3src-'));
const src = path.join(srcDir, 'src.jpg');
await writeJpeg(src);
try {
const key = await imageProcessor.generatePreviewImage(src, { outputBasename: 's3-survive.jpg' });
expect(key).toBe('previews/preview_s3-survive.jpg');
const before = store.objects.get(key);
expect(before).toBeTruthy();
store.failNextPut = true;
const result = await imageProcessor.generatePreviewImage(src, { regenerate: true, outputBasename: 's3-survive.jpg' });
expect(result).toBeNull();
expect(store.objects.get(key)).toBe(before);
} finally {
await fs.rm(srcDir, { recursive: true, force: true }).catch(() => {});
}
});
});
});
@@ -0,0 +1,148 @@
/**
* Preview tier lookup and cleanup must agree with what the generator writes
* (#1020 follow-up).
*
* generatePreviewImage rewrites the extension to match the encoding (`.jpg`,
* or `.webp` for alpha / multi-frame sources). ensurePreviewImageAtWidth and
* previewTierKeys kept the SOURCE extension, so for a `.png`, `.JPG`, `.heic`
* or RAW source the tier was generated on every single request — the stat
* never matched — and cleanup never found the files, which piled up in
* storage for the life of the install.
*
* Driven against real Sharp output, because the whole question is which
* extension the encoder actually chose.
*/
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
const sharp = require('sharp');
jest.mock('../../src/database/db', () => {
const state = { event: null };
const api = (table) => {
if (table === 'events') return { where: () => ({ first: async () => state.event }) };
if (table === 'photos') return { where: () => ({ update: async () => 1 }) };
if (table === 'app_settings') return { whereIn: () => ({ select: async () => [] }) };
throw new Error(`unexpected table in test: ${table}`);
};
api.__state = state;
return { db: api };
});
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const storageModule = require('../../src/services/storage');
const { db } = require('../../src/database/db');
const EVENT = { id: 21, slug: 'fmt-ev', source_mode: 'managed' };
let nextId = 5000;
describe('preview tier keys follow the encoded extension', () => {
let storage; let storageRoot; let imageProcessor; let puts;
beforeAll(async () => {
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-tierkeys-'));
storage = new LocalFsStorage({ root: storageRoot });
await storage.init();
const origPut = storage.put.bind(storage);
storage.put = async (key, ...rest) => { puts.push(key); return origPut(key, ...rest); };
storageModule.setStorageForTesting(storage);
delete require.cache[require.resolve('../../src/services/imageProcessor')];
imageProcessor = require('../../src/services/imageProcessor');
}, 30000);
beforeEach(() => { puts = []; db.__state.event = EVENT; });
afterAll(async () => {
storageModule.resetStorage();
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
});
async function photoWith(name, { alpha = false } = {}) {
const id = nextId++;
const rel = `${EVENT.slug}/${name}`;
const abs = path.join(storageRoot, 'events/active', rel);
await fs.mkdir(path.dirname(abs), { recursive: true });
const pipeline = sharp({
create: {
width: 2000, height: 1400, channels: alpha ? 4 : 3,
background: alpha ? { r: 10, g: 20, b: 30, alpha: 0.5 } : { r: 10, g: 20, b: 30 },
},
});
if (/\.png$/i.test(name)) await pipeline.png().toFile(abs);
else if (/\.webp$/i.test(name)) await pipeline.webp().toFile(abs);
else await pipeline.jpeg().toFile(abs);
return { id, event_id: EVENT.id, source_origin: 'managed', path: rel, filename: name };
}
it.each([
['opaque PNG', 'photo.png', false, '.jpg'],
['transparent PNG', 'photo-alpha.png', true, '.webp'],
['uppercase JPG', 'IMG_0001.JPG', false, '.jpg'],
['jpeg spelled out', 'photo.jpeg', false, '.jpg'],
['lowercase jpg', 'photo.jpg', false, '.jpg'],
])('%s: the second request is a cache hit, not a second generation', async (_label, name, alpha, ext) => {
const photo = await photoWith(name, { alpha });
const first = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
expect(first).toBe(`previews/preview_w640_p${photo.id}_${name.replace(/\.[^.]+$/, '')}${ext}`);
expect(puts).toEqual([first]);
const second = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
expect(second).toBe(first);
expect(puts).toHaveLength(1);
});
it.each([
['opaque PNG', 'cleanup.png', false],
['transparent PNG', 'cleanup-alpha.png', true],
['uppercase JPG', 'CLEANUP.JPG', false],
])('%s: previewTierKeys covers the generated key, so deletePreviewTiers removes it', async (_label, name, alpha) => {
const photo = await photoWith(name, { alpha });
const k640 = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
const k1280 = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280);
expect(await storage.stat(k640)).toBeTruthy();
expect(await storage.stat(k1280)).toBeTruthy();
const keys = imageProcessor.previewTierKeys(photo);
expect(keys).toEqual(expect.arrayContaining([k640, k1280]));
await imageProcessor.deletePreviewTiers(photo);
expect(await storage.stat(k640)).toBeNull();
expect(await storage.stat(k1280)).toBeNull();
});
it('a tier written before the extension rewrite is still found, by lookup and by cleanup', async () => {
// JPEG bytes under the source's `.png` name — what generatePreviewImage
// produced before it started rewriting the extension. Served as JPEG by
// the route (the Content-Type comes from the `.webp` suffix only).
const photo = await photoWith('legacy.png');
const legacyKey = `previews/preview_w640_p${photo.id}_legacy.png`;
await storage.put(legacyKey, await sharp({ create: { width: 640, height: 448, channels: 3, background: '#123' } }).jpeg().toBuffer());
puts = [];
expect(await imageProcessor.ensurePreviewImageAtWidth(photo, 640)).toBe(legacyKey);
expect(puts).toHaveLength(0);
expect(imageProcessor.previewTierKeys(photo)).toContain(legacyKey);
await imageProcessor.deletePreviewTiers(photo);
expect(await storage.stat(legacyKey)).toBeNull();
});
it('never lists the canonical 1920 rendition, which preview_path owns', () => {
const keys = imageProcessor.previewTierKeys({ id: 9, path: 'e/a.png', source_origin: 'managed' });
expect(keys.some((k) => k.includes('w1920'))).toBe(false);
expect(keys.every((k) => k.includes('p9_'))).toBe(true);
// Both encodings plus the legacy source-extension key, per width.
expect(keys).toEqual([
'previews/preview_w640_p9_a.jpg', 'previews/preview_w640_p9_a.webp', 'previews/preview_w640_p9_a.png',
'previews/preview_w1280_p9_a.jpg', 'previews/preview_w1280_p9_a.webp', 'previews/preview_w1280_p9_a.png',
]);
});
it('does not duplicate the legacy key when the source already is a lowercase .jpg', () => {
const keys = imageProcessor.previewTierKeys({ id: 9, path: 'e/a.jpg', source_origin: 'managed' });
expect(keys.filter((k) => k.includes('w640'))).toEqual([
'previews/preview_w640_p9_a.jpg', 'previews/preview_w640_p9_a.webp',
]);
});
});
@@ -0,0 +1,25 @@
jest.mock('../../src/utils/logger', () => ({ error: jest.fn() }));
const { scheduledTask } = require('../../src/services/scheduledTask');
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('starts once, skips overlap and drains the accepted run on stop', async () => {
let finish;
const work = jest.fn(() => new Promise(resolve => { finish = resolve; }));
const task = scheduledTask(work, { interval: 100 });
task.start(); task.start();
expect(jest.getTimerCount()).toBe(1);
await jest.advanceTimersByTimeAsync(300);
expect(work).toHaveBeenCalledTimes(1);
let stopped = false;
const stop = task.stop().then(() => { stopped = true; });
await Promise.resolve(); expect(stopped).toBe(false);
finish(); await stop; expect(stopped).toBe(true);
expect(jest.getTimerCount()).toBe(0);
await jest.advanceTimersByTimeAsync(1000); expect(work).toHaveBeenCalledTimes(1);
});
it('cancels a delayed first run and can restart cleanly', async () => {
const work = jest.fn(); const task = scheduledTask(work, { interval: 100, initialDelay: 10 });
task.start(); await task.stop(); await jest.advanceTimersByTimeAsync(200); expect(work).not.toHaveBeenCalled();
task.start(); await jest.advanceTimersByTimeAsync(10); expect(work).toHaveBeenCalledTimes(1);
await task.stop();
});
@@ -0,0 +1,15 @@
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
const secure = require('../../src/services/secureImageService');
beforeEach(() => { jest.useFakeTimers(); secure.dispose(); });
afterEach(() => { secure.dispose(); jest.useRealTimers(); });
it('owns one timer for many tokens and sweeps expired capabilities', () => {
for (let i = 0; i < 100; i++) secure.generateSecureToken(i, 'gallery_public_1_1', { expiresIn: 1 });
expect(jest.getTimerCount()).toBe(1); expect(secure.tokenCache.size).toBe(100);
jest.advanceTimersByTime(60000); expect(secure.tokenCache.size).toBe(0);
secure.dispose(); expect(jest.getTimerCount()).toBe(0);
});
it('disposes all session/rate caches and restarts on demand', () => {
secure.generateSecureToken(1, 'session'); secure.sessionTokens.set('a', 'b'); secure.rateLimitCache.set('a', 'b');
secure.dispose(); expect(secure.sessionTokens.size + secure.rateLimitCache.size + secure.tokenCache.size).toBe(0);
secure.generateSecureToken(1, 'session'); expect(jest.getTimerCount()).toBe(1);
});
@@ -42,6 +42,7 @@ async function bootDb() {
t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.boolean('prompt_shown').notNullable().defaultTo(false);
t.string('installation_id', 64);
t.string('public_key', 59);
t.text('private_key_encrypted');
@@ -32,6 +32,7 @@ async function bootDb() {
t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v2');
t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.boolean('prompt_shown').notNullable().defaultTo(false);
t.string('installation_id', 64);
t.string('public_key', 59);
t.text('private_key_encrypted');
@@ -26,6 +26,7 @@ async function bootDb() {
t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.boolean('prompt_shown').notNullable().defaultTo(false);
t.string('installation_id', 64);
t.string('public_key', 59);
t.text('private_key_encrypted');
@@ -25,6 +25,7 @@ async function bootDb() {
t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.boolean('prompt_shown').notNullable().defaultTo(false);
t.string('installation_id', 64);
t.string('public_key', 59);
t.text('private_key_encrypted');
@@ -0,0 +1,62 @@
/**
* generateVideoPlaceholder() must not touch the database when the caller
* already supplies width/height (videoProcessor.js's thumbnail-generation
* fallback does exactly this).
*
* Why it matters: processUploadedPhotos() (chunked video upload) holds a
* per-file SQLite transaction open across thumbnail generation. SQLite's
* knex pool defaults to a single connection, so any second, un-transacted
* db() query made while that transaction is open blocks until
* acquireConnectionTimeout (60s in production) — verified directly against
* an isolated SQLite db (codex review of #1371/#1372). Passing explicit
* dimensions must skip getThumbnailSettings()'s db() call entirely, not
* just tolerate its failure.
*/
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
const mockDbSpy = jest.fn(() => {
throw new Error('db() must not be called when width/height are supplied');
});
jest.mock('../../src/database/db', () => ({ db: (...args) => mockDbSpy(...args) }));
const storageModule = require('../../src/services/storage');
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
describe('generateVideoPlaceholder skips the settings DB lookup given explicit dimensions', () => {
let storage;
let root;
let imageProcessor;
beforeAll(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-vidplaceholder-'));
storage = new LocalFsStorage({ root });
await storage.init();
storageModule.setStorageForTesting(storage);
imageProcessor = require('../../src/services/imageProcessor');
}, 30000);
afterAll(async () => {
storageModule.resetStorage();
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
});
afterEach(() => mockDbSpy.mockClear());
it('never calls db() when width/height are provided', async () => {
const key = await imageProcessor.generateVideoPlaceholder('demo.mp4', { width: 300, height: 300 });
expect(key).toBe('thumbnails/thumb_demo.jpg');
expect(await storage.exists(key)).toBe(true);
expect(mockDbSpy).not.toHaveBeenCalled();
});
it('falls through to defaults (not a throw) when db() fails and no dimensions were given', async () => {
const key = await imageProcessor.generateVideoPlaceholder('demo2.mp4');
expect(key).toBe('thumbnails/thumb_demo2.jpg');
expect(mockDbSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,167 @@
jest.mock('../../src/utils/logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }));
describe.each(['backgroundProcessor', 'faceQueue'])('%s shutdown', name => {
let worker, db, processPhoto, featureEnabled, janitorUpdate, releases;
const prefix = name === 'faceQueue' ? 'FACE_PROCESSOR' : 'UPLOAD_PROCESSOR';
let previousEnv;
class SidecarUnavailableError extends Error {}
function deferred() {
let resolve;
const promise = new Promise(done => { resolve = done; });
releases.push(resolve);
return { promise, resolve };
}
beforeEach(() => {
jest.resetModules();
jest.useFakeTimers();
releases = [];
previousEnv = { ...process.env };
delete process.env[`${prefix}_DISABLED`];
process.env[`${prefix}_CONCURRENCY`] = '2';
process.env[`${prefix}_POLL_MS`] = '2000';
process.env.FACE_PROCESSOR_BACKOFF_MS = '30000';
processPhoto = jest.fn().mockResolvedValue({ status: 'skipped' });
featureEnabled = jest.fn().mockResolvedValue(true);
janitorUpdate = jest.fn().mockResolvedValue(0);
const chain = { where: jest.fn().mockReturnThis(), update: janitorUpdate };
db = jest.fn(() => chain);
db.client = { config: { client: 'pg' } };
// Claims and processing are controlled at the I/O boundary; the real
// workers, janitors, idle sleeps and stopServices run in every test.
db.transaction = jest.fn().mockResolvedValue(null);
jest.doMock('../../src/database/db', () => ({ db }));
jest.doMock('../../src/services/photoProcessor', () => ({ processPhoto }));
jest.doMock('../../src/services/faceProcessor', () => ({
processPhotoFaces: processPhoto, TransientSourceError: class extends Error {},
}));
jest.doMock('../../src/services/faceClient', () => ({ SidecarUnavailableError }));
jest.doMock('../../src/services/faceSettings', () => ({
isFeatureEnabled: featureEnabled, isEnabledForEvent: jest.fn().mockResolvedValue(false),
}));
worker = require(`../../src/services/${name}`);
});
afterEach(async () => {
releases.forEach(resolve => resolve());
const stopped = worker.stop();
// Also cleans up the original, non-interruptible implementation when a
// regression assertion fails; the test never needs to wait a real minute.
await jest.advanceTimersByTimeAsync(60000);
await stopped;
jest.useRealTimers();
process.env = previousEnv;
});
async function expectPromptStop(stop = () => worker.stop()) {
let done = false;
const stopped = stop().then(() => { done = true; });
await jest.advanceTimersByTimeAsync(0);
expect(done).toBe(true);
expect(jest.getTimerCount()).toBe(0);
await stopped;
}
it('wakes all idle workers and the minute-long janitor through stopServices', async () => {
worker.start();
await jest.advanceTimersByTimeAsync(0);
expect(jest.getTimerCount()).toBe(3);
// Confirm normal polling still runs before shutdown.
await jest.advanceTimersByTimeAsync(2000);
expect(db.transaction).toHaveBeenCalledTimes(4);
await expectPromptStop(() => require('../../src/services/serviceShutdown').stopServices());
});
it('wakes claim-error backoff and can start a fresh run after stopping', async () => {
db.transaction.mockRejectedValue(new Error('database unavailable'));
worker.start();
await jest.advanceTimersByTimeAsync(0);
await expectPromptStop();
db.transaction.mockResolvedValue(null);
worker.start();
await jest.advanceTimersByTimeAsync(0);
expect(jest.getTimerCount()).toBe(3);
await expectPromptStop();
});
it('drains active processing for every stop caller and prevents overlapping restarts', async () => {
const processing = deferred();
processPhoto.mockReturnValue(processing.promise);
db.transaction.mockResolvedValueOnce({ id: 1 });
worker.start();
await jest.advanceTimersByTimeAsync(0);
expect(processPhoto).toHaveBeenCalledWith(1);
let done = false;
const first = worker.stop();
expect(worker.stop()).toBe(first);
first.then(() => { done = true; });
worker.start();
await jest.advanceTimersByTimeAsync(0);
expect(done).toBe(false);
expect(db.transaction).toHaveBeenCalledTimes(2);
processing.resolve({ status: 'skipped' });
await expectPromptStop();
expect(done).toBe(true);
expect(db.transaction).toHaveBeenCalledTimes(2);
});
it('drains a claim already in flight without starting another poll', async () => {
const claim = deferred();
db.transaction.mockReturnValueOnce(claim.promise);
worker.start();
await jest.advanceTimersByTimeAsync(0);
const stopped = worker.stop();
claim.resolve({ id: 2 });
await expectPromptStop(() => stopped);
expect(processPhoto).toHaveBeenCalledWith(2);
expect(db.transaction).toHaveBeenCalledTimes(2);
});
it('does not schedule new waits when pending database work finishes after stop', async () => {
const claim = deferred();
const janitor = deferred();
db.transaction.mockReturnValue(claim.promise);
janitorUpdate.mockReturnValue(janitor.promise);
worker.start();
await jest.advanceTimersByTimeAsync(0);
let done = false;
const stopped = worker.stop().then(() => { done = true; });
claim.resolve(null);
await jest.advanceTimersByTimeAsync(0);
expect(done).toBe(false);
janitor.resolve(0);
await expectPromptStop(() => stopped);
});
if (name === 'faceQueue') {
it('interrupts the ten-second sleep with faces disabled by default', async () => {
featureEnabled.mockResolvedValue(false);
worker.start();
await jest.advanceTimersByTimeAsync(0);
expect(db.transaction).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(3);
await expectPromptStop();
});
it('releases a claimed photo and interrupts the sidecar outage backoff', async () => {
db.transaction.mockResolvedValueOnce({ id: 3, event_id: 5 });
processPhoto.mockRejectedValue(new SidecarUnavailableError('offline'));
worker.start();
await jest.advanceTimersByTimeAsync(0);
expect(janitorUpdate).toHaveBeenCalledWith({ face_status: 'pending', face_started_at: null });
await expectPromptStop();
expect(worker.inFlightByEvent.size).toBe(0);
});
it('does not claim new work after a pending feature check resolves during shutdown', async () => {
const feature = deferred();
featureEnabled.mockReturnValue(feature.promise);
worker.start();
const stopped = worker.stop();
feature.resolve(true);
await expectPromptStop(() => stopped);
expect(db.transaction).not.toHaveBeenCalled();
});
}
});
@@ -31,7 +31,7 @@ describe('resolvePhotoContentType', () => {
});
describe('serving routes use the resolver', () => {
const routes = ['gallery.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
const routes = ['gallery/media.js', 'gallery/downloads.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
it.each(routes)('%s sets no Content-Type from photo.mime_type directly', (name) => {
const src = fs.readFileSync(path.join(__dirname, '../../src/routes', name), 'utf8');
expect(src).not.toMatch(/'Content-Type':\s*photo\.mime_type/);
@@ -0,0 +1,79 @@
const dns = require('dns');
const http = require('http');
const axios = require('axios');
const { validateExternalUrlAsync } = require('../../src/utils/networkValidation');
const { pinnedRequestOptions } = require('../../src/utils/pinnedRequest');
afterEach(() => jest.restoreAllMocks());
it('never performs a second DNS lookup that could reach a private listener', async () => {
const received = jest.fn();
const server = http.createServer((req, res) => { received(); res.end('private'); });
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const url = `http://rebind.example:${server.address().port}/hook`;
const preflight = jest.spyOn(dns.promises, 'lookup').mockResolvedValue([{ address: '192.0.2.1', family: 4 }]);
const unsafeLookup = jest.spyOn(dns, 'lookup').mockImplementation((_host, opts, cb) => {
if (typeof opts === 'function') { cb = opts; opts = {}; }
cb(null, ...(opts.all ? [[{ address: '127.0.0.1', family: 4 }]] : ['127.0.0.1', 4]));
});
let options;
try {
const check = await validateExternalUrlAsync(url);
options = pinnedRequestOptions(check);
await expect(axios.post(url, 'private-data', { ...options, timeout: 200 })).rejects.toThrow();
expect(preflight).toHaveBeenCalledTimes(1);
expect(unsafeLookup).not.toHaveBeenCalled(); expect(received).not.toHaveBeenCalled();
} finally {
options?.httpAgent.destroy(); options?.httpsAgent.destroy();
await new Promise(resolve => server.close(resolve));
}
});
it('preserves the original Host and refuses redirects while using the pinned address', async () => {
const hosts = [];
const server = http.createServer((req, res) => {
hosts.push(req.headers.host); res.writeHead(302, { Location: 'http://localhost/private' }); res.end();
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const host = `pinned.example:${server.address().port}`;
// Test the transport in isolation: production checks reject this private IP.
const options = pinnedRequestOptions({ valid: true, hostname: 'pinned.example', addresses: [{ address: '127.0.0.1', family: 4 }] });
try {
const res = await axios.post(`http://${host}/hook`, 'body', { ...options, validateStatus: () => true });
expect(res.status).toBe(302); expect(hosts).toEqual([host]); expect(options.proxy).toBe(false);
} finally { options.httpAgent.destroy(); options.httpsAgent.destroy(); await new Promise(resolve => server.close(resolve)); }
});
it('fails closed for missing DNS results', () => {
expect(() => pinnedRequestOptions({ valid: true })).toThrow('validated destination');
});
it('preserves TLS SNI and certificate hostname verification for a pinned connection', async () => {
const fs = require('fs/promises');
const path = require('path');
const dir = await fs.mkdtemp(path.join(require('os').tmpdir(), 'picpeak-tls-pin-'));
const key = path.join(dir, 'key.pem'), cert = path.join(dir, 'cert.pem');
require('child_process').execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes',
'-keyout', key, '-out', cert, '-days', '1', '-subj', '/CN=pinned.example',
'-addext', 'subjectAltName=DNS:pinned.example'], { stdio: 'ignore' });
const certificate = await fs.readFile(cert);
const seen = [];
const server = require('https').createServer({ key: await fs.readFile(key), cert: certificate }, (req, res) => {
seen.push({ host: req.headers.host, servername: req.socket.servername }); res.end('ok');
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const makeOptions = hostname => {
const options = pinnedRequestOptions({ valid: true, hostname, addresses: [{ address: '127.0.0.1', family: 4 }] });
options.httpsAgent.options.ca = certificate;
return options;
};
const allowed = makeOptions('pinned.example'), wrong = makeOptions('wrong.example');
try {
const host = `pinned.example:${server.address().port}`;
expect((await axios.post(`https://${host}/hook`, 'data', { ...allowed, timeout: 2000 })).status).toBe(200);
expect(seen).toEqual([{ host, servername: 'pinned.example' }]);
await expect(axios.post(`https://wrong.example:${server.address().port}/hook`, 'data', { ...wrong, timeout: 2000 }))
.rejects.toMatchObject({ code: 'ERR_TLS_CERT_ALTNAME_INVALID' });
expect(seen).toHaveLength(1);
} finally {
for (const options of [allowed, wrong]) { options.httpAgent.destroy(); options.httpsAgent.destroy(); }
await new Promise(resolve => server.close(resolve));
await fs.rm(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,28 @@
const EventEmitter = require('events');
jest.mock('../../src/utils/logger', () => ({ info: jest.fn() }));
const logger = require('../../src/utils/logger');
const middleware = require('../../src/middleware/apiRequestLogger');
const { requestLogPath } = require('../../src/utils/requestLogPath');
const marker = 'SECRET_TEST_CAPABILITY';
it.each([
`/api/gallery/g/photos?token=${marker}&password=${marker}`,
`/api/gallery/g/verify-token/${marker}`,
`/api/gallery/g/show/${marker}/state`,
`/api/images/g/photo/1/signed/${marker}`,
`/api/secure-images/g/secure/1/${marker}`,
`/api/secure-images/g/secure-download/1/${marker}`,
`/api/public/contracts/${marker}/sign`,
`/api/customer/auth/password-reset/${marker}`,
`/api/public/newsletter/unsubscribe/${marker}`,
])('does not log capabilities on request or response: %s', (originalUrl) => {
logger.info.mockClear();
const res = new EventEmitter(); res.statusCode = 200;
middleware({ originalUrl, method: 'GET' }, res, jest.fn());
res.emit('finish');
expect(logger.info).toHaveBeenCalledTimes(2);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(marker);
});
it('retains useful non-secret routes and removes control characters', () => {
expect(requestLogPath('/api/admin/events/12?search=private')).toBe('/api/admin/events/12');
expect(requestLogPath('/api/admin/events\nforged')).not.toContain('\n');
});
@@ -0,0 +1,13 @@
const http = require('http');
it('directs Supertest to the actual IPv6 listener instead of an unrelated IPv4 port', async () => {
jest.resetModules();
const request = require('supertest');
const server = http.createServer((_req, res) => { res.end('actual test listener'); });
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '::1', resolve); });
try {
const response = await request(server).get('/');
expect(response.status).toBe(200);
expect(response.text).toBe('actual test listener');
expect(response.request.url).toContain('://[::1]:');
} finally { await new Promise(resolve => server.close(resolve)); }
});
@@ -20,7 +20,7 @@ jest.mock('../../src/database/db', () => {
const dbFn = () => ({
insert(row) {
inserted.push(row);
return { onConflict: () => ({ ignore: async () => undefined }) };
return { onConflict: () => ({ ignore: async () => undefined, merge: async () => undefined }) };
},
});
return { db: dbFn };
+23
View File
@@ -1,3 +1,18 @@
// Supertest 6 binds an IPv6 wildcard listener but hardcodes an IPv4 URL.
// macOS can allocate that IPv6 port while a different IPv4 service owns it.
// Address the listener's actual family so a test cannot reach that service.
jest.mock('supertest/lib/test', () => {
const Test = jest.requireActual('supertest/lib/test');
const serverAddress = Test.prototype.serverAddress;
Test.prototype.serverAddress = function(app, path) {
const url = serverAddress.call(this, app, path);
return app.address()?.family === 'IPv6'
? url.replace('://127.0.0.1:', '://[::1]:')
: url;
};
return Test;
});
beforeAll(() => {
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-secret';
@@ -8,3 +23,11 @@ beforeAll(() => {
process.env.STORAGE_PATH = '/storage';
}
});
// Dispose resources loaded by this suite using the application's draining
// shutdown. Individual fixtures still own temporary files and other DB pools.
afterAll(async () => {
await require('./src/services/serviceShutdown').stopServices();
const loadedDb = require.cache[require.resolve('./src/database/db')];
if (typeof loadedDb?.exports.db?.destroy === 'function') await loadedDb.exports.db.destroy();
});
@@ -0,0 +1,16 @@
/** Fresh installs and upgraded databases expose the same event timestamp. */
exports.up = async function (knex) {
if (!await knex.schema.hasTable('events')) return;
if (!await knex.schema.hasColumn('events', 'updated_at')) {
await knex.schema.alterTable('events', table => {
table.timestamp('updated_at');
});
}
// Do not replace existing modification times on a repeated migration.
await knex('events').whereNull('updated_at').update({ updated_at: knex.ref('created_at') });
};
exports.down = async function (knex) {
if (await knex.schema.hasTable('events') && await knex.schema.hasColumn('events', 'updated_at')) {
await knex.schema.alterTable('events', table => table.dropColumn('updated_at'));
}
};
@@ -0,0 +1,26 @@
/** Non-expiring JWTs need revocation records that cleanup never removes. */
exports.up = async function (knex) {
if (!await knex.schema.hasTable('revoked_tokens')) return;
if (!await knex.schema.hasColumn('revoked_tokens', 'expires_at')) return;
const column = await knex('revoked_tokens').columnInfo('expires_at');
if (!column.nullable) {
await knex.schema.alterTable('revoked_tokens', table => {
table.timestamp('expires_at').nullable().alter();
});
}
};
exports.down = async function (knex) {
if (!await knex.schema.hasTable('revoked_tokens')) return;
if (!await knex.schema.hasColumn('revoked_tokens', 'expires_at')) return;
// Refuse to discard permanent revocations or silently give them a TTL.
if (await knex('revoked_tokens').whereNull('expires_at').first()) {
throw new Error('Cannot roll back while permanent token revocations exist');
}
const column = await knex('revoked_tokens').columnInfo('expires_at');
if (column.nullable) {
await knex.schema.alterTable('revoked_tokens', table => {
table.timestamp('expires_at').notNullable().alter();
});
}
};
@@ -0,0 +1,31 @@
// Tracks whether this installation has ever been offered the one-time
// usage-reporting opt-in prompt shown to an existing admin on their first
// login after an update (see UsageService.markPromptShown()). A fresh
// install that went through the setup wizard's own opt-in step sets this
// too, so upgraded and brand-new installs share one "already asked" marker
// and neither gets asked twice. Separate from `notice_dismissed`, which
// governs the persistent, re-visitable dashboard banner instead.
const { formatBoolean } = require('../../src/utils/dbCompat');
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('product_usage_state'))) return;
if (!(await knex.schema.hasColumn('product_usage_state', 'prompt_shown'))) {
await knex.schema.alterTable('product_usage_state', (t) => {
t.boolean('prompt_shown').notNullable().defaultTo(false);
});
}
// Existing participants already made their choice before this marker
// existed. Preserve it through withdrawal, pending delivery and identity
// recovery; none of those transitions should produce a fresh invitation.
await knex('product_usage_state')
.whereNot('status', 'disabled')
.update({ prompt_shown: formatBoolean(true) });
};
exports.down = async function (knex) {
if (
(await knex.schema.hasTable('product_usage_state')) &&
(await knex.schema.hasColumn('product_usage_state', 'prompt_shown'))
) {
await knex.schema.alterTable('product_usage_state', (t) => t.dropColumn('prompt_shown'));
}
};
+135 -135
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "3.123.0-beta.0",
"version": "3.131.3-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "3.123.0-beta.0",
"version": "3.131.3-beta.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -39,7 +39,7 @@
"knex": "^2.4.2",
"mailparser": "^3.9.9",
"mime-types": "^3.0.1",
"multer": "2.2.0",
"multer": "2.3.0",
"node-cron": "^3.0.2",
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
@@ -53,7 +53,7 @@
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "2.17.7",
"sharp": "0.35.3",
"sharp": "0.35.4",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
@@ -1716,9 +1716,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"cpu": [
"arm64"
],
@@ -1734,13 +1734,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
"@img/sharp-libvips-darwin-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"cpu": [
"x64"
],
@@ -1756,20 +1756,20 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
"@img/sharp-libvips-darwin-x64": "1.3.3"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
@@ -1779,9 +1779,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"cpu": [
"arm64"
],
@@ -1795,9 +1795,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"cpu": [
"x64"
],
@@ -1811,9 +1811,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"cpu": [
"arm"
],
@@ -1827,9 +1827,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"cpu": [
"arm64"
],
@@ -1843,9 +1843,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"cpu": [
"ppc64"
],
@@ -1859,9 +1859,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"cpu": [
"riscv64"
],
@@ -1875,9 +1875,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"cpu": [
"s390x"
],
@@ -1891,9 +1891,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"cpu": [
"x64"
],
@@ -1907,9 +1907,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"cpu": [
"arm64"
],
@@ -1923,9 +1923,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"cpu": [
"x64"
],
@@ -1939,9 +1939,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"cpu": [
"arm"
],
@@ -1957,13 +1957,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
"@img/sharp-libvips-linux-arm": "1.3.3"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"cpu": [
"arm64"
],
@@ -1979,13 +1979,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
"@img/sharp-libvips-linux-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"cpu": [
"ppc64"
],
@@ -2001,13 +2001,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
"@img/sharp-libvips-linux-ppc64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"cpu": [
"riscv64"
],
@@ -2023,13 +2023,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
"@img/sharp-libvips-linux-riscv64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"cpu": [
"s390x"
],
@@ -2045,13 +2045,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
"@img/sharp-libvips-linux-s390x": "1.3.3"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"cpu": [
"x64"
],
@@ -2067,13 +2067,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
"@img/sharp-libvips-linux-x64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"cpu": [
"arm64"
],
@@ -2089,13 +2089,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"cpu": [
"x64"
],
@@ -2111,17 +2111,17 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
"@emnapi/runtime": "^1.11.3"
},
"engines": {
"node": ">=20.9.0"
@@ -2131,16 +2131,16 @@
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
@@ -2150,9 +2150,9 @@
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"cpu": [
"arm64"
],
@@ -2169,9 +2169,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"cpu": [
"ia32"
],
@@ -2188,9 +2188,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"cpu": [
"x64"
],
@@ -8084,9 +8084,9 @@
}
},
"node_modules/joi": {
"version": "17.13.4",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz",
"integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==",
"version": "17.13.7",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.7.tgz",
"integrity": "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "^9.3.0",
@@ -8120,9 +8120,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",
@@ -9195,9 +9195,9 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
@@ -9398,9 +9398,9 @@
}
},
"node_modules/nodemailer": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
"integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -11258,9 +11258,9 @@
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
@@ -11274,31 +11274,31 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
"@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.4"
},
"peerDependenciesMeta": {
"@types/node": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.130.0-beta.0",
"version": "3.131.3-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
@@ -49,7 +49,7 @@
"knex": "^2.4.2",
"mailparser": "^3.9.9",
"mime-types": "^3.0.1",
"multer": "2.2.0",
"multer": "2.3.0",
"node-cron": "^3.0.2",
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
@@ -63,7 +63,7 @@
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "2.17.7",
"sharp": "0.35.3",
"sharp": "0.35.4",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
+59 -48
View File
@@ -218,7 +218,7 @@ app.use((req, res, next) => {
});
// CORS configuration (apply only to API routes)
const { isAllowedOrigin, multipartOriginAllowed } = require('./src/utils/requestOrigin');
const { isAllowedOrigin } = require('./src/utils/requestOrigin');
const corsOptions = {
origin: function (origin, callback) {
@@ -528,43 +528,10 @@ app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' }));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
// CSRF protection: require JSON Content-Type on mutating API requests
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
app.use('/api', (req, res, next) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const contentType = req.headers['content-type'] || '';
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
// Allow empty-body requests (e.g. logout), multipart for uploads, and JSON for API calls
if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
}
// multipart is exactly what a cross-site <form> can send without a
// preflight, and in a split-origin deployment (SameSite=None) the admin
// cookie rides along to the upload routes. Browsers label such a
// submission Sec-Fetch-Site: cross-site (and always send Origin on a
// cross-origin POST); non-browser clients send neither header and pass.
if (contentType.includes('multipart/form-data') && !multipartOriginAllowed(req)) {
return res.status(403).json({ error: 'Cross-site multipart request rejected' });
}
}
next();
});
// Validate the origin independently of body length/content type.
app.use('/api', require('./src/middleware/csrf'));
// Request logging for API routes (with timestamps)
const apiRequestLogger = (req, res, next) => {
try {
const started = Date.now();
const ts = new Date().toISOString();
logger.info(`[${ts}] ${req.method} ${req.originalUrl}`);
res.on('finish', () => {
const ms = Date.now() - started;
const tsDone = new Date().toISOString();
logger.info(`[${tsDone}] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
});
} catch (_) {}
next();
};
app.use('/api', apiRequestLogger);
app.use('/api', require('./src/middleware/apiRequestLogger'));
// Maintenance mode middleware - add after body parsing but before routes
app.use(maintenanceMiddleware);
@@ -1098,6 +1065,30 @@ if (spaCatchAll) {
// Global error handler (must be last)
app.use(errorHandler);
// App construction is side-effect free with respect to listening and workers.
let httpServer;
let shutdownPromise;
// Docker stops a container 10 s after SIGTERM by default (compose sets no
// stop_grace_period), so the drain must finish inside that window.
const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 8000;
async function stopServer() {
if (shutdownPromise) return shutdownPromise;
shutdownPromise = (async () => {
const close = httpServer ? new Promise((resolve, reject) => httpServer.close(err => err ? reject(err) : resolve())) : Promise.resolve();
const timeout = setTimeout(() => httpServer?.closeAllConnections(), Math.floor(SHUTDOWN_TIMEOUT_MS / 2));
timeout.unref();
try {
await Promise.all([close, require('./src/services/serviceShutdown').stopServices()]);
} finally {
clearTimeout(timeout);
// Always release the pool: a rejected service stop must not leave
// ref'd sockets keeping the process alive until SIGKILL.
await db.destroy();
}
})();
return shutdownPromise;
}
// Initialize services
async function startServer() {
try {
@@ -1122,14 +1113,8 @@ async function startServer() {
const { initializeCleanupJob } = require('./src/utils/authSecurity');
initializeCleanupJob();
// Initialize temp upload cleanup job
const { cleanupTempUploads } = require('./src/utils/cleanupTempUploads');
// Run cleanup on startup
cleanupTempUploads();
// Schedule periodic cleanup every hour
setInterval(cleanupTempUploads, 60 * 60 * 1000);
logger.info('Temp upload cleanup scheduled');
require('./src/utils/cleanupTempUploads').startTempUploadCleanup();
// Start file watcher
startFileWatcher();
// External-media folder watcher (issue 1187): imports new files into
@@ -1149,6 +1134,10 @@ async function startServer() {
startTransferCleanup();
// Custom-resolution download archives (#858) are disposable renditions —
// sweep them once their TTL passes so .download-cache doesn't grow forever.
// Best-effort, as before the scheduler refactor: a transient DB error on
// this one UPDATE must not abort the whole server start.
await require('./src/services/downloadJobService').recoverOrphanedJobs()
.catch((err) => logger.error('Download job recovery failed', { error: err.message }));
startDownloadJobCleanup();
// Reveal-mode scheduler (#838): minutely stamp for scheduled reveals.
startRevealScheduler();
@@ -1306,7 +1295,7 @@ async function startServer() {
// lazy means they don't pay for a module graph they never use.
require('./src/services/faceQueue').start();
app.listen(PORT, () => {
httpServer = app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
@@ -1325,10 +1314,32 @@ async function startServer() {
});
} catch (error) {
logger.error('Failed to start server:', error);
process.exit(1);
await stopServer();
process.exitCode = 1;
}
}
startServer();
if (require.main === module) {
let stopping = false;
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, () => {
if (stopping) {
logger.warn(`Received ${signal} again during shutdown, exiting immediately`);
process.exit(1);
}
stopping = true;
// The drain itself has no deadline; a hung worker must not keep the
// process alive past the container's stop grace period.
setTimeout(() => {
logger.error(`Shutdown exceeded ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`);
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS).unref();
stopServer().catch(error => { logger.error('Shutdown failed', { error: error.message }); process.exitCode = 1; });
});
}
startServer();
}
app.startServer = startServer;
app.stopServer = stopServer;
module.exports = app; // For testing
@@ -26,6 +26,8 @@ jest.mock('../database/db', () => {
return { db: mockDb, withRetry };
});
jest.mock('../utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
jest.mock('../utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn().mockResolvedValue(false) }));
jest.mock('../utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
@@ -83,8 +85,12 @@ function mockEventAndAssignment({ event, assignment }) {
assignChain.where = jest.fn().mockReturnValue(assignChain);
assignChain.first = jest.fn().mockResolvedValue(assignment);
db.mockImplementationOnce(() => eventsChain)
.mockImplementationOnce(() => assignChain);
db.mockImplementation((table) => {
if (table === 'events') return eventsChain;
if (table === 'customer_accounts') return { ...eventsChain, first: jest.fn().mockResolvedValue({ id: 7 }) };
if (table === 'event_customer_assignments') return assignChain;
throw new Error('Unexpected table: ' + table);
});
return { eventsChain, assignChain };
}
@@ -101,7 +107,7 @@ describe('verifyGalleryAccess — customer-minted JWT with active assignment', (
it('allows access when the event_customer_assignments row exists', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
type: 'gallery', iat: Math.floor(Date.now() / 1000),
eventId: 42,
via: 'customer',
customerId: 7,
@@ -132,7 +138,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
type: 'gallery', iat: Math.floor(Date.now() / 1000),
eventId: 42,
via: 'customer',
customerId: 7,
@@ -162,7 +168,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
// and start 403'ing per-event-password sessions.
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
type: 'gallery', iat: Math.floor(Date.now() / 1000),
eventId: 42,
customerId: 7,
// intentionally no `via` claim
@@ -194,7 +200,7 @@ describe('verifyGalleryAccess — per-event-password JWT', () => {
it('does NOT touch event_customer_assignments and passes through', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
type: 'gallery', iat: Math.floor(Date.now() / 1000),
eventId: 42,
// No via, no customerId — this is the legacy per-event-password
// flow where every guest mints their own JWT after entering the
+14 -4
View File
@@ -120,7 +120,14 @@ const createPhotoUploader = (options = {}) => {
files: options.maxFiles || 2000,
fieldSize: 10 * 1024 * 1024,
parts: 10000,
headerPairs: 2000
headerPairs: 2000,
// CVE-2026-82333: no preset in this factory is currently wired up to
// a route (nothing imports createPhotoUploader et al. — routes build
// their own multer instances directly), but every preset gets the
// limit anyway so it can't be adopted later without it. None of the
// uploaders this factory builds have a legitimate use for
// array-indexed field names.
fieldArrayIndexLimit: 0
},
fileFilter: createFileFilter(ALLOWED_TYPES.media, {
validateMagicNumbers: true
@@ -146,7 +153,8 @@ const createLogoUploader = (options = {}) => {
}
}),
limits: {
fileSize: options.maxSize || SIZE_LIMITS.medium
fileSize: options.maxSize || SIZE_LIMITS.medium,
fieldArrayIndexLimit: 0 // CVE-2026-82333 — see createPhotoUploader comment
},
fileFilter: createFileFilter(ALLOWED_TYPES.logos, {
skipMagicValidation: ['image/svg+xml']
@@ -172,7 +180,8 @@ const createFaviconUploader = (options = {}) => {
}
}),
limits: {
fileSize: options.maxSize || SIZE_LIMITS.small
fileSize: options.maxSize || SIZE_LIMITS.small,
fieldArrayIndexLimit: 0 // CVE-2026-82333 — see createPhotoUploader comment
},
fileFilter: createFileFilter(ALLOWED_TYPES.favicons, {
skipMagicValidation: ['image/x-icon', 'image/vnd.microsoft.icon']
@@ -194,7 +203,8 @@ const createGalleryUploader = (destDir, options = {}) => {
dest: destDir,
limits: {
fileSize: options.maxSize || SIZE_LIMITS.large,
files: options.maxFiles || 10
files: options.maxFiles || 10,
fieldArrayIndexLimit: 0 // CVE-2026-82333 — see createPhotoUploader comment
},
fileFilter: createFileFilter(ALLOWED_TYPES.photos)
};
+1 -1
View File
@@ -421,7 +421,7 @@ async function initializeDatabase() {
table.integer('user_id').nullable(); // User who owned the token
table.string('token_type', 20); // admin, gallery, etc.
table.timestamp('revoked_at').defaultTo(db.fn.now());
table.timestamp('expires_at').notNullable(); // When token would have expired
table.timestamp('expires_at').nullable(); // NULL retains tokens without a known expiry
table.string('reason', 100); // password_change, logout, compromised, etc.
table.text('metadata'); // Additional JSON data
@@ -0,0 +1,11 @@
const logger = require('../utils/logger');
const { requestLogPath } = require('../utils/requestLogPath');
module.exports = function apiRequestLogger(req, res, next) {
const started = Date.now();
const path = requestLogPath(req.originalUrl);
logger.info(`${req.method} ${path}`);
res.once('finish', () => {
logger.info(`${req.method} ${path} -> ${res.statusCode} (${Date.now() - started}ms)`);
});
next();
};
+9 -100
View File
@@ -1,9 +1,5 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isMissingRolesSchema } = require('../utils/dbErrors');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const sessionAccess = require('../services/sessionAccessService');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
@@ -32,100 +28,10 @@ async function adminAuth(req, res, next) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
logger.warn('Revoked token used', {
userId: decoded.id,
tokenType: decoded.type
});
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') {
logger.warn('Non-admin token used for admin endpoint', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(403).json({ error: 'Insufficient permissions' });
}
// IP validation (optional - can be strict or just log)
const currentIp = req.ip || req.connection.remoteAddress;
if (decoded.ip && decoded.ip !== currentIp) {
logger.warn('Token used from different IP', {
userId: decoded.id,
tokenIp: decoded.ip,
currentIp: currentIp
});
}
// Check if admin still exists and is active, including role info
// Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario)
let admin;
try {
admin = await db('admin_users')
.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',
'roles.id as role_id',
'roles.name as role_name'
)
.first();
} catch (joinError) {
// Fail CLOSED on anything that isn't a genuinely missing roles schema:
// the fallback below fabricates super_admin, so a transient query failure
// (connection reset, deadlock, statement timeout, pool exhaustion) must
// not become a free privilege upgrade for every scoped admin. Rethrow →
// outer catch → 401, which is already how every other transient DB fault
// in this try block behaves (isTokenRevoked / isTokenBeforeCutoff both
// hit the DB here). apiTokenAuth takes the same posture on the v1
// surface, differing only in its 500.
if (!isMissingRolesSchema(joinError)) throw joinError;
// Fallback: roles table may not exist yet during upgrade
// Query without role join - user will have no role info but can still authenticate
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'username', 'email', 'password_changed_at')
.first();
if (admin) {
admin.role_id = null;
admin.role_name = 'super_admin'; // Assume super_admin for existing users during upgrade
}
}
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if password was changed after token was issued. JWT `iat` has
// 1-second resolution; `password_changed_at` is sub-second. Floor the
// comparison so a token issued in the *same* second as the password
// change isn't incorrectly rejected — that race used to bite anyone
// logging in immediately after a password reset/change.
if (admin.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(admin.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
logger.warn('Token used after password change', { userId: decoded.id });
return res.status(401).json({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED'
});
}
const admin = await sessionAccess.admin(decoded);
const requestIp = req.ip || req.connection?.remoteAddress;
if (decoded.ip && requestIp && decoded.ip !== requestIp) {
logger.info('admin session IP changed', { accountId: admin.id, tokenIp: decoded.ip, requestIp });
}
// Add user info to request (enhanced with role)
@@ -146,7 +52,10 @@ async function adminAuth(req, res, next) {
next();
} catch (error) {
logger.error('Auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
res.status(error.statusCode || 401).json({
error: error.isOperational ? error.message : 'Authentication failed',
...(error.isOperational && { code: error.code }),
});
}
}
+15
View File
@@ -0,0 +1,15 @@
const { mutationOriginAllowed } = require('../utils/requestOrigin');
module.exports = function csrfProtection(req, res, next) {
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return next();
if (!mutationOriginAllowed(req)) {
return res.status(403).json({ error: 'Cross-site request rejected' });
}
const contentType = (req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
const hasBody = Number(req.headers['content-length']) > 0 || !!req.headers['transfer-encoding'];
const jsonLike = contentType === 'application/json' || contentType.endsWith('+json');
if (hasBody && !jsonLike && contentType !== 'multipart/form-data') {
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
}
next();
};
+12 -72
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Customer Authentication Middleware
*
@@ -10,10 +11,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 sessionAccess = require('../services/sessionAccessService');
const logger = require('../utils/logger');
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
@@ -25,7 +23,7 @@ async function customerAuth(req, res, next) {
// normal (page polling, pre-login session probes). Bump to debug
// for noisy investigations only.
logger.debug('[customerAuth] no token on request', {
url: req.originalUrl,
url: requestLogPath(req.originalUrl),
hasCookieHeader: !!req.headers?.cookie,
cookieKeys: Object.keys(req.cookies || {}),
});
@@ -42,7 +40,7 @@ async function customerAuth(req, res, next) {
decoded = verified.payload;
} catch (err) {
logger.warn('[customerAuth] jwt verification failed', {
url: req.originalUrl,
url: requestLogPath(req.originalUrl),
errorName: err.name,
errorMessage: err.message,
});
@@ -52,71 +50,10 @@ async function customerAuth(req, res, next) {
return res.status(401).json({ error: 'Invalid token', code: 'JWT_INVALID' });
}
if (await isTokenRevoked(decoded)) {
logger.warn('[customerAuth] token revoked', {
url: req.originalUrl,
customerId: decoded.customerId,
tokenType: decoded.type,
iat: decoded.iat,
});
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,
tokenType: decoded.type,
});
return res.status(403).json({ error: 'Insufficient permissions', code: 'WRONG_TOKEN_TYPE' });
}
// IP drift gets logged but doesn't reject — same lenient policy as
// adminAuth. Customers may roam between mobile networks frequently.
const currentIp = req.ip || req.connection.remoteAddress;
if (decoded.ip && decoded.ip !== currentIp) {
logger.info('Customer token used from different IP', {
customerId: decoded.customerId,
tokenIp: decoded.ip,
currentIp,
});
}
const customer = await db('customer_accounts')
.where({ id: decoded.customerId, is_active: formatBoolean(true) })
.select('id', 'email', 'display_name', 'first_name', 'last_name', 'password_changed_at', 'preferred_language')
.first();
if (!customer) {
// Either deleted, deactivated, or the id was forged. 401 across the
// board so the frontend session-expiry handler kicks in.
logger.warn('[customerAuth] customer row not found / inactive', {
url: req.originalUrl,
customerId: decoded.customerId,
});
return res.status(401).json({ error: 'Invalid token', code: 'CUSTOMER_NOT_FOUND' });
}
if (customer.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(customer.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
logger.warn('[customerAuth] token rejected: password_changed_at', {
url: req.originalUrl,
customerId: decoded.customerId,
iat: decoded.iat,
passwordChangedSeconds,
});
return res.status(401).json({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED',
});
}
const customer = await sessionAccess.customer(decoded);
const requestIp = req.ip || req.connection?.remoteAddress;
if (decoded.ip && requestIp && decoded.ip !== requestIp) {
logger.info('customer session IP changed', { accountId: customer.id, tokenIp: decoded.ip, requestIp });
}
req.customer = {
@@ -131,7 +68,10 @@ async function customerAuth(req, res, next) {
next();
} catch (error) {
logger.error('Customer auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
res.status(error.statusCode || 401).json({
error: error.isOperational ? error.message : 'Authentication failed',
...(error.isOperational && { code: error.code }),
});
}
}
+13 -2
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Global error handler middleware.
* Catches all errors and returns standardized responses.
@@ -92,6 +93,16 @@ const handleKnownErrors = (err) => {
return new ValidationError('Unexpected file field');
}
// CVE-2026-82333: multer 2.3.0's fieldArrayIndexLimit rejects multipart
// field names with an oversized bracket array index (e.g. `a[99999999]`)
// before the DoS-prone field parser runs. Without this mapping the
// resulting MulterError has no .statusCode/.status and falls through to
// a 500 here, so map it to a proper 400 like the other multer limits.
if (err.code === 'LIMIT_FIELD_ARRAY_INDEX') {
const { ValidationError } = require('../utils/errors');
return new ValidationError('Field name array index too large');
}
return err;
};
@@ -119,7 +130,7 @@ const errorHandler = (err, req, res, next) => {
// Log the error
const logContext = {
url: req.originalUrl,
url: requestLogPath(req.originalUrl),
method: req.method,
ip: req.ip,
statusCode,
@@ -161,7 +172,7 @@ const errorHandler = (err, req, res, next) => {
*/
const notFoundHandler = (req, res, next) => {
const { NotFoundError } = require('../utils/errors');
next(new NotFoundError('Route', req.originalUrl));
next(new NotFoundError('Route', requestLogPath(req.originalUrl)));
};
/**
+5 -1
View File
@@ -1,3 +1,4 @@
const cleanupTimers = new Set();
const crypto = require('crypto');
const { db } = require('../database/db');
const logger = require('../utils/logger');
@@ -211,7 +212,7 @@ function strictRateLimit(options = {}) {
const store = new Map();
// Clean up old entries periodically
setInterval(() => {
const cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, data] of store.entries()) {
if (data.resetTime < now) {
@@ -219,6 +220,8 @@ function strictRateLimit(options = {}) {
}
}
}, windowMs);
cleanupTimer.unref();
cleanupTimers.add(cleanupTimer);
return (req, res, next) => {
const ip = req.ip || req.connection.remoteAddress;
@@ -255,6 +258,7 @@ function strictRateLimit(options = {}) {
}
module.exports = {
dispose() { cleanupTimers.forEach(clearInterval); cleanupTimers.clear(); },
feedbackRateLimit,
strictRateLimit,
generateGuestIdentifier,
+70 -233
View File
@@ -1,267 +1,104 @@
const jwt = require('jsonwebtoken');
const { db, withRetry } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const access = require('../services/galleryAccessService');
/**
* True when a logged-in admin is explicitly previewing this gallery (#868).
*
* Two conditions, both required:
* 1. The explicit intent flag `?admin_preview=1` is present. The plain share
* link stays byte-identical to a guest's, so the password gate is still
* testable as a guest while logged in as admin — and the bypass is visible
* in the URL without being reusable (it carries no secret).
* 2. A VERIFIED admin session — the httpOnly `admin_token` cookie (rides along
* on same-origin API calls) or an Authorization: Bearer header, never the
* URL. Must decode as `type: 'admin'`, issuer `picpeak-auth`.
*
* The cookie is tried FIRST and the Bearer is accepted only when it is itself an
* admin token (#981 review): the frontend attaches a gallery Bearer to gallery
* endpoints, and a header-first, type-blind read would let a coexisting gallery
* session shadow the admin cookie and wrongly disable the preview.
*
* Fails closed on any verification error. Replaces the old `?preview=<raw-JWT>`
* scheme, which leaked a 24h admin token into the address bar.
*/
// Cookie first: a coexisting gallery Bearer must not shadow an admin preview.
function decodeAdminPreview(req) {
if (req.query?.admin_preview !== '1') return null;
// Cookie first, then a Bearer — but only an admin-typed token satisfies it.
const candidates = [];
if (req.cookies?.admin_token) candidates.push(req.cookies.admin_token);
const candidates = [req.cookies?.admin_token];
const header = req.headers?.authorization;
if (header && header.startsWith('Bearer ')) candidates.push(header.slice(7));
for (const token of candidates) {
if (header?.startsWith('Bearer ')) candidates.push(header.slice(7));
for (const token of candidates.filter(Boolean)) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth', algorithms: ['HS256'],
});
if (decoded.type === 'admin') return decoded;
} catch { /* try the next candidate */ }
}
return null;
}
// Signature-only predicate retained for UI-intent callers. It never authorizes.
function isAdminPreview(req) {
return decodeAdminPreview(req) !== null;
}
/**
* The full session check behind the preview bypass. A verified signature is
* not a live session: adminAuth also rejects revoked tokens, tokens issued
* before the restore cutoff, deactivated admins and tokens minted before the
* admin's last password change. Without those a logged-out or deactivated
* admin token kept unlocking every draft and password gallery until `exp`
* (30 days with remember-me). Sets req.isAdminPreview on success so the
* downstream reveal-mode and logging checks read one verified flag.
*/
async function verifyAdminPreview(req) {
if (req.isAdminPreview === true) return true;
function attachAccess(req, event, grant) {
req.event = event;
req.galleryAccess = grant;
req.isAdminPreview = grant.kind === 'admin';
req.accessLevel = grant.session?.accessLevel || 'guest';
req.viaCustomer = grant.session?.via === 'customer';
req.sessionID = req.isAdminPreview ? `gallery_admin_preview_${event.id}`
: `gallery_${grant.kind === 'public' ? 'public_' : ''}${event.id}_${Date.now()}`;
const ip = req.ip || req.connection?.remoteAddress || 'unknown';
const userAgent = req.get?.('User-Agent') || 'unknown';
req.clientInfo = {
ip, userAgent, fingerprint: `${ip}-${userAgent}`.substring(0, 32), timestamp: Date.now(),
};
}
async function verifyAdminPreview(req, event) {
if (req.isAdminPreview && req.galleryAccess && (!event || event.id === req.event?.id)) return true;
const decoded = decodeAdminPreview(req);
if (!decoded) return false;
try {
if (await isTokenRevoked(decoded) || await isTokenBeforeCutoff(decoded)) return false;
const admin = await withRetry(async () => db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'password_changed_at')
.first());
if (!admin) return false;
if (admin.password_changed_at) {
const changedSeconds = Math.floor(new Date(admin.password_changed_at).getTime() / 1000);
if (decoded.iat < changedSeconds) return false;
}
} catch (err) {
logger.warn('Admin preview session check failed', { error: err.message });
const slug = req.params?.slug || req.requestedSlug;
if (!event && !slug) return false;
event = event || await db('events').where({ slug }).select('*').first();
if (!event) return false;
const grant = access.grant(event, 'admin', decoded);
await access.authorize(event, grant);
attachAccess(req, event, grant);
return true;
} catch (error) {
logger.debug('Admin gallery preview denied', { code: error.code });
req.adminPreviewDenied = error;
return false;
}
req.isAdminPreview = true;
return true;
}
// Middleware to verify gallery access
function decodeGalleryToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'], issuer: 'picpeak-auth' });
} catch (error) {
// Legacy gallery tokens lacked an issuer, but still need the same type,
// lifecycle and session checks as current tokens.
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
}
throw error;
}
}
async function verifyGalleryAccess(req, res, next) {
try {
const requestedSlug = req.params.slug || req.requestedSlug;
// Admin preview (#868) is resolved BEFORE any gallery credential (#981
// review): a coexisting gallery token/Bearer must not shadow it, and the
// admin session must never fall into the `type !== 'gallery'` reject path
// below. Per-request bypass — draft + password relaxed, NO gallery JWT
// minted (a lingering guest cookie would muddy the coexisting-cookies case).
// req.isAdminPreview flags downstream logging to keep it out of guest stats.
if (await verifyAdminPreview(req)) {
if (!requestedSlug) {
return res.status(401).json({ error: 'No token provided' });
}
const previewEvent = await withRetry(async () => db('events')
.where({ slug: requestedSlug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('*').first());
if (!previewEvent) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
req.event = previewEvent;
req.isAdminPreview = true;
req.sessionID = `gallery_admin_preview_${previewEvent.id}`;
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
timestamp: Date.now()
};
return next();
}
const token = getGalleryTokenFromRequest(req, requestedSlug);
let event;
if (!token) {
if (!requestedSlug) {
return res.status(401).json({ error: 'No token provided' });
}
event = await withRetry(async () => db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
})
.select('*').first());
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (!requiresPassword) {
req.event = event;
req.sessionID = `gallery_public_${event.id}_${Date.now()}`;
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
timestamp: Date.now()
};
return next();
}
return res.status(401).json({ error: 'No token provided' });
}
// Try to verify with issuer first, fallback to no issuer for backward compatibility
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'picpeak-auth'
});
} catch (error) {
// If verification fails with issuer, try without issuer (backward compatibility)
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
} else {
throw error;
}
}
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
// Only gallery-scoped tokens grant gallery access. Every legitimate
// path (password login, share link, client access, customer-minted,
// slideshow) mints type:'gallery'. Reject anything else — e.g. a guest
// identity token (type:'guest', for feedback attribution) that carries a
// matching eventId — instead of relying on other token types incidentally
// lacking an eventId to fail the id match below.
if (decoded.type !== 'gallery') {
if (await verifyAdminPreview(req)) return next();
// The caller asked for a preview explicitly: report why it was refused
// instead of falling through to a misleading guest-token error.
if (req.adminPreviewDenied?.isOperational) throw req.adminPreviewDenied;
const slug = req.params.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, slug);
const decoded = token ? decodeGalleryToken(token) : null;
if (decoded && decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid token type for gallery access' });
}
// If we have a slug in the URL params or from pre-middleware, verify it matches.
// (Admin preview never reaches here — it returns above — so drafts stay
// filtered for every real gallery-token request.)
if (requestedSlug) {
// Verify by slug and ensure it matches the token's event
event = await withRetry(async () => db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
})
.select('*').first());
// Verify the token's eventId matches
if (event && event.id !== decoded.eventId) {
return res.status(403).json({ error: 'Token does not match requested gallery' });
}
} else {
// Fallback to using eventId from token
event = await withRetry(async () => db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
})
.select('*').first());
}
if (!event) {
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
return res.status(404).json({ error: 'Gallery not found or expired' });
}
// Customer-minted gallery JWTs (#354): when the customer obtained
// this token via /api/customer/events/:slug/access-token, the
// payload carries `via:'customer'` and `customerId`. The admin
// can revoke the customer's access at any time by removing the
// event_customer_assignments row from the "Manage galleries"
// dialog on the customer detail page. Re-check that row here so
// the revocation takes effect on the customer's very next
// request — no token-blacklisting machinery required.
if (decoded.via === 'customer' && decoded.customerId) {
const assignment = await withRetry(async () => {
return await db('event_customer_assignments')
.where({
event_id: event.id,
customer_account_id: decoded.customerId,
})
.first();
});
if (!assignment) {
logger.info('[verifyGalleryAccess] Customer assignment revoked, rejecting token', {
customerId: decoded.customerId,
eventId: event.id,
});
return res.status(403).json({
error: 'Access to this gallery has been revoked',
code: 'CUSTOMER_ASSIGNMENT_REVOKED',
});
}
}
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
req.event = event;
req.accessLevel = decoded.accessLevel || 'guest';
// Customer-portal provenance (#746/#849): portal-minted tokens carry
// via:'customer' but NO accessLevel (they default to guest), while
// PIN-client logins carry accessLevel:'client' without `via`. Activity
// attribution/dedup needs the distinction, so surface it explicitly.
req.viaCustomer = decoded.via === 'customer';
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
// Create client info for logging (similar to secureImageMiddleware but simpler)
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32), // Limit to 32 chars for DB column
timestamp: Date.now()
};
logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
next();
if (!slug && !decoded?.eventId) return res.status(401).json({ error: 'No token provided' });
const event = await withRetry(() => db('events').where(slug ? { slug } : { id: decoded.eventId }).select('*').first());
if (!event) return res.status(404).json({ error: 'Gallery not found or expired' });
const grant = access.grant(event, decoded ? 'gallery' : 'public', decoded);
await access.authorize(event, grant);
attachAccess(req, event, grant);
return next();
} catch (error) {
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
res.status(401).json({ error: 'Invalid token' });
if (!error.isOperational) logger.error('Error verifying gallery access', { error: error.message });
return res.status(error.statusCode || 401).json({
error: error.isOperational ? error.message : 'Invalid token',
...(error.code && error.isOperational && { code: error.code }),
});
}
}
+7 -1
View File
@@ -1,6 +1,11 @@
const { db } = require('../database/db');
const logger = require('../utils/logger');
function canAccessEvent(admin, event) {
return Boolean(admin && event && (admin.roleName === 'super_admin'
|| event.created_by == null || Number(event.created_by) === Number(admin.id)));
}
/**
* Middleware to enforce event ownership for non-super_admin users.
* Super admins bypass the check. Other admins can only access events they created.
@@ -23,7 +28,7 @@ function requireEventOwnership(req, res, next) {
return res.status(404).json({ error: 'Event not found' });
}
// Allow access if: event has no owner (legacy/system), or admin owns it
if (event.created_by && event.created_by !== req.admin.id) {
if (!canAccessEvent(req.admin, event)) {
return res.status(403).json({ error: 'Access denied' });
}
next();
@@ -165,6 +170,7 @@ function requireProjectOwnership(req, res, next) {
}
module.exports = {
canAccessEvent,
requireEventOwnership,
filterOwnedEventIds,
scopeEventsQuery,
+3 -2
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Permission Checking Middleware for RBAC
* Provides role-based access control with caching for performance
@@ -143,7 +144,7 @@ function requirePermission(permissions, options = { requireAll: false }) {
userId: req.admin.id,
username: req.admin.username,
requiredPermissions: permArray,
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
method: req.method
});
throw new ForbiddenError('Insufficient permissions');
@@ -180,7 +181,7 @@ function requireSuperAdmin() {
logger.warn('Super admin access denied', {
userId: req.admin.id,
username: req.admin.username,
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
method: req.method
});
throw new ForbiddenError('Super Admin access required');
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
const { db } = require('../database/db');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
@@ -10,12 +11,24 @@ class SecureImageMiddleware {
this.suspiciousIPs = new Set();
this.blockedFingerprints = new Set();
this.rateLimitViolations = new Map();
this.cleanupTimer = null;
}
start() {
if (this.cleanupTimer) return;
this.cleanupTimer = setInterval(() => this.cleanup(), 300000);
this.cleanupTimer.unref();
}
dispose() {
clearInterval(this.cleanupTimer); this.cleanupTimer = null;
this.suspiciousIPs.clear(); this.blockedFingerprints.clear(); this.rateLimitViolations.clear();
}
/**
* Main security middleware for image access
*/
secureImageAccess = async (req, res, next) => {
this.start();
try {
const startTime = Date.now();
const clientIP = this.getClientIP(req);
@@ -56,7 +69,7 @@ class SecureImageMiddleware {
error: error.message,
stack: error.stack,
ip: req.ip,
path: req.path
path: requestLogPath(req.originalUrl || req.path)
});
res.status(500).json({
@@ -328,7 +341,7 @@ class SecureImageMiddleware {
client_ip: req.clientInfo?.ip || req.ip,
client_fingerprint: req.clientInfo?.fingerprint,
user_agent: req.get('User-Agent')?.substring(0, 255),
request_path: req.path,
request_path: requestLogPath(req.originalUrl || req.path),
request_method: req.method,
details: JSON.stringify(details),
timestamp: new Date().toISOString()
@@ -409,9 +422,4 @@ class SecureImageMiddleware {
// Create singleton instance
const secureImageMiddleware = new SecureImageMiddleware();
// Setup cleanup interval
setInterval(() => {
secureImageMiddleware.cleanup();
}, 300000); // Every 5 minutes
module.exports = secureImageMiddleware;
+2 -1
View File
@@ -26,7 +26,7 @@ const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes - reduced DB queries
// behaviour is unchanged: the timer fires every 5 min as long as
// the server has anything else keeping the loop alive (HTTP server,
// other intervals), which is always.
setInterval(() => {
const cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [token, lastActivity] of sessions.entries()) {
if (now - lastActivity > DEFAULT_SESSION_TIMEOUT) {
@@ -208,6 +208,7 @@ function getActiveSessions() {
}
module.exports = {
dispose: () => { clearInterval(cleanupTimer); sessions.clear(); cachedTimeout = null; cacheExpiry = 0; },
sessionTimeoutMiddleware,
isSessionExpired,
endSession,
+4 -3
View File
@@ -182,16 +182,17 @@ router.post('/logout', adminAuth, handleAsync(async (req, res) => {
// old header-only read skipped revocation entirely for cookie-based logout,
// leaving the JWT valid until expiry while reporting a successful logout.
const token = req.token;
clearAdminAuthCookie(res);
if (token) {
// End the in-memory session AND revoke the JWT (GHSA-cjqh) — the token
// is otherwise valid until expiry, so photoAuth/adminAuth would keep
// honouring it after logout. isTokenRevoked() checks this store.
endSession(token);
const { revokeToken } = require('../utils/tokenRevocation');
await revokeToken(token, 'logout');
if (!await revokeToken(token, 'logout')) {
throw new Error('Token revocation failed');
}
}
// Clear the auth cookie so the browser stops sending the (now revoked) JWT.
clearAdminAuthCookie(res);
// Log activity
await logActivity('admin_logout',
+5 -1
View File
@@ -202,7 +202,11 @@ const picpeakUpload = multer({
destination: (req, file, cb) => cb(null, os.tmpdir()),
filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`),
}),
limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large
// CVE-2026-82333: this route only ever consumes a single unnamed file
// field (`backup`) — no legitimate bracket-indexed field name (e.g.
// `a[0]`) exists in its form. fieldArrayIndexLimit: 0 rejects any field
// name using array-index syntax at all, closing multer's field-parser DoS.
limits: { fileSize: 5 * 1024 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5 GB — .picpeak with photos can be large
});
// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of
+3 -1
View File
@@ -109,7 +109,9 @@ const pdfLogoStorage = multer.diskStorage({
const pdfLogoUpload = multer({
storage: pdfLogoStorage,
limits: { fileSize: 5 * 1024 * 1024 },
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 },
fileFilter: (_req, file, cb) => {
const allowed = ['image/png', 'image/jpeg', 'image/svg+xml'];
if (allowed.includes(file.mimetype)) cb(null, true);
+3 -1
View File
@@ -32,7 +32,9 @@ const pageLogoStorage = multer.diskStorage({
const pageLogoUpload = multer({
storage: pageLogoStorage,
limits: { fileSize: 5 * 1024 * 1024 },
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 },
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
if (validateFileType(file.originalname, file.mimetype, allowed)) cb(null, true);
+3 -1
View File
@@ -73,7 +73,9 @@ const signedPdfStorage = multer.diskStorage({
const signedPdfUpload = multer({
storage: signedPdfStorage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
// CVE-2026-82333: single unnamed `file` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 10 MB
fileFilter: (req, file, cb) => {
const allowed = ['application/pdf'];
if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true);
+23 -2
View File
@@ -2,7 +2,7 @@ const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { databaseBackupService } = require('../services/databaseBackup');
const { databaseBackupService, isUnderPubliclyServableRoot } = require('../services/databaseBackup');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
@@ -60,7 +60,28 @@ router.put('/config', requirePermission('backup.create'), async (req, res) => {
'database_backup_email_on_failure',
'database_backup_email_on_success'
];
// A backup.create holder (the built-in `admin` role has it without
// settings.edit or backup.restore) could otherwise point backups at a
// public static mount and fetch the dump unauthenticated — see
// isUnderPubliclyServableRoot's comment (GHSA-jw8m-43r2-jqrm class).
if (
typeof req.body.database_backup_destination_path === 'string'
&& isUnderPubliclyServableRoot(req.body.database_backup_destination_path)
) {
return res.status(400).json({ error: 'Destination path must not be inside a publicly served directory' });
}
// A retention of 0 or less pushes cleanupOldBackups' cutoff to today or
// the future, deleting every completed backup on the next scheduled run
// — a backup.create holder achieving what backup.delete gates on /cleanup.
if (
req.body.database_backup_retention_days !== undefined
&& (!Number.isFinite(req.body.database_backup_retention_days) || req.body.database_backup_retention_days < 1)
) {
return res.status(400).json({ error: 'database_backup_retention_days must be a positive number' });
}
const updates = [];
for (const [key, value] of Object.entries(req.body)) {
+14 -578
View File
@@ -5,7 +5,7 @@
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../../database/db');
const { formatBoolean } = require('../../utils/dbCompat');
const { slugify } = require('../../utils/slug');
const { adminAuth } = require('../../middleware/auth');
const { requirePermission, userHasAllPermissions } = require('../../middleware/permissions');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../../utils/emailNormalization');
@@ -25,14 +25,13 @@ const eventTypeService = require('../../services/eventTypeService');
const { normaliseEventTimeTriple } = require('../../services/eventService');
const { hasColumnCached } = require('../../utils/schemaCache');
const { requireEventOwnership } = require('../../middleware/ownership');
const { getAppSetting } = require('../../utils/appSettings');
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
const { clampIntOrUndefined } = require('../../utils/numericHelpers');
const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl');
const downloadZipService = require('../../services/downloadZipService');
const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults');
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults,
getImageSecurityDefaults, resolveImageSecurityColumns, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
const { KEYBIND_MODES } = require('../../services/feedbackDefaults');
const { validateHeroImageAnchor, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade } = require('./helpers');
/**
* `events.slug` is UNIQUE, and both routes that mint one do a read-then-insert
@@ -174,7 +173,6 @@ async function queueGalleryCreatedEmail(event, { password, requirePassword } = {
module.exports = (router) => {
// Create new event
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').notEmpty().trim().custom(async (value) => {
@@ -240,7 +238,6 @@ module.exports = (router) => {
body('watermark_text').optional().trim(),
// #328 follow-up: per-event opt-in for presigned-URL "Download All".
// Bypasses watermarks; admin must enable knowingly.
body('allow_presigned_download').optional().isBoolean(),
// Feedback sub-toggles (#1044). Optional: omitting them inherits the
// global Settings > Events defaults.
body('allow_ratings').optional().isBoolean(),
@@ -298,571 +295,13 @@ module.exports = (router) => {
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
// Get field requirements from settings
const fieldRequirements = await getEventFieldRequirements();
const {
event_type,
event_name,
event_date,
// Migration 137 — calendar time fields. is_full_day defaults to
// true at the service layer when undefined (legacy form payloads).
event_time_start,
event_time_end,
is_full_day,
admin_email,
password,
welcome_message = '',
color_theme = null,
expiration_days = 30,
allow_user_uploads = false,
upload_category_id = null,
allow_downloads = true,
disable_right_click = false,
enable_devtools_protection: enableDevtoolsProtectionInput,
watermark_downloads = false,
watermark_text = null,
allow_presigned_download = false,
require_password: requirePasswordInput,
// Feedback settings. The allow_* sub-toggles deliberately have NO
// destructuring defaults: `undefined` means "the caller didn't say",
// which inherits the global Settings > Events default (#1044). The
// admin create form posts explicit values (it seeds its own panel
// from the same globals), so inheritance here is what covers the v1
// API and any other caller that omits them.
feedback_enabled: feedbackEnabledInput,
allow_ratings: allowRatingsInput,
allow_likes: allowLikesInput,
allow_comments: allowCommentsInput,
allow_favorites: allowFavoritesInput,
allow_reactions: allowReactionsInput,
allow_color_labels: allowColorLabelsInput,
keybind_mode: keybindModeInput,
require_name_email = false,
moderate_comments = true,
show_feedback_to_guests = true,
// The create form has always shown the identity-mode chooser and this
// route has never read it, so a gallery created as 'guest' quietly came
// out 'simple' and the photographer had to set it again on the event.
// Surfaced by adding a third mode (#1197); the fix is the same for all
// three. Unknown values fall back rather than reaching the column,
// which on Postgres is guarded by a CHECK constraint.
identity_mode: identityModeInput,
// CSS Template
css_template_id = null,
// Hero logo settings
hero_logo_visible = true,
// Header style settings
header_style = 'standard',
hero_divider_style = 'wave',
// Hero image anchor position (#162)
hero_image_anchor = 'center',
// Photo cap
photo_cap = null,
// Client access settings (#172)
client_access_enabled = false,
client_password = null,
// Draft mode
is_draft = true,
// Default photo sort
default_photo_sort = 'upload_date_desc',
// Banner overrides (#440 / #932) — see the insert below.
promo_mode = 'inherit',
promo_markdown = null,
info_mode = 'inherit',
info_markdown = null
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
const customerEmail = getCustomerEmailFromPayload(req.body);
// Phone field is opt-in via the global setting (#322). If disabled,
// ignore whatever the client posted — defence in depth against form
// bypass.
const phoneEnabled = await isPhoneFieldEnabled();
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null;
const customerColumnsAvailable = await hasCustomerContactColumns();
// Conditional validation based on settings
const validationErrors = [];
if (fieldRequirements.require_customer_name && !customerName) {
validationErrors.push({ path: 'customer_name', msg: 'Customer name is required' });
}
if (fieldRequirements.require_customer_email && !customerEmail) {
validationErrors.push({ path: 'customer_email', msg: 'Customer email is required' });
}
if (fieldRequirements.require_admin_email && !admin_email) {
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
}
if (fieldRequirements.require_event_date && !event_date) {
validationErrors.push({ path: 'event_date', msg: 'Event date is required' });
}
if (validationErrors.length > 0) {
return res.status(400).json({ errors: validationErrors });
}
// Default require_password from global "event_default_require_password"
// setting when the body omits it (#317 — admins want to flip the default).
let requirePasswordFallback = true;
if (requirePasswordInput === undefined) {
const setting = await readBooleanSetting('event_default_require_password');
if (setting !== undefined) requirePasswordFallback = setting;
}
const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
// Default feedback_enabled from global "event_default_feedback_enabled"
// setting when the body omits it (#520 — same pattern as require_password
// above, lets admins make Guest Feedback ON the out-of-box default for
// new events instead of toggling it on every time).
let feedbackEnabledFallback = false;
if (feedbackEnabledInput === undefined) {
const setting = await readBooleanSetting('event_default_feedback_enabled');
if (setting !== undefined) feedbackEnabledFallback = setting;
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
// Sub-toggle defaults from the global Settings > Events values (#1044).
// One batched read; an explicitly-sent body value still wins.
const feedbackDefaults = applyFeedbackDefaults({
allow_ratings: allowRatingsInput,
allow_likes: allowLikesInput,
allow_comments: allowCommentsInput,
allow_favorites: allowFavoritesInput,
allow_reactions: allowReactionsInput,
allow_color_labels: allowColorLabelsInput,
keybind_mode: keybindModeInput,
}, await resolveEventFeedbackDefaults());
// Debug logging
logger.debug('Download control values', {
allow_downloads,
disable_right_click,
watermark_downloads,
watermark_text,
require_password: requirePassword,
types: {
allow_downloads: typeof allow_downloads,
disable_right_click: typeof disable_right_click,
watermark_downloads: typeof watermark_downloads
}
});
let passwordValidation = null;
if (requirePassword) {
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. Uses the shared util so accented names
// (Família, Decoração, etc.) get transliterated instead of dropped
// — see backend/src/utils/slug.js for the why (#525).
const processedEventName = slugify(event_name);
// Use event_date in slug if provided, otherwise use random suffix
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
}
// Generate share link respecting configured format
const shareToken = crypto.randomBytes(16).toString('hex');
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds (random 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)
// If expiration is not required, expires_at will be null (never expires)
// If event_date is not provided, use current date as base for expiration
let expires_at = null;
if (fieldRequirements.require_expiration) {
const baseDate = event_date || new Date().toISOString().split('T')[0];
// Parse YYYY-MM-DD format as local date to avoid timezone issues
if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(baseDate);
}
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 });
// Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158).
let effectiveHeaderStyle = header_style;
let effectiveDividerStyle = hero_divider_style;
if (color_theme && (!req.body.header_style || !req.body.hero_divider_style)) {
try {
if (typeof color_theme === 'string' && color_theme.startsWith('{')) {
const parsed = JSON.parse(color_theme);
if (!req.body.header_style && parsed.headerStyle) {
effectiveHeaderStyle = parsed.headerStyle;
}
if (!req.body.hero_divider_style && parsed.heroDividerStyle) {
effectiveDividerStyle = parsed.heroDividerStyle;
}
}
} catch (_) {
// color_theme is not JSON nothing to extract
}
}
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
const brandingDefaults = await getBrandingDefaults();
// 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. `!= 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
// time. Only an explicit per-event size overrides it.
const effectiveHeroLogoSize = req.body.hero_logo_size || null;
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
// Inherit "Detect dev tools" from the global Image Security setting unless
// the request explicitly overrides it (#317 — admin disabled it globally
// but new events still got it ON because the column default is true).
const protectionDefaults = await getDownloadProtectionDefaults();
// #1296 — the other four Image-security settings, which were written,
// rendered as controls, and read by nothing. Same inheritance rule as
// the devtools setting below. Creation-time only; see
// getImageSecurityDefaults for why existing events are left alone.
const imageSecurityColumns = resolveImageSecurityColumns(
req.body,
await getImageSecurityDefaults(),
);
const effectiveEnableDevtoolsProtection =
enableDevtoolsProtectionInput !== undefined
? enableDevtoolsProtectionInput
: protectionDefaults.enable_devtools_protection !== undefined
? protectionDefaults.enable_devtools_protection
: true;
// Migration 137 — normalise calendar time triple. Throws AppError
// 400 when is_full_day=false but times are malformed/inverted.
const calendarTriple = normaliseEventTimeTriple({
event_time_start, event_time_end, is_full_day,
});
const calendarColumnsExist = await hasColumnCached('events', 'is_full_day');
// Insert into database
// Seed the new event's Live Slideshow display style from the PICPEAK-WIDE
// preset (app_settings, Settings → Slideshow). New events inherit it and the
// admin can still override per event. Watermark is left NULL = inherit the
// global watermark; the share token is minted on demand, not seeded. Guarded
// so un-migrated installs (mid-branch) don't reference missing columns.
let slideshowSeed = {};
if (await hasColumnCached('events', 'show_interval_ms')) {
try {
// parseInt-first: the previous `Number.isFinite(+v)` pre-check let
// NaN through for null/''/true (+null is 0, parseInt(null) is NaN),
// producing show_interval_ms=NaN in the INSERT — PG rejects that
// with "invalid input syntax for type integer" while SQLite
// silently stores NULL, so event creation 500'd on PG whenever the
// slideshow app_settings rows were absent.
const intP = (v, min, max) => clampIntOrUndefined(v, min, max);
const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined);
const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000);
const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS);
const tms = intP(await getAppSetting('slideshow_transition_ms', undefined), 100, 5000);
const cf = oneOf(await getAppSetting('slideshow_colorfilter', undefined), SLIDESHOW_COLORFILTERS);
if (i !== undefined) slideshowSeed.show_interval_ms = i;
if (tr) slideshowSeed.show_transition = tr;
if (tms !== undefined) slideshowSeed.show_transition_ms = tms;
if (cf) slideshowSeed.show_colorfilter = cf;
} catch (e) {
logger.warn('Failed to seed slideshow settings from global preset', { error: e.message });
}
}
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
...slideshowSeed,
event_date: event_date || null,
...(calendarColumnsExist ? {
event_time_start: calendarTriple.event_time_start,
event_time_end: calendarTriple.event_time_end,
is_full_day: formatBoolean(calendarTriple.is_full_day),
} : {}),
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
...(customerPhone ? { customer_phone: customerPhone } : {}),
host_name: customerName || null,
host_email: customerEmail || null,
admin_email: admin_email || null,
password_hash,
// Opt-in recoverable copy (#1271), written with the hash so the two
// can never disagree. Empty unless the security setting is on.
...(await galleryPasswordColumns({
...(requirePassword && password ? { password } : {}),
...(client_access_enabled && client_password ? { clientPassword: client_password } : {}),
})),
welcome_message,
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads,
upload_category_id,
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection),
// Request value, else the global default, else the column default —
// a key absent here is one the database fills in (#1296).
...imageSecurityColumns,
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text,
allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'),
require_password: formatBoolean(requirePassword),
css_template_id: css_template_id || null,
// Already formatBoolean-coerced above, or null = inherit global (#756).
hero_logo_visible: effectiveHeroLogoVisible,
hero_logo_size: effectiveHeroLogoSize,
hero_logo_position: effectiveHeroLogoPosition,
// Banner overrides. Both were accepted by the validators above and
// then dropped here, so an API client could POST info_mode:'off' or a
// custom banner, get 201, and find the row still on 'inherit'.
// Markdown is only stored for 'custom' — same rule the PUT applies.
promo_mode: ['inherit', 'custom', 'off'].includes(promo_mode) ? promo_mode : 'inherit',
promo_markdown: promo_mode === 'custom' && typeof promo_markdown === 'string' && promo_markdown.trim()
? promo_markdown.trim() : null,
info_mode: ['inherit', 'custom', 'off'].includes(info_mode) ? info_mode : 'inherit',
info_markdown: info_mode === 'custom' && typeof info_markdown === 'string' && info_markdown.trim()
? info_markdown.trim() : null,
header_style: effectiveHeaderStyle || 'standard',
hero_divider_style: effectiveDividerStyle || 'wave',
hero_image_anchor: hero_image_anchor || 'center',
photo_cap: photo_cap || null,
is_draft: formatBoolean(parseBooleanInput(is_draft, true)),
default_photo_sort: default_photo_sort || 'upload_date_desc',
// Client access (#172)
client_access_enabled: formatBoolean(client_access_enabled),
...(client_access_enabled && client_password ? {
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
client_share_token: crypto.randomBytes(32).toString('hex')
} : {}),
// Per-event opt-in for hero-photo OG share image (#474). Defaults
// false on create — admin opts in from the event detail page once
// they've picked a hero they're comfortable surfacing publicly.
og_image_share_enabled: formatBoolean(req.body.og_image_share_enabled === true),
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// #1271 — the setting was read before the hashes; re-check after the write
await dropCopiesIfStorageOff(eventId);
// Apply customer-account assignments (#354). Skip when the customer
// portal flag is off — the frontend hides the picker in that case,
// but a stale tab could still POST customer_account_ids; we ignore
// them rather than 403 the entire create.
if (Array.isArray(req.body.customer_account_ids)) {
try {
const customerAccountsService = require('../../services/customerAccountsService');
if (await customerAccountsService.isCustomerPortalEnabled()) {
await customerAccountsService.setAssignmentsForEvent(
eventId,
req.body.customer_account_ids,
req.admin.id
);
}
} catch (e) {
logger.error('Failed to set customer assignments on event create', {
eventId, error: e.message,
});
}
}
// Insert feedback settings if feedback is enabled
if (feedback_enabled) {
await db('event_feedback_settings').insert({
event_id: eventId,
feedback_enabled: formatBoolean(feedback_enabled),
allow_ratings: formatBoolean(feedbackDefaults.allow_ratings),
allow_likes: formatBoolean(feedbackDefaults.allow_likes),
allow_comments: formatBoolean(feedbackDefaults.allow_comments),
allow_favorites: formatBoolean(feedbackDefaults.allow_favorites),
allow_reactions: formatBoolean(feedbackDefaults.allow_reactions),
allow_color_labels: formatBoolean(feedbackDefaults.allow_color_labels),
keybind_mode: feedbackDefaults.keybind_mode,
require_name_email: formatBoolean(require_name_email),
moderate_comments: formatBoolean(moderate_comments),
show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
identity_mode: ['simple', 'guest', 'shared'].includes(identityModeInput)
? identityModeInput
: 'simple',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
// Log activity
await logActivity('event_created',
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Fire event.created webhook (#327). If the event is being published
// immediately (not a draft), event.published also fires below.
// Payload uses canonical event subject (#341) so receivers always see
// the same shape (id/slug/event_name + customer contact + share_*).
try {
const webhookService = require('../../services/webhookService');
await webhookService.fire('event.created', {
event: {
...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,
}),
is_draft: parseBooleanInput(is_draft, true),
},
});
} catch (e) { /* webhookService.fire never throws but be defensive */ }
// Queue creation email (only if there is a recipient and event is not a draft)
// Language detection is handled by email processor
const isDraft = parseBooleanInput(is_draft, true);
if (customerEmail && !isDraft) {
// Build email data with optional client access info
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
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 ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
};
// Include client access info in email when enabled (#172)
if (client_access_enabled && client_password) {
const createdEvent = await db('events').where('id', eventId).first();
// Same FRONTEND_URL-before-APP_URL order as before: APP_URL is
// passed as the override so it still outranks the general_site_url
// setting and the request origin. Chaining it after the resolver
// would make it dead code, because the resolver only returns falsy
// when NOTHING is configured (#1104).
const frontendUrl = await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL });
emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
emailData.client_password = client_password;
}
await db('email_queue').insert({
event_id: eventId,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
// scheduled_at will use default value
});
}
// WhatsApp gallery_ready notification (#640D). Fires when the event is
// created NOT as a draft, the `whatsapp` flag is on, a config exists, and
// the customer supplied a phone number. Non-fatal: a queue failure should
// never block gallery creation.
if (!isDraft && 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, // resolved by processor via general_default_language
});
}
} catch (waError) {
logger.warn('Failed to queue WhatsApp notification on create', { error: waError.message });
}
}
// Fire event.published when the event is created NOT as a draft. The
// separate /publish endpoint fires it for the draft → live transition;
// this covers the "create-and-publish in one shot" path.
if (!isDraft) {
try {
const webhookService = require('../../services/webhookService');
await webhookService.fire('event.published', {
event: 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,
}),
});
} catch (e) { /* non-fatal */ }
}
res.json({
id: eventId,
slug,
event_name,
event_type,
customer_name: customerName,
customer_email: customerEmail,
require_password: requirePassword,
photo_cap: photo_cap || null,
is_draft: isDraft,
share_link: shareUrl,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString()
const created = await require('../../services/eventCreationService').createEvent(req.body, {
actor: req.admin,
frontendUrl: await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL }),
});
res.json(created);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json(error.responseBody || { error: error.message, code: error.code });
if (isDuplicateSlugError(error)) {
logger.warn('Event creation lost the slug race', { error: error.message });
return res.status(409).json(DUPLICATE_SLUG_RESPONSE);
@@ -1425,6 +864,7 @@ module.exports = (router) => {
share_token: shareToken,
expires_at: newExpiresAt ? newExpiresAt.toISOString() : null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads: source.allow_user_uploads,
upload_category_id: source.upload_category_id,
@@ -1441,7 +881,6 @@ module.exports = (router) => {
use_canvas_rendering: source.use_canvas_rendering,
watermark_downloads: source.watermark_downloads,
watermark_text: source.watermark_text,
allow_presigned_download: source.allow_presigned_download,
require_password: source.require_password,
css_template_id: source.css_template_id || null,
hero_logo_visible: source.hero_logo_visible,
@@ -1598,7 +1037,6 @@ module.exports = (router) => {
body('disable_right_click').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim(),
body('allow_presigned_download').optional().isBoolean(),
body('source_mode').optional().isIn(['managed', 'reference']),
body('external_path').optional({ nullable: true }).isString().trim(),
body('external_watch').optional().isBoolean(),
@@ -2029,8 +1467,6 @@ module.exports = (router) => {
}
}
// Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158). This ensures the
// database columns stay in sync even if the frontend only sends the
@@ -2232,12 +1668,12 @@ module.exports = (router) => {
return res.status(404).json({ error: 'Event not found' });
}
const newStatus = !event.is_active;
const newStatus = !parseBooleanInput(event.is_active, false);
await db('events')
.where('id', id)
.update({
is_active: newStatus,
updated_at: new Date()
is_active: formatBoolean(newStatus),
updated_at: new Date().toISOString()
});
// Log activity
+2 -421
View File
@@ -1,392 +1,9 @@
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
// Shared helpers + module-level caches used across the adminEvents sub-routers.
const { db, logActivity } = require('../../database/db');
const fs = require('fs').promises;
const path = require('path');
const logger = require('../../utils/logger');
const { parseStringInput } = require('../../utils/parsers');
const settings = require('../../services/eventSettings');
// Shared validator for hero_image_anchor accepts legacy keywords or "X% Y%" focal point
const validateHeroImageAnchor = (value) => {
if (['top', 'center', 'bottom'].includes(value)) return true;
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
}
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
};
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
// Helper to get event field requirements from settings
const getEventFieldRequirements = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email',
'event_require_event_date',
'event_require_expiration'
])
.select('setting_key', 'setting_value');
const requirements = {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
value = value === 'true';
}
}
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
});
return requirements;
} catch (error) {
logger.error('Failed to get event field requirements', { error: error.message });
return {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
}
};
// Helper to read app_settings booleans by key, used to inherit per-setting
// defaults onto new events. Returns `undefined` for missing/non-boolean rows
// so callers can fall back to a legacy default.
/**
* Decode an app_settings value into the JS value it represents.
*
* setting_value is JSON text on SQLite and may already be decoded by the
* driver on a PG json column, so one parse does not normalise both. On top
* of that, the Image Security tab used to PUT back values it had read
* undecoded, wrapping another layer of quoting around each one on every
* save — the GET handler decodes now, but installs carry however many
* layers they accumulated before that.
*
* Every reader of app_settings has to agree about this, or the admin UI
* shows one thing while event creation does another.
*
* Terminates: each parse of a string is strictly shorter than its input.
*/
const decodeSettingValue = (raw) => {
let value = raw;
while (typeof value === 'string') {
let parsed;
try { parsed = JSON.parse(value); } catch { break; }
if (parsed === value) break;
value = parsed;
}
return value;
};
const readBooleanSetting = async (key) => {
try {
const setting = await db('app_settings').where('setting_key', key).first();
if (!setting) return undefined;
const value = decodeSettingValue(setting.setting_value);
return typeof value === 'boolean' ? value : undefined;
} catch (error) {
logger.error('Failed to read app setting', { key, error: error.message });
return undefined;
}
};
// Helper to read the global "enable_devtools_protection" admin setting so
// new events inherit it instead of always falling back to the DB column default
// (#317 — admin disabled it globally but new events still got it ON).
const getDownloadProtectionDefaults = async () => {
return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') };
};
/**
* The rest of Settings → Image security, as creation defaults (#1296).
*
* Four settings in that panel were written, reloaded and rendered as
* controls, and read by nothing:
*
* default_protection_level → events.protection_level
* default_image_quality → events.image_quality
* enable_canvas_rendering → events.use_canvas_rendering
*
* Each maps onto a column migration 038 already created, and each is
* labelled "… by default", so applying them at creation is what the panel
* has always claimed to do. `enable_devtools_protection` above is the only
* one of the five that was ever wired.
*
* Creation-time only, deliberately. Applying them to EXISTING events would
* silently change live galleries on upgrade — an install with
* enable_canvas_rendering already on would switch every grid to canvas
* rendering, which is memory-expensive at scale and is the profile under
* investigation in #1287. New events only; existing rows untouched.
*
* Any value that is missing or malformed comes back undefined so the caller
* falls through to the column default, exactly as before this existed.
*/
const PROTECTION_LEVELS = ['basic', 'standard', 'enhanced', 'maximum'];
// parseInt would rescue malformed settings instead of rejecting them:
// parseInt('72oops') is 72, parseInt(72.5) is 72, parseInt([72]) is 72.
// That matters because the settings PUT stores whatever JSON it is handed
// without validating the value (adminImageSecurity.js writes
// JSON.stringify(value) for any allow-listed key), so those shapes really
// can be sitting in app_settings. Accept only a genuine integer, or a
// string that is exactly one.
const toInteger = (value) => {
if (typeof value === 'number') return Number.isInteger(value) ? value : undefined;
if (typeof value === 'string' && /^[+-]?\d+$/.test(value.trim())) return Number(value.trim());
return undefined;
};
const getImageSecurityDefaults = async (trx = null) => {
const defaults = {};
try {
// Accepts a transaction the way getAppSetting does. It matters on
// sqlite3, whose pool holds a single connection: a caller already inside
// db.transaction() that read through the global `db` would block on the
// connection its own transaction holds until the acquire timeout, and
// the catch below would then quietly swallow it and drop the defaults.
const query = trx || db;
const rows = await query('app_settings')
.whereIn('setting_key', [
'default_protection_level',
'default_image_quality',
'enable_canvas_rendering',
])
.select('setting_key', 'setting_value');
// app_settings holds JSON text on SQLite, while a PG json column comes
// back already decoded — so one parse is not enough to normalise both.
// Worse, GET /api/admin/image-security/settings returns setting_value
// without decoding it and the settings tab PUTs the whole fetched object
// straight back through JSON.stringify, so opening the tab and saving
// re-encodes every value it read as text. After one such round trip
// `true` is stored as "\"true\"" and a single parse yields the string
// 'true', which the type checks below reject — the settings would go
// quietly dead again, which is the bug this whole change exists to fix.
// The GET handler now decodes, so this stops accumulating — but installs
// that already stacked N layers have to keep working, and N is however
// many times someone opened that tab. So unwrap until it stops being a
// JSON string rather than to a fixed depth; this terminates because each
// parse of a string is strictly shorter than its input.
const read = (key) => {
const row = rows.find((r) => r.setting_key === key);
if (!row) return undefined;
return decodeSettingValue(row.setting_value);
};
const level = read('default_protection_level');
if (typeof level === 'string' && PROTECTION_LEVELS.includes(level)) {
defaults.protection_level = level;
}
// The column is an integer percentage; anything outside 1..100 is a
// misconfiguration and falls through rather than being clamped into
// something the operator did not choose.
const quality = toInteger(read('default_image_quality'));
if (quality !== undefined && quality >= 1 && quality <= 100) {
defaults.image_quality = quality;
}
const canvas = read('enable_canvas_rendering');
if (typeof canvas === 'boolean') {
defaults.use_canvas_rendering = canvas;
}
} catch (error) {
// A settings read must never block event creation; the column defaults
// are a correct fallback.
logger.error('Failed to read image-security defaults', { error: error.message });
}
return defaults;
};
/**
* Build the image-security columns for a NEW event: an explicit request
* value wins, then the global default, then the column default (the key is
* omitted entirely so the database supplies it).
*
* Shared by the admin create route and POST /api/v1/events so the configured
* security level cannot depend on which entry point created the gallery —
* the same split that made #592 (devtools) a separate bug from #317.
*
* `body` values are already validated by the route's express-validator
* chain; `defaults` come from getImageSecurityDefaults(), which validates
* them itself.
*/
const resolveImageSecurityColumns = (body = {}, defaults = {}) => {
const { formatBoolean } = require('../../utils/dbCompat');
const columns = {};
// express-validator runs isInt/isIn/isBoolean element-wise on arrays, so a
// single-element array like `image_quality: [72]` passes the route's chain
// and arrives here still an array. The routes reject those with
// .not().isArray(); this guard means any future caller cannot write one
// into a scalar column (a PG insert error, or `[false]` coerced to true).
const scalar = (v) => (v !== null && typeof v === 'object' ? undefined : v);
const pick = (key) => {
const fromBody = scalar(body[key]);
return fromBody !== undefined ? fromBody : defaults[key];
};
const level = pick('protection_level');
if (level !== undefined) columns.protection_level = level;
const quality = pick('image_quality');
if (quality !== undefined) columns.image_quality = quality;
const canvas = pick('use_canvas_rendering');
if (canvas !== undefined) columns.use_canvas_rendering = formatBoolean(canvas);
return columns;
};
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance).
//
// Note: `branding_logo_position` (header bar — left/center/right) is a
// different concept from `hero_logo_position` (hero block — top/center/
// bottom) and must NOT be mapped here. A previous version copied the
// branding value over, which wrote 'left'/'right' into per-event
// hero_logo_position columns and broke any subsequent PUT validation
// (#357). Migration 084 heals existing rows.
const getBrandingDefaults = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'branding_logo_display_hero',
'branding_logo_size'
])
.select('setting_key', 'setting_value');
const defaults = {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
}
if (s.setting_key === 'branding_logo_display_hero') {
defaults.hero_logo_visible = value !== false;
}
if (s.setting_key === 'branding_logo_size' && value) {
defaults.hero_logo_size = value;
}
});
return defaults;
} catch (error) {
logger.error('Failed to get branding defaults', { error: error.message });
return {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
}
};
// 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. Cached for
// the request via a module-level read; drift is acceptable since this
// only governs whether to persist the field, not security boundaries.
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 (error) {
logger.debug('Failed to read event_phone_field_enabled', { error: error.message });
return false;
}
};
const RECOVERABLE_PASSWORD_COLUMNS = ['password_recoverable', 'client_password_recoverable'];
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
customer_phone,
// Bound only to exclude the secrets from `...rest` — never read.
password_hash: _ph, client_password_hash: _cph,
...rest
} = event;
// #1271 — the encrypted copies never leave the server except via
// /:id/password. Removed by name (not destructured) so a secret scanner
// does not read the binding as a hard-coded password.
for (const column of RECOVERABLE_PASSWORD_COLUMNS) delete rest[column];
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null,
customer_phone: customer_phone ?? 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) {
logger.debug('Failed to detect customer_email column', { error: error.message });
return false;
}
};
// Cascade-delete a single event: photos, audit/access logs, queued emails,
// the event row itself (in one transaction), then the on-disk folder /
// archive zip / hero logo (best-effort — file failures don't unwind the DB
// changes since the source of truth is the database). Used by both the
// per-event DELETE /:id route and the bulk-delete route to avoid drift.
//
// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the
// bulk-delete loop can report it as a per-id failure without aborting the
// whole batch. Any other error propagates and is the caller's problem.
async function deleteEventCascade(eventId, adminContext) {
const event = await db('events').where('id', eventId).first();
if (!event) {
@@ -678,40 +295,4 @@ async function deleteEventCascade(eventId, adminContext) {
return { id: event.id, name: event.event_name };
}
// ---------------------------------------------------------------------------
// Live Slideshow ("Diashow") — a token-only fullscreen kiosk link for live
// events that auto-picks-up new uploads (migration 138). Mirrors the
// client-access second-token pattern: the link is minted on demand, rotatable
// and disable-able, independent of the gallery password / share link.
// ---------------------------------------------------------------------------
// Allowed slide transition styles (kept in sync with the SlideshowPage).
// dipwhite/dipblack = fade through highlights / lowlights between images.
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 = {
RECOVERABLE_PASSWORD_COLUMNS,
validateHeroImageAnchor,
getStoragePath,
getEventFieldRequirements,
readBooleanSetting,
decodeSettingValue,
getDownloadProtectionDefaults,
getImageSecurityDefaults,
resolveImageSecurityColumns,
getBrandingDefaults,
getCustomerNameFromPayload,
getCustomerEmailFromPayload,
getCustomerPhoneFromPayload,
isPhoneFieldEnabled,
mapEventForApi,
hasCustomerContactColumns,
deleteEventCascade,
SLIDESHOW_ORDERS,
SLIDESHOW_TRANSITIONS,
SLIDESHOW_COLORFILTERS,
};
module.exports = { ...settings, deleteEventCascade };
+3 -1
View File
@@ -31,7 +31,9 @@ const eventLogoStorage = multer.diskStorage({
const eventLogoUpload = multer({
storage: eventLogoStorage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5MB
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
+4 -1
View File
@@ -41,7 +41,10 @@ function diskUpload(subdir) {
},
filename: (_req, file, cb) => cb(null, `${subdir.split('/').pop()}-${Date.now()}${path.extname(file.originalname) || ''}`),
}),
limits: { fileSize: 15 * 1024 * 1024 },
// CVE-2026-82333: both callers (`inboundUpload` → 'file', `proofUpload`
// → 'proof') take a single unnamed field — no legitimate array-indexed
// field names, so reject any bracket-index field name.
limits: { fileSize: 15 * 1024 * 1024, fieldArrayIndexLimit: 0 },
fileFilter: (_req, file, cb) => (ALLOWED_MIME.includes(file.mimetype) ? cb(null, true) : cb(new Error('Only PDF, JPEG or PNG files are allowed'))),
});
}
+3 -1
View File
@@ -72,7 +72,9 @@ const importedInvoiceStorage = multer.diskStorage({
});
const importedInvoiceUpload = multer({
storage: importedInvoiceStorage,
limits: { fileSize: 10 * 1024 * 1024 },
// CVE-2026-82333: single unnamed `pdf` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 },
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'application/pdf') cb(null, true);
else cb(new Error('Only PDF files are allowed for imported invoices'));
+6 -1
View File
@@ -112,7 +112,12 @@ const createUpload = (maxFileSizeBytes) => multer({
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
parts: 10000,
headerPairs: 2000
headerPairs: 2000,
// CVE-2026-82333: files arrive as repeated `photos` parts via
// multer's own .array('photos', N) — not bracket-indexed field names
// like `photos[0]` — so no legitimate field name uses array-index
// syntax at all. Reject any that do.
fieldArrayIndexLimit: 0
},
fileFilter: (req, file, cb) => {
// req.allowedMimeTypes is populated by the middleware that runs before multer
+7 -2
View File
@@ -153,7 +153,10 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
// CVE-2026-82333: single unnamed field (`logo` or `watermarkLogo`) per
// route — no legitimate array-indexed field names, so reject any
// bracket-index field name.
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5MB
fileFilter: (req, file, cb) => {
// Note: SVG files are excluded from magic number validation for logos
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
@@ -181,7 +184,9 @@ const faviconStorage = multer.diskStorage({
const faviconUpload = multer({
storage: faviconStorage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB — roomy enough for a 512×512+ square PNG
// CVE-2026-82333: single unnamed `favicon` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 2 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 2MB — roomy enough for a 512×512+ square PNG
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'];
const name = file.originalname.toLowerCase();
+5 -2
View File
@@ -204,7 +204,9 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
// cannot clobber each other, and writes thumbnail_path back itself.
// Nulling thumbnail_path is what stops it short-circuiting on
// isThumbnailValid — the same trick /regenerate-previews uses.
const newThumbnailPath = await ensureThumbnail({ ...photo, thumbnail_path: null });
// `force` is what stops it joining a lazy generation that is still
// running under the OLD settings and adopting that result (#1020).
const newThumbnailPath = await ensureThumbnail({ ...photo, thumbnail_path: null }, { force: true });
if (newThumbnailPath) {
// Drop the superseded canonical rendition when the key MOVED.
@@ -293,7 +295,8 @@ router.post('/regenerate-previews', adminAuth, requirePermission('photos.edit'),
// is precisely the case this endpoint exists for (a replaced
// reference source, or a corrupted rendition).
await require('../services/imageProcessor').deletePreviewTiers(photo);
const newPreviewPath = await ensurePreviewImage({ ...photo, preview_path: null });
// `force`: never adopt a lazy generation already in flight (#1020).
const newPreviewPath = await ensurePreviewImage({ ...photo, preview_path: null }, { force: true });
if (newPreviewPath) {
successCount++;
} else {
+4 -1
View File
@@ -50,7 +50,10 @@ const tempStorage = multer.diskStorage({
function buildAdminUploader(maxSizeBytes, allowed) {
return multer({
storage: tempStorage,
limits: { fileSize: maxSizeBytes, files: ADMIN_MAX_FILES },
// CVE-2026-82333: files arrive as repeated `files` parts via .array(),
// not bracket-indexed field names like `files[0]` — no legitimate
// field name uses array-index syntax at all. Reject any that do.
limits: { fileSize: maxSizeBytes, files: ADMIN_MAX_FILES, fieldArrayIndexLimit: 0 },
fileFilter: (req, file, cb) => {
if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true);
return cb(new Error('This file type is not allowed'));
+7
View File
@@ -73,6 +73,13 @@ router.post(
'/dismiss',
wrap(async (_req, res) => res.json(await service.dismiss()))
);
// Acknowledges the one-time opt-in prompt (setup wizard or the post-update
// modal) regardless of whether the admin enabled or declined — either way it
// must not ask this installation again.
router.post(
'/prompt-seen',
wrap(async (_req, res) => res.json(await service.markPromptShown()))
);
router.post(
'/enable',
wrap(async (req, res) =>
+40 -102
View File
@@ -1,6 +1,8 @@
const { isGalleryAvailable } = require('../utils/galleryLifecycle');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
@@ -354,7 +356,9 @@ router.post('/logout', async (req, res) => {
if (token) {
// Revoke the token so it can't be reused, then end the session
await revokeToken(token, 'user_logout');
if (!await revokeToken(token, 'user_logout')) {
throw new Error('Token revocation failed');
}
endSession(token);
try {
@@ -398,6 +402,8 @@ router.post('/logout', async (req, res) => {
res.json({ message: 'Logged out successfully', ...(ssoLogoutUrl ? { ssoLogoutUrl } : {}) });
} catch (error) {
clearAdminAuthCookie(res);
clearGalleryAuthCookies(res);
errorResponse(res, error, 500, 'Logout failed');
}
});
@@ -420,7 +426,7 @@ router.post('/gallery/verify', [
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
if (!isGalleryAvailable(event)) {
// Perform a dummy bcrypt compare to prevent timing-based slug enumeration
await bcrypt.compare(password || '', DUMMY_BCRYPT_HASH);
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
@@ -496,6 +502,9 @@ router.post('/gallery/verify', [
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
@@ -545,7 +554,7 @@ router.post('/gallery/:slug/client-login', [
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event || !event.client_access_enabled || !event.client_password_hash) {
if (!isGalleryAvailable(event) || !event.client_access_enabled || !event.client_password_hash) {
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid credentials' });
}
@@ -570,6 +579,9 @@ router.post('/gallery/:slug/client-login', [
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
accessLevel: 'client',
ip: ipAddress,
loginTime: Date.now()
@@ -631,14 +643,14 @@ router.post('/gallery/share-login', [
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
if (!isGalleryAvailable(event)) {
const resolved = await resolveShareIdentifier(slug);
if (resolved?.event) {
event = resolved.event;
}
}
if (!event) {
if (!isGalleryAvailable(event)) {
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
return res.status(404).json({ error: 'Gallery not found' });
}
@@ -666,6 +678,9 @@ router.post('/gallery/share-login', [
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
@@ -703,11 +718,14 @@ router.post('/gallery/logout', async (req, res) => {
const { slug } = req.body || {};
const token = getGalleryTokenFromRequest(req, slug);
if (token) {
await revokeToken(token, 'gallery_logout');
if (!await revokeToken(token, 'gallery_logout')) {
throw new Error('Token revocation failed');
}
}
clearGalleryAuthCookies(res, slug);
res.json({ message: 'Logged out successfully' });
} catch (error) {
clearGalleryAuthCookies(res, req.body?.slug);
errorResponse(res, error, 500, 'Logout failed');
}
});
@@ -743,106 +761,26 @@ router.get('/session', async (req, res) => {
issuer: 'picpeak-auth'
});
// Check if token has been revoked (e.g. after logout)
const { isTokenRevoked } = require('../utils/tokenRevocation');
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ valid: false, error: 'Session has been invalidated' });
}
// The redirect loop reported on the v3.32.4-beta.0 release came
// from /auth/session reporting valid: true while the protected
// adminAuth / galleryAuth middleware rejected the same token for
// reasons /auth/session never checked: the admin user was
// deactivated, the admin's password had been changed since iat,
// 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).
const sessions = require('../services/sessionAccessService');
let adminUser = null;
if (decoded.type === 'admin') {
let admin = null;
try {
admin = await db('admin_users')
.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
// through and trust the token. Real deployments always have it.
admin = null;
// intentional swallow; if the table is missing we do not want
// to fail-closed during e.g. early bootstrap.
}
if (admin === null) {
// Lookup didn't run because the table is missing; skip the
// existence/password checks and treat the token as valid.
} else if (!admin) {
return res.json({ valid: false, error: 'Admin account no longer active' });
} else if (admin.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(admin.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
return res.json({ valid: false, error: 'Token invalid due to password change' });
}
}
// Mirror the session-timeout check that sessionTimeoutMiddleware
// enforces on every /api/admin endpoint. Without this, /auth/session
// returns valid:true for an idle/old-iat token that protected
// endpoints reject with 401 SESSION_TIMEOUT — the same redirect-loop
// shape as the issuer-claim and password-change asymmetries (issue
// #350 recurrence on v3.39.1-beta.0).
try {
const { isSessionExpired } = require('../middleware/sessionTimeout');
if (await isSessionExpired(token, decoded)) {
return res.json({ valid: false, error: 'Session expired' });
}
} catch (timeoutErr) {
// 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
};
const admin = await sessions.admin(decoded, { includeProfile: true });
const { isSessionExpired } = require('../middleware/sessionTimeout');
if (await isSessionExpired(token, decoded)) {
return res.json({ valid: false, error: 'Session expired' });
}
adminUser = {
id: admin.id, username: admin.username, email: admin.email,
mustChangePassword: !!admin.must_change_password,
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')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
})
.first();
if (!event) {
return res.json({ valid: false, error: 'Gallery no longer available' });
}
if (event.expires_at && new Date(event.expires_at) < new Date()) {
return res.json({ valid: false, error: 'Gallery has expired' });
}
} catch (galleryLookupErr) {
// events table missing in this context — same fallback as
// admin path; trust the token rather than fail-closed.
}
const access = require('../services/galleryAccessService');
const event = await db('events').where({ id: decoded.eventId }).first();
if (!event) return res.json({ valid: false, error: 'Gallery no longer available' });
await access.authorize(event, access.grant(event, 'gallery', decoded));
} else {
return res.status(403).json({ valid: false, error: 'Invalid token type' });
}
// Calculate remaining time
+9 -3
View File
@@ -1,3 +1,4 @@
const { isGalleryAvailable, isGalleryExpired } = require('../utils/galleryLifecycle');
/**
* Customer dashboard routes
*
@@ -14,6 +15,7 @@
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { body, param, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { getBcryptRounds, MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation');
@@ -165,10 +167,12 @@ router.get('/events/:slug/access-token', [
if (event.is_archived) {
return res.status(410).json({ error: 'This gallery has been archived' });
}
if (event.expires_at && new Date(event.expires_at) < new Date()) {
if (isGalleryExpired(event)) {
return res.status(410).json({ error: 'This gallery has expired' });
}
if (!isGalleryAvailable(event)) return res.status(404).json({ error: 'Event not found' });
const hasAccess = await customerAccountsService.customerHasAccessToEvent(
req.customer.id,
event.id
@@ -189,10 +193,12 @@ router.get('/events/:slug/access-token', [
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
ip: ipAddress,
loginTime: Date.now(),
// Optional bookkeeping claim — surfaces the originating customer in
// logs when the token is later used. Doesn't affect authorization.
// Rechecked on each gallery/media request, including account status.
via: 'customer',
customerId: req.customer.id,
}, process.env.JWT_SECRET, {
+3 -1
View File
@@ -181,7 +181,9 @@ router.post('/logout', async (req, res) => {
try {
const token = getCustomerTokenFromRequest(req);
if (token) {
await revokeToken(token, 'user_logout');
if (!await revokeToken(token, 'user_logout')) {
throw new Error('Token revocation failed');
}
}
clearCustomerAuthCookie(res);
res.json({ message: 'Logged out successfully' });
File diff suppressed because it is too large Load Diff
+945
View File
@@ -0,0 +1,945 @@
const express = require('express');
const { db, logActivity } = require('../../database/db');
const { parseBooleanInput } = require('../../utils/parsers');
const archiver = require('archiver');
const path = require('path');
const { resolvePhotoContentType } = require('../../utils/photoContentType');
const router = express.Router();
const watermarkService = require('../../services/watermarkService');
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
const { noStoreCache } = require('../../middleware/noStoreCache');
const logger = require('../../utils/logger');
const { pipeStreamToResponse } = require('../../utils/streamResponse');
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../../services/photoResolver');
const { errorResponse } = require('../../utils/routeHelpers');
const { blockHiddenGallery } = require('../../utils/revealMode');
const downloadZipService = require('../../services/downloadZipService');
const { renderPhotoForDownload, resolveWatermarkSettings } = require('../../services/downloadRendition');
const downloadJobService = require('../../services/downloadJobService');
const {
resolveEventDownloadPolicy,
pickRequestedResolution,
parseResolution,
} = require('../../utils/downloadResolutions');
const { applyPhotoVisibilityFilter, canSeeHiddenPhotos } = require('../../utils/photoVisibility');
const {
getUseOriginalFilenames,
pickRawDownloadName,
getZipEntryNames,
} = require('../../services/downloadFilenameService');
const { buildContentDisposition } = require('../../utils/filenameSanitizer');
const { getStorage } = require('../../services/storage');
const fs = require('fs');
function parseByteRange(header, size) {
if (!header || typeof header !== 'string' || !size) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return null;
const [, rawStart, rawEnd] = match;
if (rawStart === '' && rawEnd === '') return null;
let start;
let end;
if (rawStart === '') {
// Suffix form: the last N bytes.
const suffix = parseInt(rawEnd, 10);
if (!suffix) return null;
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = parseInt(rawStart, 10);
end = rawEnd === '' ? size - 1 : parseInt(rawEnd, 10);
}
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
if (start > end || start >= size) return null;
return { start, end: Math.min(end, size - 1) };
}
function galleryActor(req) {
// Portal tokens run as accessLevel 'guest' but carry via:'customer'
// (req.viaCustomer); PIN-client logins carry accessLevel 'client'.
// Both are customers, not guests (codex review of #849, final round).
const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client'));
return { type: isCustomer ? 'customer' : 'guest' };
}
const SINGLE_DOWNLOAD_DEBOUNCE_MS = 60 * 60 * 1000;
const singleDownloadNotifiedAt = new Map();
function notifySinglePhotoDownload(event, req) {
const now = Date.now();
const last = singleDownloadNotifiedAt.get(event.id) || 0;
if (now - last < SINGLE_DOWNLOAD_DEBOUNCE_MS) return;
singleDownloadNotifiedAt.set(event.id, now);
logActivity('gallery_downloaded', { scope: 'single' }, event.id, galleryActor(req));
}
router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
const { photoId } = req.params;
// Check if downloads are allowed for this event
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Per-category download permission (#640). Photos without a category are
// always downloadable when the event allows downloads — only categorised
// photos can opt out per-category.
if (photo.category_id) {
const cat = await db('photo_categories')
.where('id', photo.category_id)
.first('allow_downloads');
if (cat && !parseBooleanInput(cat.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this category' });
}
}
// Download resolution (#858). Resolved BEFORE the counters below: a
// rejected resolution must not inflate download stats, which a guest
// could otherwise do by replaying ?resolution=bogus.
const isVideo = photo.media_type === 'video'
|| (photo.mime_type && photo.mime_type.startsWith('video/'));
const policy = await resolveEventDownloadPolicy(req.event);
const requested = pickRequestedResolution(policy, req.query.resolution);
if (requested === null) {
return res.status(400).json({ error: 'Resolution not available for this gallery' });
}
const box = isVideo ? null : parseResolution(requested);
// A HEAD is a metadata probe, not a download. Answering it below the
// counters recorded every probe as a real download, and answering it below
// renderPhotoForDownload fetched and watermarked an image whose body Node
// then discards. Both happen before this point in a GET, so HEAD leaves
// here — with no side effects and no bytes read.
if (req.method === 'HEAD') {
const headUseOriginal = await getUseOriginalFilenames();
const headHeaders = {
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': buildContentDisposition(pickRawDownloadName(photo, headUseOriginal)),
'Accept-Ranges': 'bytes',
};
// Content-Length only when the bytes ship untransformed AND the size can
// be read without fetching them. A watermark or resize changes the
// length, and the only way to learn the new one is to do the work this
// branch exists to avoid — HEAD is allowed to omit it.
const headWatermark = await resolveWatermarkSettings(req.event);
if (!box && !headWatermark) {
try {
const headKey = resolvePhotoStorageKey(req.event, photo);
const headStorage = getStorage();
if (headKey && headStorage.kind() !== 'local') {
const headStat = await headStorage.stat(headKey);
if (!headStat) return res.status(404).json({ error: 'Photo file not found' });
headHeaders['Content-Length'] = headStat.size;
if (headStat.mtime) headHeaders['Last-Modified'] = new Date(headStat.mtime).toUTCString();
}
} catch (headErr) {
// No length is a valid HEAD; not worth failing the probe over.
logger.debug('HEAD probe could not stat the object', { photoId, error: headErr.message });
}
}
res.set(headHeaders);
return res.end();
}
// Admin preview (#868) downloads are excluded from the download count +
// guest analytics — kept out of client-facing stats.
if (!req.isAdminPreview) {
// Update download count
await db('photos').where('id', photoId).increment('download_count', 1);
// Log download
await db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download',
photo_id: photoId
});
}
// Surface in the admin notification bell (#746) — debounced, and only
// once the response actually finished: notifying up-front would log a
// download that then 404s/fails and the debounce would suppress the
// next real one for an hour (codex review of #849).
res.on('finish', () => {
if (res.statusCode < 400 && !req.isAdminPreview) notifySinglePhotoDownload(req.event, req);
});
// #493: if the admin enabled "use original filenames", surface the
// pre-rename camera filename in Content-Disposition. Storage path is
// unchanged — only the user-visible download name is swapped.
const useOriginal = await getUseOriginalFilenames();
const downloadName = pickRawDownloadName(photo, useOriginal);
const contentDisposition = buildContentDisposition(downloadName);
// The gallery's standard applies to EVERY ordinary download, single photos
// included — otherwise a lowered standard is trivially bypassed by
// downloading photos one at a time. `box` was resolved above, before the
// counters. Videos have no resize path and always ship as-is.
//
// renderPhotoForDownload (#858) owns the resize-then-watermark ordering
// and the storage fetch, and is what the zip builders below already use.
// It returns null when the photo needs no transformation at all, which is
// the default gallery's common case and lets us ship the stored bytes
// without buffering a full-size original into memory.
const effectiveSettings = await resolveWatermarkSettings(req.event);
let rendered;
try {
rendered = await renderPhotoForDownload(req.event, photo, box, effectiveSettings);
} catch (renderError) {
// Classify, the same way the pass-through branch below does. This can
// reject because the source object is gone, but equally because
// getToFile timed out, the tmp filesystem filled up, or sharp failed —
// and reporting an operational failure as 404 tells the guest their
// photo does not exist and tells us nothing.
const gone = renderError.code === 'ENOENT'
|| renderError.name === 'NoSuchKey'
|| renderError.name === 'NotFound'
|| renderError.$metadata?.httpStatusCode === 404;
logger.error('Failed to render photo for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: renderError.message,
});
return gone
? res.status(404).json({ error: 'Photo file not found' })
: res.status(500).json({ error: 'Failed to download photo' });
}
if (rendered) {
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': contentDisposition,
'Content-Length': rendered.length
});
return res.send(rendered);
}
// Untransformed: ship the stored bytes.
//
// Managed photos live behind the storage abstraction and on an S3/R2
// deployment are not on local disk at all — resolving a filesystem path
// unconditionally here is what made every single-photo download 404 with
// ENOENT in S3 mode (#1048), while download-all and secure-images worked
// because they already went through getStorage().
//
// resolvePhotoStorageKey returns null for external/reference photos: those
// live on a local mount and keep the sendFile path.
let storageKey = null;
try {
storageKey = resolvePhotoStorageKey(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo storage key for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
const storage = getStorage();
if (storageKey && storage.kind() !== 'local') {
// Deliberately NOT the local path: res.sendFile emits Content-Length,
// Accept-Ranges, ETag and Last-Modified and answers Range requests with
// a 206, and a bare stream.pipe(res) has none of that. On local disk
// sendFile stays the better implementation, so it stays the branch.
//
// On S3 we reproduce the parts that matter for a download: the length
// (browsers need it for the progress indicator, which matters most on
// exactly the large files this route serves) and Range, so an
// interrupted download resumes instead of appending a second full body
// onto the partial file. Conditional requests are not reproduced —
// there is no ETag here, so a client revalidating gets the whole body,
// same as it does today.
const stat = await storage.stat(storageKey);
if (!stat) {
logger.error('Photo not found in storage backend for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
storageKey,
});
return res.status(404).json({ error: 'Photo file not found' });
}
const lastModified = stat.mtime ? new Date(stat.mtime).toUTCString() : null;
const headers = {
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': contentDisposition,
'Accept-Ranges': 'bytes',
};
if (lastModified) headers['Last-Modified'] = lastModified;
// If-Range: a client resuming an interrupted download sends back the
// validator it was given last time. If the object has been replaced
// since — the watcher re-importing a swapped file, an admin re-upload —
// answering 206 from the NEW bytes lets the client splice two different
// versions into one corrupt file. A validator that doesn't match means
// a full 200, which is the whole point of the header.
const ifRange = req.headers['if-range'];
const staleValidator = !!ifRange && (!lastModified || ifRange.trim() !== lastModified);
const range = staleValidator ? null : parseByteRange(req.headers.range, stat.size);
// Open the stream BEFORE any header is staged or sent. stat() succeeding
// does not mean get() will: a concurrent delete or replace, or a
// transient backend error, lands here. Once writeHead(206) has gone out
// the outer catch can do nothing but throw ERR_HTTP_HEADERS_SENT, and in
// the non-range case it would send its 500 JSON underneath the staged
// image/jpeg attachment headers — a .jpg file full of JSON.
let stream;
try {
stream = range
? await storage.getRange(storageKey, range.start, range.end)
: await storage.get(storageKey);
} catch (fetchError) {
const gone = fetchError.code === 'ENOENT'
|| fetchError.name === 'NoSuchKey'
|| fetchError.name === 'NotFound'
|| fetchError.$metadata?.httpStatusCode === 404;
logger.error('Failed to open photo stream for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
storageKey,
error: fetchError.message,
});
return gone
? res.status(404).json({ error: 'Photo file not found' })
: res.status(500).json({ error: 'Failed to download photo' });
}
if (range) {
// status()+set() rather than writeHead(): writeHead commits the
// response immediately, so a stream that resolves and THEN errors
// before its first chunk would leave pipeStreamToResponse able only to
// destroy the connection. Staged headers are flushed by the first body
// write, which means an error at byte zero can still clear them and
// return a clean, retryable status instead of a transport reset.
res.status(206).set({
...headers,
'Content-Range': `bytes ${range.start}-${range.end}/${stat.size}`,
'Content-Length': (range.end - range.start) + 1,
});
} else {
res.set({ ...headers, 'Content-Length': stat.size });
}
pipeStreamToResponse(stream, res, {
context: range ? `download range for photo ${photo.id}` : `download for photo ${photo.id}`,
});
return;
}
let filePath;
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
// res.download() builds Content-Disposition itself but doesn't emit the
// RFC 5987 filename* parameter, so unicode camera filenames would lose
// their bytes on download. Set the header explicitly and stream the
// file with res.sendFile-equivalent semantics.
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': contentDisposition,
});
res.sendFile(filePath, (downloadError) => {
if (downloadError) {
logger.error('Error streaming gallery download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: downloadError.message,
});
}
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to download photo');
}
});
// Download all photos as ZIP
// Zip downloads count toward each contained photo's download_count (#895)
// — previously only single-photo downloads did, so galleries whose guests
// grab the zip showed 0 per-photo downloads forever. Used by the
// pre-generated-zip branches only: it mirrors downloadZipService._build,
// which zips EVERY event photo with no per-category allow_downloads
// filter — the counter has to reflect what actually shipped. (That the
// prebuilt zip ignores per-category download opt-outs is a separate,
// pre-existing issue.) Known approximation: _build skips entries whose
// WATERMARK step fails and still publishes the zip; counting those
// would need a persisted archive manifest, which isn't worth it for
// that tail case. Fire-and-forget at the call sites: counters must
// never fail a download.
async function bumpEventDownloadCounts(eventId) {
await db('photos').where('event_id', eventId).increment('download_count', 1);
}
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
// Check if downloads are allowed for this event
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
// Try to serve pre-generated zip (instant download with Content-Length).
// Guests may use the prebuilt cache ONLY when the event has no hidden
// photos: a cache built before a photo was hidden — or before this
// visibility-aware builder shipped — could otherwise still leak it, and
// getZipInfo only checks the DB pointer + file stat, not freshness. When
// hidden photos exist, guests fall through to the visibility-filtered
// stream below. PIN-clients always stream a full archive.
const isClient = canSeeHiddenPhotos(req.accessLevel);
const eventHasHidden = await db('photos')
.where({ event_id: req.event.id, visibility: 'hidden' })
.first()
.then(Boolean);
const zipInfo = (isClient || eventHasHidden)
? null
: await downloadZipService.getZipInfo(req.event.id);
if (zipInfo) {
const storage = getStorage();
// Stream via the authenticated route so logout, restore and account
// changes are checked on every download, including S3-backed archives.
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Length', zipInfo.size);
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
const stream = await storage.get(zipInfo.key);
pipeStreamToResponse(stream, res, { context: `prepared zip for event ${req.event.id}`, missingStatus: 410 });
// Log bulk download (admin preview #868 excluded — stats stay client-only).
if (!req.isAdminPreview) {
db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download_all'
}).catch(() => {});
bumpEventDownloadCounts(req.event.id).catch(() => {});
// Surface in the admin notification bell (#746) — only once the
// stream actually finished; logging at pipe-time would report
// downloads that then broke mid-transfer (codex review of #849).
res.on('finish', () => {
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
});
}
return;
}
// Fallback: on-the-fly streaming (existing behavior). Only pre-build the
// guest cache when it will actually be served next time — a guest
// download of an event with no hidden photos. Client bypasses and
// hidden-photo events always stream, so rebuilding the guest archive on
// those requests is wasted I/O (codex review).
if (!isClient && !eventHasHidden) {
downloadZipService.generateZip(req.event.id).catch(err =>
logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message })
);
}
// Fetch photos — exclude photos in categories that disabled downloads (#640).
// Uncategorised photos are always included; categories without the column
// (pre-migration-135) fall through the LEFT JOIN's null and are included.
const photos = await applyPhotoVisibilityFilter(
db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.where(function () {
this.whereNull('photos.category_id')
.orWhere('photo_categories.allow_downloads', true)
.orWhereNull('photo_categories.allow_downloads');
}),
req.accessLevel
)
.select('photos.*')
.orderBy('photos.type', 'asc')
.orderBy('photos.uploaded_at', 'desc');
if (photos.length === 0) {
return res.status(404).json({ error: 'No photos found' });
}
// Count unique types
const uniqueTypes = new Set(photos.map(p => p.type)).size;
const hasMultipleTypes = uniqueTypes > 1;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
const archive = archiver('zip', { zlib: { level: 5 } });
archive.on('error', (err) => {
throw err;
});
archive.pipe(res);
// Get watermark settings - apply if global setting OR event-level setting is enabled
const watermarkSettings = await watermarkService.getWatermarkSettings();
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
const effectiveSettings = shouldApplyWatermark ? {
...watermarkSettings,
enabled: true,
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
} : null;
// The gallery's standard resolution applies to the streamed archive too,
// not only the cached one (#858).
const { standardBox: bulkBox } = await resolveEventDownloadPolicy(req.event);
// Add photos to archive — managed photos via storage backend, external via local path.
const { resolvePhotoStorageKey } = require('../../services/photoResolver');
const storage = getStorage();
// #493: resolve a unique display filename per photo up-front so collisions
// get a deterministic `_1` suffix before the entries hit the archive.
const useOriginalBulk = await getUseOriginalFilenames();
const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk);
// Only photos whose append succeeded count as downloaded (#895) — the
// catch below deliberately skips missing/corrupt sources, and those
// never make it into the archive.
const appendedIds = [];
for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
const storageKey = resolvePhotoStorageKey(req.event, photo);
const entryName = bulkEntryNames[i];
let archiveName;
if (hasMultipleTypes) {
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
archiveName = path.join(folderName, entryName);
} else {
archiveName = entryName;
}
try {
// Verify the source exists BEFORE appending — but only for local
// sources: fs.createReadStream is lazy, so its error fires outside
// this try/catch and the archive 'error' handler then kills the
// whole response instead of skipping one photo (#895 review). S3's
// get() awaits GetObject and rejects right here on a missing key,
// so a preflight HEAD per entry would just be a redundant serial
// round trip (500-photo zip = 500 extra HEADs).
if (storageKey && storage.kind() === 'local') {
const srcStat = await storage.stat(storageKey);
if (!srcStat) {
throw new Error(`Photo missing in storage: ${storageKey}`);
}
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
throw new Error('Photo file missing on disk');
}
// Resize to the gallery's standard resolution (#858) and/or watermark.
// This branch runs whenever the cached zip isn't usable — the first
// download after an invalidation, PIN clients, and galleries with
// hidden photos all land here, so skipping the cap would leak
// full-resolution files for exactly those cases.
const rendered = await renderPhotoForDownload(req.event, photo, bulkBox, effectiveSettings);
if (rendered) {
archive.append(rendered, { name: archiveName });
} else if (storageKey) {
const stream = await storage.get(storageKey);
archive.append(stream, { name: archiveName });
} else {
archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName });
}
appendedIds.push(photo.id);
} catch (err) {
logger.warn('Skipping photo in bulk download due to error', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: err.message,
});
}
}
// Notification only after the response actually finished — finalize()
// ends Archiver's input, not the HTTP transfer (codex review of #849,
// confirmation round). Registered before finalize so it can't be missed.
// Admin preview (#868) streams the archive but is excluded from stats.
if (!req.isAdminPreview) {
res.on('finish', () => {
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
});
}
await archive.finalize();
if (!req.isAdminPreview) {
// Log bulk download
await db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download_all'
});
// Exactly the photos that made it into this archive (#895) — skipped
// (missing/corrupt) sources don't count.
if (appendedIds.length > 0) {
db('photos').whereIn('id', appendedIds)
.increment('download_count', 1).catch(() => {});
}
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to create download archive');
}
});
// Download selected photos as ZIP
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
// Check if downloads are allowed for this event
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
const ids = Array.isArray(req.body?.photo_ids) ? req.body.photo_ids : [];
if (!ids.length) {
return res.status(400).json({ error: 'photo_ids is required (non-empty array)' });
}
// Clean IDs
const photoIds = ids
.map((v) => parseInt(v, 10))
.filter((v) => Number.isInteger(v))
.slice(0, 500);
if (photoIds.length === 0) {
return res.status(400).json({ error: 'No valid photo IDs provided' });
}
// Fetch photos — exclude photos in categories that disabled downloads (#640).
// Same LEFT JOIN pattern as the download-all endpoint.
const photos = await applyPhotoVisibilityFilter(
db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.whereIn('photos.id', photoIds)
.where(function () {
this.whereNull('photos.category_id')
.orWhere('photo_categories.allow_downloads', true)
.orWhereNull('photo_categories.allow_downloads');
}),
req.accessLevel
)
.select('photos.*')
.orderBy('photos.uploaded_at', 'desc');
if (photos.length === 0) {
return res.status(404).json({ error: 'No photos found for selected IDs' });
}
// Download resolution (#858). Resolve BEFORE any header goes out — once
// the archive starts streaming we can no longer return a JSON error.
const selectedPolicy = await resolveEventDownloadPolicy(req.event);
const selectedResolution = pickRequestedResolution(selectedPolicy, req.body?.resolution);
if (selectedResolution === null) {
return res.status(400).json({ error: 'Resolution not available for this gallery' });
}
const selectedBox = parseResolution(selectedResolution);
const archiveName = `${req.event.slug}-selected.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
const archive = archiver('zip', { zlib: { level: 5 } });
archive.on('error', (err) => {
logger.error('Zip error generating selected download', {
slug: req.params.slug,
eventId: req.event?.id,
error: err.message,
});
try {
res.status(500).end();
} catch (_) {
// ignore double-send errors
}
});
archive.pipe(res);
// Check watermark settings - apply if global setting OR event-level setting is enabled
const watermarkSettings = await watermarkService.getWatermarkSettings();
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
const effectiveSettings = shouldApplyWatermark ? {
...watermarkSettings,
enabled: true,
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
} : null;
const { resolvePhotoStorageKey: resolveSelectedKey } = require('../../services/photoResolver');
const selectedStorage = getStorage();
// #493: same display-name resolution as bulk download, with dedup.
const useOriginalSelected = await getUseOriginalFilenames();
const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected);
// Only photos whose append succeeded count as downloaded (#895).
const appendedIds = [];
for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`;
const storageKey = resolveSelectedKey(req.event, photo);
try {
// Same pre-append source check as download-all (#895 review),
// local backend only: a lazy fs stream's async error would kill
// the response instead of skipping the photo; S3's get() rejects
// at the await below, so no redundant per-entry HEAD there.
if (storageKey && selectedStorage.kind() === 'local') {
const srcStat = await selectedStorage.stat(storageKey);
if (!srcStat) {
throw new Error(`Photo missing in storage: ${storageKey}`);
}
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
throw new Error('Photo file missing on disk');
}
// Resize (#858) and/or watermark. renderPhotoForDownload returns null
// when neither applies, so the untransformed case still streams from
// storage rather than buffering the whole photo.
const rendered = await renderPhotoForDownload(req.event, photo, selectedBox, effectiveSettings);
if (rendered) {
archive.append(rendered, { name });
} else if (storageKey) {
const stream = await selectedStorage.get(storageKey);
archive.append(stream, { name });
} else {
archive.file(resolvePhotoFilePath(req.event, photo), { name });
}
appendedIds.push(photo.id);
} catch (err) {
logger.warn('Skipping selected photo due to error', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: err.message,
});
}
}
// See download-all: notify only on response 'finish'.
// Admin preview (#868) streams the archive but is excluded from stats.
if (!req.isAdminPreview) {
res.on('finish', () => {
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'selected', photo_count: photoIds.length }, req.event.id, galleryActor(req));
});
}
await archive.finalize();
if (!req.isAdminPreview) {
await db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download_selected'
});
// Exactly the photos that made it into this archive (#895) — skipped
// (missing/corrupt) sources don't count.
if (appendedIds.length > 0) {
db('photos').whereIn('id', appendedIds)
.increment('download_count', 1).catch(() => {});
}
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to download selected photos');
}
});
// ──────────────────────────────────────────────────────────────────────────
// Custom-resolution download jobs (#858).
//
// The plain download-all is served from the pre-built cache at the gallery's
// STANDARD resolution. Picking a different size has nothing to cache against,
// and resizing a whole gallery inside one request would sit far past any
// reverse-proxy timeout — so those archives are built as a job the client
// polls. Same access rules as the download routes above.
// ──────────────────────────────────────────────────────────────────────────
// Kick off (or join) a build. Returns the polling token.
router.post('/:slug/download-jobs', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
const policy = await resolveEventDownloadPolicy(req.event);
if (!policy.pickerEnabled) {
return res.status(403).json({ error: 'Resolution choice is not enabled for this gallery' });
}
const resolution = pickRequestedResolution(policy, req.body?.resolution);
if (resolution === null) {
return res.status(400).json({ error: 'Resolution not available for this gallery' });
}
// Optional subset. Absent = the whole visible gallery.
let photoIds = null;
if (Array.isArray(req.body?.photo_ids) && req.body.photo_ids.length) {
photoIds = req.body.photo_ids
.map((v) => parseInt(v, 10))
.filter((v) => Number.isInteger(v))
.slice(0, 500);
if (photoIds.length === 0) {
return res.status(400).json({ error: 'No valid photo IDs provided' });
}
}
let job;
try {
job = await downloadJobService.createJob({
event: req.event,
resolution,
photoIds,
accessLevel: req.accessLevel,
});
} catch (err) {
if (err.code === 'NO_PHOTOS') {
return res.status(404).json({ error: 'No photos available for this selection' });
}
if (err.code === 'BUSY') {
return res.status(429).json({ error: 'Too many downloads are being prepared right now — please try again shortly' });
}
throw err;
}
res.status(202).json({
token: job.token,
status: job.status,
resolution: job.resolution,
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to start download preparation');
}
});
// Poll. The token is unguessable, but it is never sufficient on its own —
// verifyGalleryAccess still runs and the job must belong to THIS event.
// no-store: a cached 'preparing' would strand the caller in a poll that can
// never observe the job finishing.
router.get('/:slug/download-jobs/:token', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, noStoreCache, async (req, res) => {
try {
const job = await downloadJobService.getStatus(req.params.token);
if (!job || job.event_id !== req.event.id) {
return res.status(404).json({ error: 'Download job not found' });
}
res.json({
status: job.status,
resolution: job.resolution,
photo_count: job.photo_count || 0,
size_bytes: job.size_bytes || null,
error: job.status === 'failed' ? (job.error || 'Preparation failed') : undefined,
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to read download job');
}
});
// Deliver the finished archive.
router.get('/:slug/download-jobs/:token/file', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
// Downloads can be switched off after a job was created — every other
// download route re-checks this per request, so this one must too.
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
const job = await downloadJobService.getStatus(req.params.token);
if (!job || job.event_id !== req.event.id) {
return res.status(404).json({ error: 'Download job not found' });
}
// The token alone never grants access: the archive was built under one
// visibility scope, and only a requester still in that scope may take it.
// Without this, a leaked client token would hand hidden photos to a guest.
if (job.visibility_scope !== downloadJobService.visibilityScopeFor(req.accessLevel)) {
return res.status(404).json({ error: 'Download job not found' });
}
if (job.status !== 'ready' || !job.zip_path) {
return res.status(409).json({ error: 'Download is not ready yet', status: job.status });
}
if (new Date(job.expires_at).getTime() <= Date.now()) {
return res.status(410).json({ error: 'This download has expired — please request it again' });
}
// A photo hidden AFTER this archive was built is still inside it, and the
// scope check above can't see that — both sides remain 'public'. Re-run
// the visibility query over the packaged set before handing it over.
if (!(await downloadJobService.isStillDeliverable(job, req.event, req.accessLevel))) {
return res.status(409).json({
error: 'This gallery changed since the download was prepared — please request it again',
status: 'stale',
});
}
const storage = getStorage();
const stat = await storage.stat(job.zip_path);
if (!stat) {
return res.status(410).json({ error: 'This download is no longer available' });
}
// Stats parity with the other bulk paths (#895): only count once the
// response actually completed, and keep admin previews out of guest stats.
res.on('finish', () => {
if (res.statusCode >= 400 || req.isAdminPreview) return;
// The DELIVERED set, not the requested one: a photo whose source was
// missing at build time isn't in the zip and must not be counted.
let ids = [];
try {
ids = JSON.parse(job.delivered_photo_ids || job.photo_ids || '[]');
} catch (_) { /* malformed row — skip counting rather than fail */ }
if (ids.length > 0) {
db('photos').whereIn('id', ids).increment('download_count', 1).catch(() => {});
}
db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download',
photo_id: null,
}).catch(() => {});
logActivity('gallery_downloaded', { scope: 'all', resolution: job.resolution },
req.event.id, galleryActor(req));
});
const suffix = job.resolution === 'original' ? 'original' : job.resolution;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Length', stat.size);
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}-${suffix}.zip"`);
const stream = await storage.get(job.zip_path);
pipeStreamToResponse(stream, res, { context: `download job ${job.id}`, missingStatus: 410 });
} catch (error) {
errorResponse(res, error, 500, 'Failed to serve prepared download');
}
});
// Explicit per-photo view beacon (#895). Counting views on the image-
// serving routes is wrong in both directions: the lightbox preloads the
// prev/next neighbours (three fetches per open), while a preloaded
// neighbour that becomes the current slide is never re-fetched (#505
// keeps the DOM node alive across the swipe) — so request-level counters
// overcount preloads AND undercount swipe-throughs. Instead the lightbox
// pings this endpoint exactly when a photo becomes the visible slide.
// This also covers enhanced/maximum-protection galleries, whose bytes
// are served by /api/secure-images and never pass the routes below.
// The slideshow kiosk is excluded (denySlideshowToken; migration 138).
module.exports = router;
+639
View File
@@ -0,0 +1,639 @@
const express = require('express');
const { db } = require('../../database/db');
const path = require('path');
const { resolvePhotoContentType } = require('../../utils/photoContentType');
const router = express.Router();
const watermarkService = require('../../services/watermarkService');
const watermarkGeneratorService = require('../../services/watermarkGeneratorService');
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
const withPreview = (req, url) => (req.isAdminPreview ? `${url}${url.includes('?') ? '&' : '?'}admin_preview=1` : url);
const secureImageService = require('../../services/secureImageService');
const logger = require('../../utils/logger');
const { pipeStreamToResponse } = require('../../utils/streamResponse');
const { errorResponse } = require('../../utils/routeHelpers');
const { blockHiddenGallery } = require('../../utils/revealMode');
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../../services/imageProcessor');
const { getStorage } = require('../../services/storage');
const fs = require('fs');
const { getStoragePath } = require('../../config/storage');
router.post('/:slug/photo/:photoId/view',
verifyGalleryAccess,
denySlideshowToken,
blockHiddenGallery,
async (req, res) => {
try {
const photo = await db('photos')
.where({ id: req.params.photoId, event_id: req.event.id })
.first('id', 'visibility');
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Admin preview (#981 review) is excluded from per-photo view analytics.
if (!req.isAdminPreview) {
await db('photos').where('id', photo.id).increment('view_count', 1);
}
res.status(204).end();
} catch (error) {
errorResponse(res, error, 500, 'Failed to record view');
}
});
// View single photo (with watermark if enabled)
router.get('/:slug/photo/:photoId',
verifyGalleryAccess,
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Check if this is a video
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
// Check protection level - basic and standard protection allow direct JWT access
const protectionLevel = req.event.protection_level || 'standard';
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
// For enhanced/maximum protection, redirect to secure endpoint
return res.status(302).json({
error: 'Secure access required',
secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`,
photoId: photoId
});
}
// Resolve where to read the photo bytes from. For external/reference
// photos the source is always a local mount path. For managed photos
// we go through the storage abstraction so S3 deployments work too
// (#432 — previously this route did fs.* directly and 500'd in S3
// mode because the file wasn't on the container's local fs).
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../../services/photoResolver');
const storage = getStorage();
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
const useStorageBackend = !isExternal;
let filePath = null; // Local fs path (external photos OR LocalFs storage)
let storageKey = null; // Relative storage key (managed photos via storage abstraction)
let stat;
let fileSize;
if (useStorageBackend) {
try {
storageKey = resolvePhotoStorageKey(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo storage key', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
photoPath: photo.path,
photoFilename: photo.filename
});
return res.status(404).json({ error: 'Photo file not found' });
}
stat = await storage.stat(storageKey);
if (!stat) {
logger.error('Photo not found in storage backend', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
storageKey
});
return res.status(404).json({ error: 'Photo file not found' });
}
fileSize = stat.size;
} else {
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
photoPath: photo.path,
photoFilename: photo.filename
});
return res.status(404).json({ error: 'Photo file not found' });
}
if (!fs.existsSync(filePath)) {
logger.error('Photo file does not exist at resolved path', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
resolvedPath: filePath,
photoPath: photo.path
});
return res.status(404).json({ error: 'Photo file not found' });
}
stat = fs.statSync(filePath);
fileSize = stat.size;
}
// Handle video streaming with range requests
if (isVideo) {
const range = req.headers.range;
if (range) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
// Validate before writing the 206: a NaN, inverted or out-of-file
// range used to be committed to the headers and then throw while
// streaming (or read past the end).
if (!Number.isInteger(start) || !Number.isInteger(end)
|| start < 0 || end < start || start >= fileSize) {
res.set('Content-Range', `bytes */${fileSize}`);
return res.status(416).end();
}
const boundedEnd = Math.min(end, fileSize - 1);
const chunksize = (boundedEnd - start) + 1;
res.writeHead(206, {
'Content-Range': `bytes ${start}-${boundedEnd}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': resolvePhotoContentType(photo),
'Cache-Control': 'private, max-age=1800',
'X-Protection-Level': 'basic'
});
const file = useStorageBackend
? await storage.getRange(storageKey, start, boundedEnd)
: fs.createReadStream(filePath, { start, end: boundedEnd });
pipeStreamToResponse(file, res, { context: `video range for photo ${photo.id}` });
} else {
res.writeHead(200, {
'Content-Length': fileSize,
'Content-Type': resolvePhotoContentType(photo),
'Accept-Ranges': 'bytes',
'Cache-Control': 'private, max-age=1800',
'X-Protection-Level': 'basic'
});
const file = useStorageBackend
? await storage.get(storageKey)
: fs.createReadStream(filePath);
pipeStreamToResponse(file, res, { context: `video for photo ${photo.id}` });
}
return;
}
// Image path
const watermarkSettings = await watermarkService.getWatermarkSettings();
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
// orientation_checked_at participates because the backfill (#1198) can
// change these bytes without touching either of the other two inputs:
// it rewrites the derived renditions while the ORIGINAL's mtime and the
// watermark settings both stay exactly as they were. Without it a guest
// holding a pre-fix ETag keeps getting 304 and keeps their cached
// sideways image, however many times the backfill succeeds.
const orientationVersion = photo.orientation_checked_at
? `-o${new Date(photo.orientation_checked_at).getTime()}`
: '';
const etag = `"${photoId}-${mtimeMs}${watermarkHash}${orientationVersion}"`;
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
if (watermarkSettings && watermarkSettings.enabled) {
// Pre-generated watermarked file: served via the storage backend
// (managed) or directly from local fs (external).
if (photo.watermark_path) {
try {
if (useStorageBackend) {
const wmStat = await storage.stat(photo.watermark_path);
if (wmStat) {
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Content-Length': wmStat.size,
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
const wmStream = await storage.get(photo.watermark_path);
return pipeStreamToResponse(wmStream, res, { context: `watermarked photo ${photo.id}` });
}
} else {
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
if (fs.existsSync(watermarkFilePath)) {
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
return res.sendFile(watermarkFilePath);
}
}
} catch (err) {
logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
}
}
// Fallback: apply watermark on-the-fly. applyWatermark needs a
// local file path (sharp + fs.readFile) — for managed photos in
// S3 mode, withLocalCopy materializes to a tmp file and cleans up.
const watermarkedBuffer = useStorageBackend
? await withLocalCopy(storageKey, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings))
: await watermarkService.applyWatermark(filePath, watermarkSettings);
// Queue watermark generation in background for next request
watermarkGeneratorService.generateForPhoto(photo.id)
.catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
res.send(watermarkedBuffer);
} else {
res.set({
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
if (useStorageBackend) {
res.set('Content-Length', stat.size);
res.set('Content-Type', resolvePhotoContentType(photo));
const stream = await storage.get(storageKey);
pipeStreamToResponse(stream, res, { context: `photo ${photo.id}` });
} else {
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
res.sendFile(absolutePath);
}
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to serve photo');
}
}
);
// Serve thumbnail
router.get('/:slug/thumbnail/:photoId',
verifyGalleryAccess,
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Ensure thumbnail exists and is valid, regenerate if needed
// Responsive tier (#1095), whitelisted the same way the preview route's
// is. Unrecognised or absent falls through to the canonical 300px
// thumbnail, so existing clients are untouched.
const { THUMBNAIL_WIDTHS, normalizeTierWidth, ensureThumbnailAtWidth } =
require('../../services/imageProcessor');
const thumbTier = normalizeTierWidth(req.query.w, THUMBNAIL_WIDTHS);
const thumbnailPath = thumbTier
? (await ensureThumbnailAtWidth(photo, thumbTier)) || (await ensureThumbnail(photo))
: await ensureThumbnail(photo);
// What was actually resolved, not what was asked for. A tier request can
// land on the canonical thumbnail — generation failed, or the row is a
// video — and stamping the requested tier into the ETag below would then
// have the client cache a 300px image under its 900px key for the full
// max-age, with no way to notice.
const servedTier = thumbTier && thumbnailPath
&& path.basename(thumbnailPath).startsWith(`thumb_w${thumbTier}_`)
? thumbTier
: null;
if (!thumbnailPath) {
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
return res.status(404).json({ error: 'Thumbnail generation failed' });
}
// Read thumbnail metadata via the storage abstraction so we work in
// both LocalFs and S3 modes (#432). The previous fs.statSync on the
// resolved local path 500'd in S3 deployments because the thumbnail
// only exists in the bucket, not on the container's local fs.
const storage = getStorage();
const stat = await storage.stat(thumbnailPath);
if (!stat) {
logger.error(`Thumbnail not found in storage backend for photo ${photoId}`, { thumbnailPath });
return res.status(404).json({ error: 'Thumbnail not found' });
}
// Log thumbnail access
await secureImageService.logImageAccess(
photoId,
req.event.id,
req.clientInfo,
'thumbnail'
);
// Check if watermarks are enabled and apply to thumbnail
const watermarkSettings = await watermarkService.getWatermarkSettings();
// ETag uses storage stat mtime + photo id + watermark hash.
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
// Tier in the ETag, same reason as the preview route: without it a
// client holding the 300px thumbnail gets a 304 for its 600px request
// and renders the small one, which is this feature inverted.
const etag = `"thumb-${photoId}-${servedTier || 'def'}-${mtimeMs}${watermarkHash}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
// Set appropriate headers with enhanced security
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Reduced cache time
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Protected-Thumbnail': 'true',
'ETag': etag
});
if (watermarkSettings && watermarkSettings.enabled) {
// Watermarking needs a local file path (sharp + fs.readFile).
// Materialize via withLocalCopy — no-op in local mode, downloads
// to a tmp file then cleans up in S3 mode.
const watermarkedBuffer = await withLocalCopy(thumbnailPath, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings)
);
res.send(watermarkedBuffer);
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(thumbnailPath);
pipeStreamToResponse(stream, res, { context: `thumbnail for photo ${photoId}` });
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to serve thumbnail');
}
}
);
// Serve hero-optimized image (1920x1080 for full-width hero sections)
router.get('/:slug/hero/:photoId',
verifyGalleryAccess,
// Reveal-gated too: this route serves a 1920px derivative of ANY photo id,
// not just the chosen hero — an open bypass while hidden (review round 1).
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Check if this is a video - videos don't get hero images
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
if (isVideo) {
// For videos, redirect to the regular photo endpoint
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
}
// Ensure hero image exists and is valid, regenerate if needed
const heroPath = await ensureHeroImage(photo);
if (!heroPath) {
// If hero generation fails, fall back to original photo
logger.warn(`Failed to generate hero image for photo ${photoId}, falling back to original`);
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
}
// Hero images are always written via the storage abstraction (see
// imageProcessor.generateHeroImage), so they're a managed-storage
// key in both LocalFs and S3 modes (#432). Read via storage.
const storage = getStorage();
const stat = await storage.stat(heroPath);
if (!stat) {
logger.error('Hero image file does not exist in storage backend', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
heroPath
});
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
}
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const etag = `"hero-${photoId}-${mtimeMs}"`;
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
const watermarkSettings = await watermarkService.getWatermarkSettings();
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=3600', // Cache for 1 hour
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Hero-Image': 'true',
'ETag': etag
});
if (watermarkSettings && watermarkSettings.enabled) {
// applyWatermark needs a local file path; materialize via
// withLocalCopy so this works in S3 mode too.
const watermarkedBuffer = await withLocalCopy(heroPath, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings)
);
res.send(watermarkedBuffer);
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(heroPath);
pipeStreamToResponse(stream, res, { context: `hero for photo ${photoId}` });
}
} catch (error) {
logger.error('Error serving hero image:', {
error: error.message,
photoId: req.params.photoId,
eventId: req.event?.id
});
// Fall back to original photo on any error
res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`));
}
}
);
// Lightbox preview tier (#492). Aspect-preserved JPEG capped at 1920px
// long edge — admin-controlled opt-in via app_settings.lightbox_preview_enabled.
// Mirrors the hero route shape: same auth, ETag from preview mtime,
// fall back to original on any failure so the lightbox never shows a
// broken image. The watermark application path is preserved so a
// preview surfaced in the lightbox carries the same protection a
// guest would see on the full original.
router.get('/:slug/preview/:photoId',
verifyGalleryAccess,
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Videos don't get a preview tier — fall through to the regular
// photo endpoint (which serves the source). The frontend should
// already be checking media_type before requesting /preview but
// belt-and-braces in case a stale tab does.
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
if (isVideo) {
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
}
// Responsive tier (#1095). Whitelisted only — an open ?w= would let
// anyone fill the disk with renditions nobody asked for. An unrecognised
// or absent value falls through to the canonical 1920 preview, so old
// clients and hand-typed URLs behave exactly as before.
const { PREVIEW_WIDTHS, normalizeTierWidth, ensurePreviewImageAtWidth } =
require('../../services/imageProcessor');
const tierWidth = normalizeTierWidth(req.query.w, PREVIEW_WIDTHS);
// Lazy generation: ensurePreviewImage returns null on any
// failure (corrupt source, sharp OOM, storage unavailable, …).
// Fall back to the original so the lightbox always renders.
const previewPath = tierWidth
? (await ensurePreviewImageAtWidth(photo, tierWidth)) || (await ensurePreviewImage(photo))
: await ensurePreviewImage(photo);
if (!previewPath) {
logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`);
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
}
const storage = getStorage();
const stat = await storage.stat(previewPath);
if (!stat) {
logger.error('Preview file does not exist in storage backend', {
slug: req.params.slug, photoId, eventId: req.event.id, previewPath,
});
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
}
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const watermarkSettings = await watermarkService.getWatermarkSettings();
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
// Tier is part of the etag: without it a client that already holds the
// 1920 rendition would get a 304 for its 640 request and render the
// wrong size, which is the whole point of the feature inverted.
const etag = `"preview-${photoId}-${tierWidth || 'def'}-${mtimeMs}${watermarkHash}"`;
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set({
// From the key, not hard-coded: a preview of a transparent or animated
// source is WebP, because JPEG carries neither. `nosniff` below means
// getting this wrong shows a broken image rather than being silently
// corrected by the browser. Pre-existing keys have no .webp suffix and
// are JPEG, so they keep their old header.
'Content-Type': previewPath.endsWith('.webp') ? 'image/webp' : 'image/jpeg',
// Cache aggressively — preview only changes on photo
// re-upload (which generates a new preview key) or settings
// regenerate (which writes a new mtime + ETag).
'Cache-Control': 'private, max-age=3600',
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Preview-Image': 'true',
'ETag': etag,
});
if (watermarkSettings && watermarkSettings.enabled) {
// No Content-Type override here. applyWatermark PRESERVES the source
// format (watermarkService.js: png -> png, webp -> webp, else jpeg),
// and its input is this preview — so the output format matches the key
// the header was already derived from. Forcing image/jpeg would
// mislabel a watermarked WebP preview, and `nosniff` means the browser
// will not correct it.
//
// What is still lost is the animation: the compositor flattens a
// multi-frame source to one frame while keeping the WebP container.
// That is a separate problem and a much larger one.
const watermarkedBuffer = await withLocalCopy(previewPath, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings)
);
res.send(watermarkedBuffer);
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(previewPath);
pipeStreamToResponse(stream, res, { context: `preview for photo ${photoId}` });
}
} catch (error) {
logger.error('Error serving preview image:', {
error: error.message,
photoId: req.params.photoId,
eventId: req.event?.id,
});
res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`));
}
}
);
// GET /:slug/feedback-settings lives in galleryFeedback.js. A duplicate of it
// used to sit here, and since server.js mounts galleryRoutes before
// galleryFeedback it shadowed the real handler — dropping the per-guest caps
// (#655) from the guest payload, so the gallery could never render the
// favorite/like limits or their counters (#1030).
// Get photo stats. no-store: view/download/visitor counters are private
// gallery analytics and change on every request.
module.exports = router;
+249
View File
@@ -0,0 +1,249 @@
const { isGalleryExpired } = require('../../utils/galleryLifecycle');
const express = require('express');
const { db } = require('../../database/db');
const { formatBoolean } = require('../../utils/dbCompat');
const { getAppSetting } = require('../../utils/appSettings');
const { timingSafeEqualStr } = require('../../utils/timingSafe');
const router = express.Router();
const { resolveHeroLogoVisible } = require('../../services/galleryModel');
const { verifyAdminPreview } = require('../../middleware/gallery');
const { noStoreCache } = require('../../middleware/noStoreCache');
const logger = require('../../utils/logger');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../../services/shareLinkService');
const { handleAsync, errorResponse } = require('../../utils/routeHelpers');
const { isGalleryHidden } = require('../../utils/revealMode');
const { NotFoundError } = require('../../utils/errors');
async function checkSlugRedirect(slug) {
try {
const hasTable = await db.schema.hasTable('slug_redirects');
if (!hasTable) return null;
const redirect = await db('slug_redirects')
.where({ old_slug: slug })
.first();
return redirect ? redirect.new_slug : null;
} catch (error) {
logger.warn('Error checking slug redirect:', { slug, error: error.message });
return null;
}
}
router.get('/resolve/:identifier', handleAsync(async (req, res) => {
const { identifier } = req.params;
let result = await resolveShareIdentifier(identifier);
// If not found, check for redirect
if (!result) {
const newSlug = await checkSlugRedirect(identifier);
if (newSlug) {
return res.status(301).json({
redirect: true,
newSlug,
message: 'Gallery has been renamed'
});
}
throw new NotFoundError('Gallery');
}
const { event, matchType, shareToken } = result;
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
// The share_token is a bearer secret. Only return it (and the share
// links/URLs that embed it) when the caller already proved they hold it —
// i.e. they resolved via the token or the full share link. A bare *slug*
// lookup (slugs appear in gallery URLs and are guessable) must NOT hand
// back the secret, or an anonymous caller could turn a known slug into
// share-link access to a no-password gallery (GHSA-rh8r).
const callerHasToken = matchType !== 'slug';
if (!callerHasToken) {
return res.json({ slug: event.slug, matchType, requires_password: requiresPassword });
}
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
res.json({
slug: event.slug,
token: shareToken,
matchType,
share_link: event.share_link,
share_path: linkVariants.sharePath,
share_url: linkVariants.shareUrl,
short_enabled: linkVariants.shortEnabled,
requires_password: requiresPassword
});
}));
// Verify share token. no-store: this is an authorization decision — a cached
// `{ valid: true }` would keep answering for a token the admin has rotated.
router.get('/:slug/verify-token/:token', noStoreCache, handleAsync(async (req, res) => {
const { slug, token } = req.params;
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) })
.select('id', 'share_link', 'share_token')
.first();
if (!event) {
throw new NotFoundError('Gallery');
}
const expectedToken = getEventShareToken(event);
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
throw new NotFoundError('Gallery', 'Invalid gallery link');
}
res.json({ valid: true });
}));
// Get gallery info (with optional token verification)
router.get('/:slug/info', async (req, res) => {
try {
const { slug } = req.params;
const { token } = req.query;
let event = await db('events')
.where({ slug })
.select(
'id',
'created_by',
'event_name',
'event_type',
'event_date',
'expires_at',
'is_active',
'is_archived',
'share_link',
'share_token',
'allow_downloads',
'allow_user_uploads',
'reveal_mode',
'reveal_at',
'revealed_at',
'disable_right_click',
'watermark_downloads',
'watermark_text',
'require_password',
'color_theme',
'enable_devtools_protection',
'use_canvas_rendering',
'hero_logo_visible',
'hero_logo_size',
'hero_logo_position',
'hero_logo_url',
'login_logo_visible',
'header_style',
'hero_divider_style',
'hero_image_anchor',
'is_draft',
'default_photo_sort',
// Per-event promotional override (#440). Resolution into a
// ready-to-render markdown string happens below so the
// frontend doesn't have to know about modes.
'promo_mode',
'promo_markdown',
'info_mode',
'info_markdown'
)
.first();
if (!event) {
// Check for redirect
const newSlug = await checkSlugRedirect(slug);
if (newSlug) {
return res.status(301).json({
redirect: true,
newSlug,
message: 'Gallery has been renamed'
});
}
return res.status(404).json({ error: 'Gallery not found' });
}
// Check if event is archived
if (event.is_archived) {
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
}
// Admin preview (#868) bypasses both the draft gate and — below — the
// password gate. Computed once and reused.
const adminPreview = await verifyAdminPreview(req, event);
// Check if event is a draft (allow admin preview)
if (event.is_draft && !adminPreview) {
return res.status(404).json({ error: 'Gallery is not yet published' });
}
// If token provided, verify it matches the share link
if (token) {
const expectedToken = getEventShareToken(event);
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
return res.status(404).json({ error: 'Invalid gallery link' });
}
}
// Admin preview skips the guest password on published, protected galleries
// (#868) — the admin already sees every photo through the admin routes.
const requiresPassword = adminPreview
? false
: !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
res.json({
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
expires_at: event.expires_at,
is_active: event.is_active,
is_expired: !event.is_active || isGalleryExpired(event),
requires_password: requiresPassword,
color_theme: event.color_theme,
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1',
// Reveal mode (#838): effective hidden state (computed, time-exact) so
// the landing page can hint at the reveal before login too.
hidden_until_reveal: isGalleryHidden(event),
reveal_at: isGalleryHidden(event) ? (event.reveal_at || null) : null,
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
watermark_text: event.watermark_text,
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
// #894: only an explicit false hides the logo on the password page;
// NULL keeps the default (show).
login_logo_visible: !(event.login_logo_visible === false || event.login_logo_visible === 0 || event.login_logo_visible === '0'),
// #756: NULL per-event size inherits the global branding_logo_size.
hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium',
hero_logo_position: event.hero_logo_position || 'top',
hero_logo_url: event.hero_logo_url || null,
header_style: event.header_style || 'standard',
hero_divider_style: event.hero_divider_style || 'wave',
hero_image_anchor: event.hero_image_anchor || 'center',
default_photo_sort: event.default_photo_sort || 'upload_date_desc',
// Per-event promotional override (#440). Frontend resolves
// 'inherit' against branding_promo_markdown from public settings.
promo_mode: event.promo_mode || 'inherit',
promo_markdown: event.promo_markdown || null,
// Info banner (#932). Same inherit/custom/off semantics as promo,
// resolved against branding_info_markdown from public settings.
info_mode: event.info_mode || 'inherit',
info_markdown: event.info_markdown || null
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to fetch gallery info');
}
});
// ---------------------------------------------------------------------------
// Live Slideshow ("Diashow") — token-only fullscreen kiosk surface
// (migration 138). The token in the URL IS the secret (no gallery password),
// so these routes are unauthenticated except for the token match itself. The
// slideshow shows ALL public/visible, finished photos — exactly the guest
// set — so once /session mints a short-lived `accessLevel:'slideshow'` JWT,
// the page reuses the normal /photos + image endpoints unchanged.
// ---------------------------------------------------------------------------
// 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.
module.exports = router;
+192
View File
@@ -0,0 +1,192 @@
const express = require('express');
const { db, logActivity } = require('../../database/db');
const router = express.Router();
const { verifyGalleryAccess } = require('../../middleware/gallery');
const { resolveGuest } = require('../../middleware/guestAuth');
const { noStoreCache } = require('../../middleware/noStoreCache');
const { generateGuestIdentifier } = require('../../middleware/feedbackRateLimit');
const { errorResponse } = require('../../utils/routeHelpers');
const { guestBlockedByReveal } = require('../../utils/revealMode');
const downloadZipService = require('../../services/downloadZipService');
const GALLERY_OPENED_DEBOUNCE_MS = 6 * 60 * 60 * 1000;
const galleryOpenedNotifiedAt = new Map();
function galleryActor(req) {
// Portal tokens run as accessLevel 'guest' but carry via:'customer'
// (req.viaCustomer); PIN-client logins carry accessLevel 'client'.
// Both are customers, not guests (codex review of #849, final round).
const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client'));
return { type: isCustomer ? 'customer' : 'guest' };
}
function notifyGalleryOpened(event, req) {
// Customer-PORTAL opens already log `customer_event_access` on the
// access-token mint — a second `gallery_opened` per portal click would
// double-notify. Keyed on the portal provenance (req.viaCustomer), NOT
// on accessLevel: PIN-client logins are 'client' without any other
// open signal and must keep notifying (codex review of #849, final
// round — the previous check had this inverted).
if (req && req.viaCustomer) return;
const now = Date.now();
const last = galleryOpenedNotifiedAt.get(event.id) || 0;
if (now - last < GALLERY_OPENED_DEBOUNCE_MS) return;
galleryOpenedNotifiedAt.set(event.id, now);
// Fire-and-forget — logActivity swallows its own errors.
logActivity('gallery_opened', {}, event.id, galleryActor(req));
}
router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => {
try {
const payload = await require('../../services/galleryQueryService').getGalleryPhotos({
event: req.event, slug: req.params.slug, query: req.query,
identity: { guestId: req.guest?.id, guestIdentifier: generateGuestIdentifier(req) },
accessLevel: req.accessLevel, adminPreview: req.isAdminPreview,
hiddenForGuest: guestBlockedByReveal(req),
});
// Log view — but NOT for the Live Slideshow kiosk. A running projector
// refetches this list on every new-upload poll, which would massively
// inflate total_views / unique_visitors. The slideshow is explicitly
// excluded from real visitor analytics (migration 138 design).
// Admin preview (#868) is excluded from guest analytics + the "gallery
// opened" bell — it's the photographer looking at their own gallery.
if (req.accessLevel !== 'slideshow' && !req.isAdminPreview && !(Number(req.query.page) > 1)) {
await db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'view'
});
notifyGalleryOpened(req.event, req);
}
res.json(payload);
} catch (error) { errorResponse(res, error, 500, 'Failed to fetch photos'); }
});
/**
* People in this gallery (#1074).
*
* Returns [] rather than 403 whenever the feature is unavailable a guest
* must not be able to tell "this gallery has no people" from "this gallery
* has the feature switched off". Same reasoning as reveal mode returning an
* empty photo set rather than an error.
*
* Counts and cover faces are computed against the caller's own visibility
* scope inside facePeopleService; nothing here reads face_count_total.
*/
// no-store for the same reason as /photos: the people list and its scan
// progress are scoped to what THIS viewer may see.
router.get('/:slug/people', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => {
try {
const isClient = req.accessLevel === 'client';
const { isEnabledForEvent, areFacesVisibleToGuests, getThresholds } =
require('../../services/faceSettings');
if (!(await isEnabledForEvent(req.event))) {
return res.json({ people: [] });
}
if (!isClient && !areFacesVisibleToGuests(req.event)) {
return res.json({ people: [] });
}
// While a gallery is hidden behind reveal mode (#838), a plain guest sees
// no photos — so they see no people either.
if (guestBlockedByReveal(req)) {
return res.json({ people: [] });
}
const { listPeople, getScanStatus } = require('../../services/facePeopleService');
const thresholds = await getThresholds();
const people = await listPeople(req.event.id, {
isClient,
forAdmin: false,
minClusterSize: thresholds.face_min_cluster_size,
});
// Drives the "Finding people… 240/1200" progress line during a backfill.
// Scoped to what this viewer may see — an unscoped total would leak the
// number of hidden photos through the progress bar.
const status = await getScanStatus(req.event.id, { isClient });
res.json({
people,
scan: {
in_progress: status.in_progress,
scanned: status.scanned,
total: status.total,
},
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to fetch people');
}
});
// Toggle photo visibility (client-only)
router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => {
try {
if (req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Client access required' });
}
const { photoId } = req.params;
const { visibility } = req.body;
if (!['visible', 'hidden'].includes(visibility)) {
return res.status(400).json({ error: 'Invalid visibility value' });
}
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
await db('photos')
.where({ id: photoId, event_id: req.event.id })
.update({ visibility });
// A client hiding/showing a photo changes the guest download bundle —
// drop the cached ZIP so it rebuilds fresh (codex review).
downloadZipService.invalidate(req.event.id);
res.json({ message: 'Photo visibility updated', visibility });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update photo visibility');
}
});
// Bulk toggle photo visibility (client-only)
router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => {
try {
if (req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Client access required' });
}
const { photoIds, visibility } = req.body;
if (!Array.isArray(photoIds) || photoIds.length === 0) {
return res.status(400).json({ error: 'Invalid photo IDs' });
}
if (!['visible', 'hidden'].includes(visibility)) {
return res.status(400).json({ error: 'Invalid visibility value' });
}
const count = await db('photos')
.whereIn('id', photoIds)
.where('event_id', req.event.id)
.update({ visibility });
// Client bulk hide/show alters the guest download bundle — invalidate
// the cached ZIP (codex review).
downloadZipService.invalidate(req.event.id);
res.json({ message: `${count} photos updated`, visibility });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update photo visibility');
}
});
// Download single photo
module.exports = router;
+293
View File
@@ -0,0 +1,293 @@
const { isGalleryAvailable } = require('../../utils/galleryLifecycle');
const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { db } = require('../../database/db');
const { formatBoolean } = require('../../utils/dbCompat');
const router = express.Router();
const { noStoreCache } = require('../../middleware/noStoreCache');
const logger = require('../../utils/logger');
const { getEventShareToken, buildShareLinkVariants } = require('../../services/shareLinkService');
const { handleAsync } = require('../../utils/routeHelpers');
const { NotFoundError } = require('../../utils/errors');
const { setGalleryAuthCookies } = require('../../utils/tokenUtils');
const { getSlideshowGlobals } = require('../../utils/slideshowGlobals');
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
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');
})
.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
// when the link is missing/rotated/disabled or the gallery isn't live (archived
// / draft / inactive / expired) — every one of those collapses to a 404 so a
// dead link reveals nothing and stops any projector on its next poll.
async function resolveSlideshow(slug, token) {
if (!token) return null;
// The `slideshow` feature flag is a master kill-switch: when an admin turns
// Live Slideshow off, every existing /show/ link dies on its next request
// (the running projector stops within one /state poll), not just the admin UI.
if (!(await isFeatureEnabled('slideshow'))) return null;
const event = await db('events')
.where({
slug,
show_share_token: token,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
})
.first();
if (!isGalleryAvailable(event)) return null;
return event;
}
// Resolve the slideshow's live styling, including the ZDF/ARD-ident-style
// watermark (a white, semi-transparent corner logo). The logo URL is resolved
// from the chosen source so the kiosk renders it without knowing about
// branding/event internals; null url = nothing to overlay.
async function slideshowSettings(event, req) {
// The global look/fit (Settings → Slideshow) + branding logo URLs come from a
// short-TTL cached bundle so a 3s projector poll doesn't re-fire ~10 settings
// reads each time (PR #646 review, concern 2).
const g = await getSlideshowGlobals();
// Watermark: the LOOK (logo/position/opacity/style/size) is configured ONCE
// globally; it is NOT duplicated per event. The only per-event control is
// whether the watermark shows: `show_watermark` NULL inherits the global
// enabled flag, true/false force it on/off.
const wm = event.show_watermark;
const inherit = (wm === null || wm === undefined);
const enabled = inherit ? g.watermark_enabled : (wm === true || wm === 1 || wm === '1');
let watermark = null;
if (enabled) {
// Resolve the chosen logo to a URL. Branding assets come from settings;
// the event source uses the event's own hero logo.
let url;
if (g.watermark_source === 'event') {
url = event.hero_logo_url || null;
} else if (g.watermark_source === 'logo_dark') {
url = g.branding_logo_url_dark;
} else if (g.watermark_source === 'favicon') {
url = g.branding_favicon_url;
} else {
url = g.branding_logo_url;
}
if (url) {
watermark = {
url,
position: g.watermark_position,
opacity: g.watermark_opacity,
style: g.watermark_style,
size: g.watermark_size,
};
}
}
// QR overlay (#837): like the watermark, the LOOK is global-only and the
// per-event `show_qr` tri-state (NULL = inherit) decides visibility. The QR
// encodes the gallery share URL and ships as a data URI so the public
// slideshow client needs no QR library and no extra authenticated endpoint.
const qrOverride = event.show_qr;
const qrInherit = (qrOverride === null || qrOverride === undefined);
const qrEnabled = qrInherit ? g.qr_enabled : (qrOverride === true || qrOverride === 1 || qrOverride === '1');
let qr = null;
if (qrEnabled) {
const dataUrl = await slideshowQrDataUrl(event, req);
if (dataUrl) {
qr = {
data_url: dataUrl,
position: g.qr_position,
opacity: g.qr_opacity,
size: g.qr_size,
};
}
}
return {
interval_ms: event.show_interval_ms || 5000,
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,
qr,
};
}
// The state endpoint is polled every ~3s per projector — cache the generated
// QR data URI per share URL instead of re-encoding on every poll. Bounded:
// entries live for past events / rotated tokens too, so without eviction the
// map would grow with every share URL ever displayed (codex review of #848).
// Insertion-order eviction is enough — concurrently-shown events stay hot.
const SLIDESHOW_QR_CACHE_MAX = 50;
// Keyed by event id (NOT by URL): the origin is caller-influenced when the
// configured base is loopback, so URL-keyed caching would let a slideshow
// -link holder force a fresh QRCode.toDataURL per request with unique
// origins — a cheap CPU-exhaustion path (codex review of #848,
// confirmation round). Per-event entries + a regeneration throttle bound
// the encode rate regardless of what the caller sends.
const SLIDESHOW_QR_REGEN_MS = 60_000;
const slideshowQrCache = new Map(); // eventId -> { url, dataUrl, at }
// Localhost/relative guard (codex review of #848): with the compose-default
// FRONTEND_URL=http://localhost:3000 (or none configured) the QR would send
// scanning phones to THEIR localhost. The state poll comes from the kiosk
// browser itself, so its Host header + protocol are exactly the public
// origin guests can reach — prefer that whenever the configured base is
// missing or loopback. trust proxy is configured, so req.protocol respects
// X-Forwarded-Proto behind the standard reverse-proxy setups.
// Centralised in utils/frontendUrl (#705) so the QR path and the public-origin
// resolver agree on what counts as a non-shareable base.
const QR_LOCAL_BASE_RE = { test: (v) => require('../../utils/frontendUrl').isLoopbackBase(v) };
const QR_ORIGIN_RE = /^https?:\/\/[^\s/]+$/i;
async function slideshowQrDataUrl(event, req) {
try {
const shareToken = getEventShareToken(event);
if (!shareToken) return null;
let { shareUrl, sharePath } = await buildShareLinkVariants({ slug: event.slug, shareToken });
if (!/^https?:\/\//i.test(shareUrl) || QR_LOCAL_BASE_RE.test(shareUrl)) {
// Prefer the kiosk's own window.location.origin (?origin=, validated):
// req.get('host') is NOT the browser origin behind the standard
// proxies — frontend/nginx.conf forwards $host (port stripped), so a
// compose LAN deployment on :3000 would encode port 80. A LOOPBACK
// kiosk origin is rejected too: it is no more guest-reachable than
// the loopback base it would replace (codex review of #848).
const rawOrigin = req?.query?.origin;
const queryOrigin = typeof rawOrigin === 'string' && QR_ORIGIN_RE.test(rawOrigin) && !QR_LOCAL_BASE_RE.test(rawOrigin)
? rawOrigin.replace(/\/$/, '')
: null;
const host = req && req.get ? req.get('host') : null;
const hostOrigin = host ? `${req.protocol}://${host}` : null;
if (queryOrigin) shareUrl = `${queryOrigin}${sharePath}`;
else if (hostOrigin && !QR_LOCAL_BASE_RE.test(hostOrigin)) shareUrl = `${hostOrigin}${sharePath}`;
// Still loopback/relative → no reachable URL exists; suppress the
// overlay rather than encode a QR that sends phones to localhost.
else return null;
}
const cached = slideshowQrCache.get(event.id);
if (cached && cached.url === shareUrl) return cached.dataUrl;
// URL differs from the cached one: NEVER serve the mismatched artifact —
// a slideshow-token holder could otherwise poison the projector's QR
// with an attacker origin for a whole throttle window (codex review of
// #848, final round). Inside the window the overlay is briefly
// suppressed instead; regeneration stays bounded per event.
if (cached && Date.now() - cached.at < SLIDESHOW_QR_REGEN_MS) {
return cached.pending ? cached.dataUrl : null;
}
// Single-flight: concurrent polls on a cold cache must not each
// schedule their own 512px encode — reserve the entry with a shared
// promise before awaiting.
if (cached && cached.pending && cached.url === shareUrl) return cached.pending;
const QRCode = require('qrcode');
const entry = { url: shareUrl, dataUrl: null, at: Date.now(), pending: null };
entry.pending = QRCode.toDataURL(shareUrl, { width: 512, margin: 4 }).then((dataUrl) => {
entry.dataUrl = dataUrl;
entry.pending = null;
return dataUrl;
}).catch((e) => {
slideshowQrCache.delete(event.id);
throw e;
});
if (!slideshowQrCache.has(event.id) && slideshowQrCache.size >= SLIDESHOW_QR_CACHE_MAX) {
slideshowQrCache.delete(slideshowQrCache.keys().next().value);
}
slideshowQrCache.set(event.id, entry);
return await entry.pending;
} catch (e) {
logger.error('Slideshow QR generation failed:', e);
return null;
}
}
// Open a slideshow session: validate the token and mint a short-lived gallery
// JWT scoped to `accessLevel:'slideshow'` (treated as a guest by the photo /
// image endpoints → visible photos only, no client-only/hidden). The page
// stores this token and the existing axios interceptor injects it.
// no-store: this response *is* a credential (it mints a gallery JWT and sets
// the per-slug auth cookie), so it must never be retained anywhere.
router.get('/:slug/show/:token/session', noStoreCache, handleAsync(async (req, res) => {
const { slug, token } = req.params;
const event = await resolveSlideshow(slug, token);
if (!event) {
throw new NotFoundError('Slideshow');
}
const sessionToken = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
accessLevel: 'slideshow',
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '12h',
issuer: 'picpeak-auth'
});
// <img> tags can't carry an Authorization header, so the photo/thumbnail/
// preview endpoints authenticate via the per-slug gallery cookie. Set it
// here so the kiosk's image requests are authorized with zero extra wiring.
setGalleryAuthCookies(res, sessionToken, event.slug);
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
res.json({
token: sessionToken,
event: {
event_name: event.event_name,
event_type: event.event_type,
color_theme: event.color_theme
},
settings: await slideshowSettings(event, req),
photo_count: parseInt(count, 10) || 0,
expires_at: event.expires_at || null
});
}));
// Cheap live-poll endpoint (tiny payload, hit every ~3s by the running show):
// current settings + the visible photo count. The page diffs photo_count to
// decide when to refetch the full list, and re-reads settings so admin changes
// take effect live. A dead/disabled link 404s here → the projector stops.
router.get('/:slug/show/:token/state', noStoreCache, handleAsync(async (req, res) => {
const { slug, token } = req.params;
const event = await resolveSlideshow(slug, token);
if (!event) {
throw new NotFoundError('Slideshow');
}
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
res.json({
...(await slideshowSettings(event, req)),
photo_count: parseInt(count, 10) || 0,
expires_at: event.expires_at || null
});
}));
// Get all photos.
//
// no-store (B6): the payload is private and per-guest — it carries the
// viewer's own likes/favorites/ratings and, for a client token, photos hidden
// from plain guests. With no Cache-Control at all a browser applies heuristic
// freshness and may reuse a body it stored on disk, on a shared device, for a
// gallery whose password has since been rotated. Express still computes its
// weak ETag, so a caller that does revalidate (React Query's own in-memory
// cache is unaffected either way) still gets a correct 304.
module.exports = router;
+44
View File
@@ -0,0 +1,44 @@
const express = require('express');
const { db } = require('../../database/db');
const router = express.Router();
const { verifyGalleryAccess } = require('../../middleware/gallery');
const { noStoreCache } = require('../../middleware/noStoreCache');
const { blockHiddenGallery } = require('../../utils/revealMode');
router.get('/:slug/stats', verifyGalleryAccess, blockHiddenGallery, noStoreCache, async (req, res) => {
try {
const totalPhotos = await db('photos')
.where('event_id', req.event.id)
.count('id as count')
.first();
const totalViews = await db('access_logs')
.where('event_id', req.event.id)
.where('action', 'view')
.count('id as count')
.first();
const totalDownloads = await db('photos')
.where('event_id', req.event.id)
.sum('download_count as total')
.first();
const uniqueVisitors = await db('access_logs')
.where('event_id', req.event.id)
.countDistinct('ip_address as count')
.first();
res.json({
total_photos: totalPhotos.count,
total_views: totalViews.count,
total_downloads: totalDownloads.total || 0,
unique_visitors: uniqueVisitors.count
});
} catch (error) {
res.status(500).json({ error: 'Failed to fetch stats' });
}
});
// User photo upload endpoint
module.exports = router;
+41
View File
@@ -0,0 +1,41 @@
const express = require('express');
const { db } = require('../../database/db');
const router = express.Router();
const logger = require('../../utils/logger');
router.get('/:slug/css-template', async (req, res) => {
try {
const { slug } = req.params;
// Find the event by slug
const event = await db('events')
.where({ slug })
.select('css_template_id')
.first();
if (!event || !event.css_template_id) {
// No custom CSS - return 204 No Content
return res.status(204).send();
}
// Get the template if it's enabled
const template = await db('css_templates')
.where({ id: event.css_template_id, is_enabled: true })
.select('css_content')
.first();
if (!template || !template.css_content) {
return res.status(204).send();
}
// Return CSS with caching headers
res.setHeader('Content-Type', 'text/css');
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
res.send(template.css_content);
} catch (error) {
logger.error('Get CSS template error:', error);
res.status(500).send('/* Error loading template */');
}
});
module.exports = router;
+216
View File
@@ -0,0 +1,216 @@
const express = require('express');
const { db } = require('../../database/db');
const router = express.Router();
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
const { noStoreCache } = require('../../middleware/noStoreCache');
const logger = require('../../utils/logger');
const { errorResponse } = require('../../utils/routeHelpers');
router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId);
// Verify the event matches the token
if (req.event.id !== eventId) {
return res.status(403).json({ error: 'Access denied' });
}
// Check if user uploads are allowed
if (!req.event.allow_user_uploads) {
return res.status(403).json({ error: 'User uploads are not allowed for this event' });
}
// Ensure temp upload directory exists
const fs = require('fs');
const tempUploadDir = '/tmp/uploads/';
if (!fs.existsSync(tempUploadDir)) {
try {
fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 });
logger.info('Created temp upload directory:', tempUploadDir);
} catch (mkdirErr) {
return errorResponse(res, mkdirErr, 500, 'Server configuration error: unable to create upload directory');
}
}
// Import multer and photo processing
const multer = require('multer');
const { getAllowedMimeTypes, getMaxFilesPerUpload, getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings');
const { validateFileType } = require('../../utils/fileSecurityUtils');
// Resolve allowed MIME types from settings
let allowedMimeTypes;
try {
allowedMimeTypes = await getAllowedMimeTypes();
} catch {
allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
}
// #613 — per-batch file count was hardcoded to 10 here, so the admin's
// Settings → General → "Max Files per Upload" value silently didn't
// apply to guest uploads (only admin uploads honoured it via
// adminPhotos.js:131). Zszywany reported uploading 16 files succeeded
// even with the limit set to 10. Mirror the admin path: resolve from
// settings (cached for 60s in the service) and feed multer both
// `limits.files` and the `.array(...)` cap. Fall back to the service's
// default if the read fails.
let maxFilesPerUpload;
try {
maxFilesPerUpload = await getMaxFilesPerUpload();
} catch {
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: maxFileSizeBytes,
files: maxFilesPerUpload,
// CVE-2026-82333: files arrive as repeated `photos` parts via
// .array(), not bracket-indexed field names like `photos[0]` — no
// legitimate field name uses array-index syntax at all. Reject any
// that do.
fieldArrayIndexLimit: 0
},
fileFilter: (req, file, cb) => {
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
cb(null, true);
} else {
cb(new Error('Invalid file type'));
}
}
}).array('photos', maxFilesPerUpload);
// Handle upload
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 });
}
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const { queueFilesForProcessing } = require('../../services/photoProcessor');
const rawCategory = req.body.category_id || req.event.upload_category_id || null;
const numericCategoryId = (() => {
if (rawCategory === null || rawCategory === undefined) return null;
const n = parseInt(rawCategory, 10);
return Number.isFinite(n) ? n : null;
})();
try {
// Queue files as 'pending' — the background worker will process
// thumbnails / EXIF / dimensions off the request thread (#357).
const result = await queueFilesForProcessing(req.files, {
eventId,
photoType: 'individual',
categoryId: numericCategoryId,
});
res.status(202).json({
message: 'Photos queued for processing',
upload_id: result.uploadId,
count: result.photos.length,
photo_ids: result.photos.map((p) => p.id),
photos: result.photos,
errors: result.errors.length > 0 ? result.errors : undefined,
});
} catch (processError) {
errorResponse(res, processError, 500, 'Failed to process photos');
}
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to upload photos');
}
});
// A guest upload_id is `crypto.randomBytes(16).toString('hex')`
// (photoProcessor.js). The pattern is deliberately a little wider than that so
// an id-format change does not silently 400, but narrow enough that the value
// can only ever be an opaque token.
const UPLOAD_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
// The guest UI uploads one file per request, so a batch of N files yields N
// upload ids. Batching them into a single poll keeps the request rate flat
// regardless of batch size; the cap bounds the IN-list.
const MAX_UPLOAD_STATUS_IDS = 50;
/**
* GET /:slug/uploads/status?ids=<upload_id>[,<upload_id>]
*
* Guest-facing processing status for the guest's own uploads (B7).
*
* The upload route answers 202 and queues the files, and /photos only returns
* rows that reached `processing_status: 'complete'`. Without this the gallery
* had to poll /photos blind, could not say "processing…", and could not tell a
* slow worker from a photo that failed outright the guest just watched their
* upload not appear.
*
* Authorization: `verifyGalleryAccess` already resolved `req.event` from the
* caller's gallery token, and the query is filtered on `event_id = req.event.id`
* as well as the ids. An id belonging to another gallery therefore matches no
* row rather than being reported as forbidden no cross-event read, and no
* existence oracle either. Slideshow tokens are denied because a kiosk never
* uploads.
*
* The response is counts only. The guest already knows which files they sent;
* anything more (filenames, `processing_error` strings, which can carry
* internal paths) would be leaking beyond "how far along is my upload".
*/
router.get('/:slug/uploads/status', verifyGalleryAccess, denySlideshowToken, noStoreCache, async (req, res) => {
try {
const ids = String(req.query.ids || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean);
if (ids.length === 0 || ids.length > MAX_UPLOAD_STATUS_IDS || !ids.every((id) => UPLOAD_ID_PATTERN.test(id))) {
return res.status(400).json({ error: 'Invalid upload ids' });
}
const rows = await db('photos')
.where('event_id', req.event.id)
.whereIn('upload_id', ids)
.select('processing_status');
const summary = { total: rows.length, pending: 0, processing: 0, complete: 0, failed: 0 };
for (const row of rows) {
// NULL is a pre-async-migration row, treated as complete exactly as the
// /photos filter treats it.
const status = row.processing_status || 'complete';
if (Object.prototype.hasOwnProperty.call(summary, status) && status !== 'total') {
summary[status] += 1;
}
}
res.json(summary);
} catch (error) {
errorResponse(res, error, 500, 'Failed to read upload status');
}
});
/**
* GET /:slug/css-template
* Get custom CSS template for gallery (public endpoint)
*/
module.exports = router;
+19 -13
View File
@@ -6,6 +6,7 @@ const { verifyGalleryAccess } = require('../middleware/gallery');
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
const watermarkService = require('../services/watermarkService');
const secureImageService = require('../services/secureImageService');
const galleryAccessService = require('../services/galleryAccessService');
const { getStorage } = require('../services/storage');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver');
const { withLocalCopy } = require('../services/imageProcessor');
@@ -19,16 +20,13 @@ const router = express.Router();
/**
* Generate a signed URL token for image access
*/
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false) {
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false, galleryAccess) {
const secret = process.env.JWT_SECRET;
const expires = Date.now() + (expiresIn * 1000);
// Third segment (#838): whether the minting context bypasses reveal mode
// (slideshow/client/admin). Fourth segment: whether the minter was a
// PIN-client, allowing the serve route to still deliver a photo that was
// hidden AFTER minting (TOCTOU) — a guest's token carries 0, so it stops
// working the moment the photo is hidden. Old shorter tokens verify
// unchanged and read both flags as no-bypass.
const data = `${photoId}:${expires}:${revealBypass ? 1 : 0}:${clientBypass ? 1 : 0}`;
// Bind the URL to its issuing access grant. Old tokens without a grant
// must be refreshed: they cannot prove session revocation or ownership.
const grant = Buffer.from(JSON.stringify(galleryAccess)).toString('base64url');
const data = `${photoId}:${expires}:${revealBypass ? 1 : 0}:${clientBypass ? 1 : 0}:${grant}`;
const signature = crypto.createHmac('sha256', secret).update(data).digest('hex');
return `${Buffer.from(data).toString('base64')}.${signature}`;
}
@@ -41,7 +39,7 @@ function verifyImageToken(token) {
const secret = process.env.JWT_SECRET;
const [data, signature] = token.split('.');
const decoded = Buffer.from(data, 'base64').toString();
const [photoId, expires, bypassFlag, clientFlag] = decoded.split(':');
const [photoId, expires, bypassFlag, clientFlag, grant] = decoded.split(':');
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
@@ -50,7 +48,7 @@ function verifyImageToken(token) {
}
// Check expiration
if (Date.now() > parseInt(expires)) {
if (!Number.isFinite(Number(expires)) || Date.now() >= Number(expires) || !grant) {
return null;
}
@@ -59,6 +57,7 @@ function verifyImageToken(token) {
expires: parseInt(expires),
revealBypass: bypassFlag === '1',
clientBypass: clientFlag === '1',
galleryAccess: JSON.parse(Buffer.from(grant, 'base64url').toString()),
};
} catch (error) {
return null;
@@ -170,6 +169,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery
res.send(finalImage);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving protected image:', error);
res.status(500).json({ error: 'Failed to serve image' });
}
@@ -207,6 +207,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
// Generate secure token. clientBypass lets a client's token keep serving
// a photo hidden after minting; a guest's stops at the serve route.
const token = secureImageService.generateSecureToken(photoId, req.sessionID || 'anonymous', {
galleryAccess: req.galleryAccess,
expiresIn,
maxUses: protectionLevel === 'maximum' ? 1 : 3,
clientFingerprint,
@@ -222,6 +223,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
});
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error generating secure token:', error);
res.status(500).json({ error: 'Failed to generate token' });
}
@@ -262,7 +264,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
// Generate signed token. The client-bypass flag lets a PIN-client's
// token keep serving a photo hidden after minting; a guest's token
// (clientBypass=0) stops the moment the photo is hidden.
const token = generateImageToken(photoId, 3600, bypassesReveal(req), canSeeHiddenPhotos(req.accessLevel));
const token = generateImageToken(photoId, 3600, bypassesReveal(req), canSeeHiddenPhotos(req.accessLevel), req.galleryAccess);
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
res.json({
@@ -271,6 +273,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
});
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error generating signed URL:', error);
res.status(500).json({ error: 'Failed to generate URL' });
}
@@ -299,6 +302,8 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
return res.status(404).json({ error: 'Event not found' });
}
await galleryAccessService.authorize(event, tokenData.galleryAccess);
// Reveal mode (#838): a signed URL minted before a re-hide must not keep
// serving hidden photos; tokens minted by bypass contexts carry the flag.
if (isGalleryHidden(event) && !tokenData.revealBypass) {
@@ -338,7 +343,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Content-Length': imageBuffer.length,
'Cache-Control': 'private, max-age=3600',
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff'
});
@@ -346,9 +351,10 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
res.send(imageBuffer);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving signed image:', error);
res.status(500).json({ error: 'Failed to serve image' });
}
});
module.exports = router;
module.exports = router;
+4 -1
View File
@@ -62,7 +62,10 @@ const signedPdfStorage = multer.diskStorage({
const signedPdfUpload = multer({
storage: signedPdfStorage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
// CVE-2026-82333: single unnamed `file` field only, and this route is
// unauthenticated (token-only) — no legitimate array-indexed field
// names, so reject any bracket-index field name.
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 10 MB
fileFilter: (req, file, cb) => {
if (validateFileType(file.originalname, file.mimetype, ['application/pdf'])) return cb(null, true);
return cb(new Error('Only PDF files are allowed'));
+5 -1
View File
@@ -122,7 +122,11 @@ const tempStorage = multer.diskStorage({
function buildUploader(maxSizeBytes, allowed) {
return multer({
storage: tempStorage,
limits: { fileSize: maxSizeBytes, files: MAX_FILES_PER_UPLOAD },
// CVE-2026-82333: files arrive as repeated `files` parts via .array(),
// not bracket-indexed field names like `files[0]`, and this route is
// unauthenticated (token-only) — no legitimate field name uses
// array-index syntax at all. Reject any that do.
limits: { fileSize: maxSizeBytes, files: MAX_FILES_PER_UPLOAD, fieldArrayIndexLimit: 0 },
fileFilter: (req, file, cb) => {
if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true);
return cb(new Error('This file type is not allowed'));
+12
View File
@@ -4,6 +4,7 @@ const { db } = require('../database/db');
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
const secureImageService = require('../services/secureImageService');
const galleryAccessService = require('../services/galleryAccessService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
@@ -58,6 +59,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
// Generate secure token with appropriate settings
const tokenOptions = {
galleryAccess: req.galleryAccess,
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
maxUses: accessType === 'download' ? 1 : 3,
clientFingerprint,
@@ -96,6 +98,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
});
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error generating secure token', {
error: error.message,
photoId: req.body.photoId,
@@ -151,6 +154,10 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Gallery not found' });
}
// Revalidate the issuing session, ownership and gallery lifecycle at
// every use, including capabilities minted before logout or restore.
await galleryAccessService.authorize(event, tokenValidation.data?.galleryAccess);
// Bind the token to the gallery + photo it was minted for
// (GHSA-g94x-8vv8-3c9f). This route serves via <img src> with the
// token in the URL, so it can't require verifyGalleryAccess like the
@@ -248,6 +255,7 @@ router.get('/:slug/secure/:photoId/:token',
res.send(processedImage);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving secure image', {
error: error.message,
photoId,
@@ -292,6 +300,8 @@ router.get('/:slug/secure-download/:photoId/:token',
return res.status(403).json({ error: 'Invalid or expired token' });
}
await galleryAccessService.authorize(req.event, tokenValidation.data?.galleryAccess);
// Bind the token to the photo it was minted for (GHSA-crxv) — the
// /secure serve route does this, but secure-download did not, so a
// token minted for photo A could download photo B (incl. a hidden one).
@@ -386,6 +396,7 @@ router.get('/:slug/secure-download/:photoId/:token',
res.send(fileBuffer);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving secure download', {
error: error.message,
photoId: req.params.photoId
@@ -414,6 +425,7 @@ router.get('/security/stats', adminAuth, requirePermission('settings.view'), asy
res.json(stats);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error getting security stats', { error: error.message });
res.status(500).json({ error: 'Failed to get security stats' });
}
@@ -1,283 +1,74 @@
/**
* Regression tests for issue #550.
*
* Two related bugs in POST /v1/events:
* 1. color_theme was not accepted on the request body and never written
* to the events row. Editing such an event later in the admin UI
* snapped the theme picker to GALLERY_THEME_PRESETS.default and
* saving overwrote whatever theme was inherited visually.
* 2. event_feedback_settings row was never created, so the gallery UI
* read it as "feedback off" regardless of the global
* event_default_feedback_enabled toggle (#520).
*
* Test pattern mirrors events.category.test.js queue up db() chains
* with db.__setImplementations() in the exact order the handler invokes
* them, then assert against the captured payloads.
*/
/** Persisted contracts, not a mock tied to the number/order of Knex calls. */
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../../../../__tests__/integration/helpers/crmDb');
const request = require('supertest');
const express = require('express');
const buildChain = ({ firstResult, insertResult, returningResult, selectResult } = {}) => {
const chain = {
where: jest.fn().mockReturnThis(),
whereIn: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
// `select` resolves to an array so `await db(...).whereIn(...).select(...)`
// gives an iterable result (used by the branding-defaults probe added in
// #592 follow-up). Tests that don't need it leave selectResult undefined
// and get `[]`, which is a safe no-op for any caller that iterates.
select: jest.fn().mockResolvedValue(selectResult ?? []),
first: jest.fn().mockResolvedValue(firstResult),
insert: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]),
};
return chain;
};
jest.mock('../../../database/db', () => {
const dbMock = jest.fn();
dbMock.raw = jest.fn();
dbMock.__setImplementations = (...chains) => {
dbMock.mockReset();
chains.forEach((chain) => {
dbMock.mockImplementationOnce(() => chain);
});
};
return {
db: dbMock,
logActivity: jest.fn().mockResolvedValue(undefined),
};
let db, cleanup, app, adminId, adminToken, apiToken;
const base = { event_type: 'wedding', event_name: 'Creation parity', event_date: '2030-06-15',
customer_name: 'Ada', customer_email: 'ada@example.test', admin_email: 'admin@example.test',
require_password: false, is_draft: false, expires_at: '2030-07-15T00:00:00.000Z' };
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb()); ({ adminId } = await seedMinimal(db)); await assignAdminRole(db, adminId);
adminToken = mintAdminToken(adminId);
const generated = require('../../../middleware/apiTokenAuth').generateApiToken(); apiToken = generated.plaintext;
await db('api_tokens').insert({ name: 'parity', hashed_token: generated.hashed, scopes: 'admin', created_by: adminId });
app = express(); app.use(express.json());
app.use('/admin', require('../../adminEvents'));
app.use('/v1', require('../events'));
}, 120000);
afterAll(async () => { await require('../../../services/serviceShutdown').stopServices(); await cleanup(); });
async function create(source, extra) {
const input = { ...base, ...extra };
if (source === 'legacy') return require('../../../services/eventService').createEvent(input, { actor: { id: adminId } });
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send(input);
expect(response.status).toBe(source === 'admin' ? 200 : 201);
return response.body;
}
it.each(['admin', 'v1', 'legacy'])('%s stores theme, owner, dates and feedback defaults through one use case', async source => {
const theme = JSON.stringify({ primaryColor: '#ff0066' });
const created = await create(source, { color_theme: theme, feedback_enabled: true });
const row = await db('events').where({ id: created.id }).first();
expect(row).toMatchObject({ color_theme: theme, created_by: adminId, event_name: base.event_name, customer_email: base.customer_email });
expect(require('../../../utils/dateNormalize').toIso(row.expires_at)).toBe(base.expires_at);
expect([false, 0]).toContain(row.require_password);
expect(row.updated_at).toBeTruthy(); expect(row.share_token).toBeTruthy(); expect(row.password_hash).toBeTruthy();
const feedback = await db('event_feedback_settings').where({ event_id: row.id }).first();
for (const key of ['feedback_enabled','allow_ratings','allow_likes','allow_comments','allow_favorites','allow_reactions','moderate_comments','show_feedback_to_guests']) expect([true, 1]).toContain(feedback[key]);
expect([false, 0]).toContain(feedback.allow_color_labels); expect(feedback.keybind_mode).toBe('colors');
});
// RBAC is enforced on these routes since GHSA-9697 (requirePermission), but
// this suite mocks the database, so a real permission lookup would 500. These
// tests cover route logic, not authorization — the intersection of token
// scopes and role permissions is pinned in __tests__/routes/v1EventOwnership.
jest.mock('../../../middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
userHasAnyPermission: async () => true,
userHasAllPermissions: async () => true,
}));
jest.mock('../../../middleware/apiTokenAuth', () => ({
apiTokenAuth: (req, _res, next) => {
req.apiToken = { id: 1, admin_id: 1, scopes: ['admin'] };
req.admin = { id: 1, username: 'token-admin' };
next();
},
requireApiScope: () => (_req, _res, next) => next(),
}));
// bcrypt.hash is awaited twice per request (real path + dummy path).
// Stub it to a constant so tests don't burn CPU on bcrypt rounds.
jest.mock('bcrypt', () => ({
hash: jest.fn().mockResolvedValue('$2b$10$mocked-hash'),
}));
jest.mock('../../../services/shareLinkService', () => ({
buildShareLinkVariants: jest.fn().mockResolvedValue({
shareUrl: 'https://example.test/gallery/some-slug?t=abc',
shareLinkToStore: '/gallery/some-slug?t=abc',
}),
}));
// Webhook fire is in a try/catch; stub to silence the predictable
// failure log so test output stays clean.
jest.mock('../../../services/webhookService', () => ({
fire: jest.fn().mockResolvedValue(undefined),
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 = () => {
const app = express();
app.use(express.json());
app.use('/', eventsRouter);
return app;
};
const BASE_BODY = {
event_name: 'Issue 550 Wedding',
event_type: 'wedding',
event_date: '2026-06-15',
require_password: false,
};
// db() call sequence for BASE_BODY (no feedback / devtools provided,
// require_password supplied so its probe is skipped, no customer_phone,
// no slug collision):
// 1. app_settings.where('event_default_feedback_enabled').first() (#550)
// 2. app_settings.where('enable_devtools_protection').first() (#592)
// 3. app_settings.whereIn([branding_logo_display_hero,...]).select(...) (#592 follow-up)
// Then slug probe, events insert, optional feedback insert.
const baseSettingsChains = () => [
buildChain({ firstResult: null }), // feedback default
buildChain({ firstResult: null }), // devtools default
buildChain({ selectResult: [] }), // image-security whereIn → empty rows (#1296)
buildChain({ selectResult: [] }), // branding whereIn → empty rows
];
describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('persists color_theme to the events row when provided', async () => {
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 42 }] });
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, color_theme: 'default' })
.expect(201);
const insertedRow = insertChain.insert.mock.calls[0][0];
expect(insertedRow).toMatchObject({
event_name: 'Issue 550 Wedding',
color_theme: 'default',
});
});
it('accepts a JSON-encoded theme string and persists it verbatim', async () => {
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 43 }] });
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
const customTheme = JSON.stringify({ primaryColor: '#ff0066' });
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, color_theme: customTheme })
.expect(201);
const insertedRow = insertChain.insert.mock.calls[0][0];
expect(insertedRow.color_theme).toBe(customTheme);
});
it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => {
// feedback_enabled provided → feedback probe SKIPPED. Sequence:
// 1. devtools probe
// 2. image-security probe (whereIn → select, #1296)
// 3. branding probe (whereIn → select)
// 4. slug probe
// 5. events insert
// 6. feedback sub-toggle defaults probe (whereIn → select, #1044)
// 7. event_feedback_settings insert
const devtoolsChain = buildChain({ firstResult: null });
const imageSecurityChain = buildChain({ selectResult: [] });
const brandingChain = buildChain({ selectResult: [] });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 50 }] });
const feedbackDefaultsChain = buildChain({ selectResult: [] });
const feedbackInsertChain = buildChain();
db.__setImplementations(
devtoolsChain, imageSecurityChain, brandingChain, slugChain, insertChain,
feedbackDefaultsChain, feedbackInsertChain,
);
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, feedback_enabled: true })
.expect(201);
expect(db).toHaveBeenNthCalledWith(7, 'event_feedback_settings');
const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0];
expect(feedbackRow).toMatchObject({ event_id: 50 });
// formatBoolean() returns 1/0 on SQLite and true/false on PG. Either
// way the value must be truthy/falsy in the right places — assert by
// coercion so the test stays driver-agnostic.
expect(Boolean(feedbackRow.feedback_enabled)).toBe(true);
expect(Boolean(feedbackRow.allow_ratings)).toBe(true);
expect(Boolean(feedbackRow.allow_likes)).toBe(true);
expect(Boolean(feedbackRow.allow_comments)).toBe(true);
expect(Boolean(feedbackRow.allow_favorites)).toBe(true);
// #1044: this insert used to omit allow_reactions entirely, so v1-created
// events only got reactions by accident of the column default.
expect(Boolean(feedbackRow.allow_reactions)).toBe(true);
// Colour labels are opt-in, so they stay off until the global is flipped.
expect(Boolean(feedbackRow.allow_color_labels)).toBe(false);
expect(feedbackRow.keybind_mode).toBe('colors');
expect(Boolean(feedbackRow.require_name_email)).toBe(false);
expect(Boolean(feedbackRow.moderate_comments)).toBe(true);
expect(Boolean(feedbackRow.show_feedback_to_guests)).toBe(true);
});
it('honours the event_default_feedback_enabled global when body omits feedback_enabled', async () => {
// Feedback probe returns serialized "true" → fallback kicks in and
// the feedback insert runs. Sequence: feedback probe, devtools probe,
// image-security probe (#1296), branding probe, slug, insert, sub-toggle
// defaults probe (#1044), feedback insert (8 calls total).
const feedbackProbe = buildChain({
firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' },
});
const devtoolsChain = buildChain({ firstResult: null });
const imageSecurityChain = buildChain({ selectResult: [] });
const brandingChain = buildChain({ selectResult: [] });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 51 }] });
const feedbackDefaultsChain = buildChain({ selectResult: [] });
const feedbackInsertChain = buildChain();
db.__setImplementations(
feedbackProbe, devtoolsChain, imageSecurityChain, brandingChain, slugChain,
insertChain, feedbackDefaultsChain, feedbackInsertChain,
);
await request(buildApp())
.post('/events')
.send(BASE_BODY)
.expect(201);
expect(db).toHaveBeenNthCalledWith(8, 'event_feedback_settings');
expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1);
});
it('does NOT create a feedback row when global setting is unset and body omits feedback_enabled', async () => {
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 52 }] });
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
await request(buildApp())
.post('/events')
.send(BASE_BODY)
.expect(201);
// 6 db() calls: feedback + devtools + image-security + branding probes,
// slug, insert. event_feedback_settings is never touched.
expect(db).toHaveBeenCalledTimes(6);
expect(db).not.toHaveBeenCalledWith('event_feedback_settings');
});
it('rejects non-boolean feedback_enabled with 400', async () => {
// Validators run before any db() call, so no chain queueing needed.
await request(buildApp())
.post('/events')
.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();
});
it('inherits global feedback and preserves explicit overrides for every entry point', async () => {
await db('app_settings').insert({ setting_key: 'event_default_feedback_enabled', setting_value: 'true', setting_type: 'boolean' })
.onConflict('setting_key').merge({ setting_value: 'true' });
for (const source of ['admin', 'v1', 'legacy']) {
const inherited = await create(source, {});
expect(await db('event_feedback_settings').where({ event_id: inherited.id }).first()).toBeTruthy();
const override = await create(source, { feedback_enabled: false });
expect(await db('event_feedback_settings').where({ event_id: override.id }).first()).toBeUndefined();
}
});
it('queues a publication email only for published galleries', async () => {
const draft = await create('admin', { is_draft: true });
expect(await db('email_queue').where({ event_id: draft.id })).toHaveLength(0);
const published = await create('v1', {});
expect(await db('email_queue').where({ event_id: published.id, email_type: 'gallery_created' })).toHaveLength(1);
});
it('rejects a required password that is missing with 400 on both routes', async () => {
for (const source of ['admin', 'v1']) {
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send({ ...base, require_password: true });
expect(response.status).toBe(400);
}
});
it('keeps accepting "0"/"1" string booleans on the v1 surface', async () => {
const created = await create('v1', { require_password: '0', feedback_enabled: '1' });
const row = await db('events').where({ id: created.id }).first();
expect([false, 0]).toContain(row.require_password);
expect(await db('event_feedback_settings').where({ event_id: row.id }).first()).toBeTruthy();
});
it.each([{ feedback_enabled: 'maybe' }, { event_type: 'unknown' }])('rejects invalid creation data before persistence: %j', async extra => {
for (const source of ['admin', 'v1']) {
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send({ ...base, ...extra });
expect(response.status).toBe(400);
}
});
+10 -260
View File
@@ -30,15 +30,13 @@ const { requireEventOwnership, scopeEventsQuery } = require('../../middleware/ow
// requirePermission gates supply the missing half; they key on req.admin.id,
// which apiTokenAuth populates.
const { requirePermission } = require('../../middleware/permissions');
const { resolveEventFeedbackDefaults } = require('../../services/feedbackDefaults');
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
const { buildShareLinkVariants } = require('../../services/shareLinkService');
const { generateThumbnail } = require('../../services/imageProcessor');
const logger = require('../../utils/logger');
const { slugify } = require('../../utils/slug');
const { formatBoolean } = require('../../utils/dbCompat');
const { parseBooleanInput } = require('../../utils/parsers');
const { getImageSecurityDefaults, resolveImageSecurityColumns, decodeSettingValue } = require('../adminEvents/helpers');
const { isValidEventType } = require('../../services/eventTypeService');
const { replacePhoto } = require('../../services/photoReplacementService');
const { getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings');
@@ -71,7 +69,9 @@ const photoStorage = multer.diskStorage({
});
const buildPhotoUpload = (maxFileSizeBytes) => multer({
storage: photoStorage,
limits: { fileSize: maxFileSizeBytes },
// CVE-2026-82333: single unnamed `photo` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: maxFileSizeBytes, fieldArrayIndexLimit: 0 },
fileFilter: (_req, file, cb) => {
if (/^image\//.test(file.mimetype)) cb(null, true);
else cb(new Error('Only image uploads are accepted on this endpoint'));
@@ -195,262 +195,12 @@ router.post(
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const {
event_name, event_type, event_date,
customer_name = null, customer_email = null, customer_phone = null,
admin_email = null,
require_password: requirePasswordInput,
password,
expires_at = null,
color_theme = null,
feedback_enabled: feedbackEnabledInput,
enable_devtools_protection: devtoolsInput,
hero_logo_visible: heroLogoVisibleInput,
hero_logo_size: heroLogoSizeInput,
hero_logo_position: heroLogoPositionInput
} = req.body;
// Issue #550 — mirror the admin POST path so API-created events
// pick up the global "Enable Guest Feedback by default" toggle
// (event_default_feedback_enabled). Without this, the UI reads
// a missing event_feedback_settings row as "feedback off"
// regardless of the admin's chosen default.
let feedbackEnabledFallback = false;
if (feedbackEnabledInput === undefined) {
const setting = await db('app_settings').where('setting_key', 'event_default_feedback_enabled').first();
if (setting) {
try {
const parsed = JSON.parse(setting.setting_value);
if (typeof parsed === 'boolean') feedbackEnabledFallback = parsed;
} catch { /* keep false */ }
}
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
// Issue #592 — same shape as the feedback fallback above. The
// events table column default is `true`, so without this an admin
// who disabled devtools detection globally still gets it ON for
// every API-created gallery. Mirrors adminEvents.js behaviour.
let devtoolsFallback = true;
if (devtoolsInput === undefined) {
const setting = await db('app_settings').where('setting_key', 'enable_devtools_protection').first();
if (setting) {
// Shared decoder: a legacy row can carry several layers of JSON
// quoting, and a single parse would leave the string 'false' here,
// reject it, and quietly enable protection the operator disabled.
const parsed = decodeSettingValue(setting.setting_value);
if (typeof parsed === 'boolean') devtoolsFallback = parsed;
}
}
const enable_devtools_protection = parseBooleanInput(devtoolsInput, devtoolsFallback);
// #1296 — same shape again, for the four Image Security settings that
// were stored and applied nowhere. Shared with the admin create route
// so a gallery's security level does not depend on which endpoint made
// it; #592 above is the bug this would otherwise repeat.
const imageSecurityColumns = resolveImageSecurityColumns(
req.body,
await getImageSecurityDefaults(),
);
// Same shape as the feedback / devtools fallbacks: honour the global
// event_default_require_password toggle (#317). Without this an admin
// who disabled "require password by default" globally still got
// password-required galleries through the API.
let requirePasswordFallback = true;
if (requirePasswordInput === undefined) {
const setting = await db('app_settings').where('setting_key', 'event_default_require_password').first();
if (setting) {
try {
const parsed = JSON.parse(setting.setting_value);
if (typeof parsed === 'boolean') requirePasswordFallback = parsed;
} catch { /* keep true */ }
}
}
const require_password = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
// Branding inheritance (Feature 7) — mirror adminEvents.js
// getBrandingDefaults so API-created events inherit the global
// hero logo visibility + size. hero_logo_position is intentionally
// NOT settings-backed (see migration 084 / #357 — branding_logo_position
// is the *header bar*, a different concept than the hero block).
let heroLogoVisibleFallback = true;
let heroLogoSizeFallback = 'medium';
const brandingRows = await db('app_settings')
.whereIn('setting_key', ['branding_logo_display_hero', 'branding_logo_size'])
.select('setting_key', 'setting_value');
for (const row of brandingRows) {
let value = row.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
if (row.setting_key === 'branding_logo_display_hero') heroLogoVisibleFallback = value !== false;
if (row.setting_key === 'branding_logo_size' && value) heroLogoSizeFallback = value;
}
const hero_logo_visible = heroLogoVisibleInput !== undefined ? heroLogoVisibleInput : heroLogoVisibleFallback;
const hero_logo_size = heroLogoSizeInput || heroLogoSizeFallback;
const hero_logo_position = heroLogoPositionInput || 'top';
if (require_password && (!password || password.length < 6)) {
return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' });
}
// Honour global phone-field toggle (#322).
let persistPhone = null;
if (customer_phone) {
const setting = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
const enabled = setting ? JSON.parse(setting.setting_value) === true : false;
persistPhone = enabled ? customer_phone : null;
}
// Generate unique slug.
const baseSlug = `${event_type}-${slugify(event_name)}-${event_date || crypto.randomBytes(3).toString('hex')}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) slug = `${baseSlug}-${counter++}`;
const shareToken = crypto.randomBytes(16).toString('hex');
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// password_hash is NOT NULL; use a random placeholder when no
// password is required so the column constraint is satisfied.
const bcrypt = require('bcrypt');
const passwordHash = require_password
? await bcrypt.hash(password, 10)
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10);
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
event_date: event_date || null,
host_name: customer_name,
host_email: customer_email,
admin_email,
password_hash: passwordHash,
// #1271 — recoverable copy rides with the hash; only when there is one
...(require_password && password ? await galleryPasswordColumns({ password }) : {}),
require_password,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at || null,
created_at: new Date().toISOString(),
created_by: req.admin.id,
is_draft: false,
// Issue #550 — without this, editing an API-created event in the
// admin UI snaps the theme picker to GALLERY_THEME_PRESETS.default
// and saving overwrites whatever theme was inherited visually.
color_theme,
// Issue #592 — write the resolved devtools setting (input value
// or global fallback) so the column default doesn't shadow it.
enable_devtools_protection: formatBoolean(enable_devtools_protection),
// Request value, else the global default, else the column default.
...imageSecurityColumns,
// Branding inheritance — resolved value from body or app_settings.
hero_logo_visible: formatBoolean(hero_logo_visible),
hero_logo_size,
hero_logo_position,
...(customer_name ? { customer_name } : {}),
...(customer_email ? { customer_email } : {}),
...(persistPhone ? { customer_phone: persistPhone } : {})
}).returning('id');
const id = insertResult[0]?.id || insertResult[0];
if (require_password && password) await dropCopiesIfStorageOff(id);
// Issue #550 — mirror adminEvents.js: create event_feedback_settings
// row when feedback is enabled, so the gallery actually shows feedback
// UI. The sub-flags come from the shared global defaults (#1044) rather
// than a hard-coded list, which is how this path silently shipped
// without allow_reactions for two releases.
if (feedback_enabled) {
const feedbackDefaults = await resolveEventFeedbackDefaults();
await db('event_feedback_settings').insert({
event_id: id,
feedback_enabled: formatBoolean(true),
allow_ratings: formatBoolean(feedbackDefaults.allow_ratings),
allow_likes: formatBoolean(feedbackDefaults.allow_likes),
allow_comments: formatBoolean(feedbackDefaults.allow_comments),
allow_favorites: formatBoolean(feedbackDefaults.allow_favorites),
allow_reactions: formatBoolean(feedbackDefaults.allow_reactions),
allow_color_labels: formatBoolean(feedbackDefaults.allow_color_labels),
keybind_mode: feedbackDefaults.keybind_mode,
require_name_email: formatBoolean(false),
moderate_comments: formatBoolean(true),
show_feedback_to_guests: formatBoolean(true),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
await logActivity('event_created', { via: 'api_v1', event_type }, id, {
type: 'admin', id: req.admin.id, name: req.admin.username
const created = await require('../../services/eventCreationService').createEvent(req.body, {
actor: req.admin, source: 'v1',
});
// Customer notifications (#647 follow-up). v1 events go live in the
// same call (not draft-aware), so the gallery_created email + WhatsApp
// fire here — mirroring the adminEvents.js create-and-publish path.
// Both are best-effort: a queue failure must not block the API response.
const expiryIso = expires_at ? new Date(expires_at).toISOString() : null;
if (customer_email) {
try {
const { queueEmail } = require('../../services/emailProcessor');
await queueEmail(id, customer_email, 'gallery_created', {
customer_name: customer_name || '',
customer_email,
host_name: customer_name || '',
event_name,
event_date: event_date || null,
gallery_link: shareUrl,
gallery_password: require_password ? password : 'No password required',
expiry_date: expiryIso,
welcome_message: ''
});
} catch (emailError) {
logger.warn('v1 POST /events: failed to queue gallery_created email', { error: emailError.message });
}
}
if (persistPhone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig();
if (waConfig && waConfig.enabled) {
await queueWhatsapp(id, persistPhone, 'gallery_created', {
customer_name: customer_name || '',
event_name,
gallery_link: shareUrl,
gallery_password: require_password ? password : '',
expiry_date: expiryIso,
language: null,
});
}
} catch (waError) {
logger.warn('v1 POST /events: failed to queue WhatsApp notification', { error: waError.message });
}
}
// Webhook lifecycle (#327). v1 events are not draft-aware, so they're
// both created AND published in the same call. Canonical event
// subject (#341) — customer contact + share_token always included.
try {
const webhookService = require('../../services/webhookService');
const eventSubject = webhookService.buildEventSubject({
id,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name,
customer_email,
customer_phone,
});
await webhookService.fire('event.created', { event: eventSubject });
await webhookService.fire('event.published', { event: eventSubject });
} catch (e) { /* non-fatal */ }
res.status(201).json({ id, slug, share_url: shareUrl, share_token: shareToken });
res.status(201).json({ id: created.id, slug: created.slug, share_url: created.share_link, share_token: created.share_token });
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json(error.responseBody || { error: error.message, code: error.code });
logger.error('v1 POST /events failed', { error: error.message, stack: error.stack });
res.status(500).json({ error: 'Failed to create event', detail: error.message });
}
@@ -1,6 +1,6 @@
const { DatabaseBackupService } = require('../databaseBackup');
const { db } = require('../../database/db');
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
// Mock dependencies
@@ -8,6 +8,10 @@ jest.mock('../../database/db');
jest.mock('../../utils/logger');
jest.mock('../emailProcessor');
jest.mock('child_process');
jest.mock('node-cron', () => ({ schedule: jest.fn(() => ({ stop: jest.fn() })) }));
const { DatabaseBackupService, startScheduledBackups, databaseBackupService, isUnderPubliclyServableRoot } = require('../databaseBackup');
const cron = require('node-cron');
describe('DatabaseBackupService', () => {
let service;
@@ -191,6 +195,213 @@ describe('DatabaseBackupService', () => {
});
});
describe('backup() destination path resolution (#1365)', () => {
// getBackupConfig() returns database_backup_*-prefixed keys.
// Regression: backup() used to destructure the unprefixed names
// (`destinationPath`, ...) straight off that object, which never
// matched, so the configured path was silently ignored and every
// run tried to create the hardcoded /backup/database default.
it('creates the directory from database_backup_destination_path when configured', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_destination_path', setting_value: JSON.stringify('/data/db-backups') }
])
});
const stop = new Error('stop after mkdir — nothing past it matters for this test');
const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop);
await expect(service.backup({})).rejects.toThrow(stop.message);
expect(mkdirSpy).toHaveBeenCalledWith('/data/db-backups', { recursive: true });
mkdirSpy.mockRestore();
});
it('falls back to /backup/database only when nothing is configured', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([])
});
const stop = new Error('stop after mkdir');
const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop);
await expect(service.backup({})).rejects.toThrow(stop.message);
expect(mkdirSpy).toHaveBeenCalledWith('/backup/database', { recursive: true });
mkdirSpy.mockRestore();
});
});
describe('isUnderPubliclyServableRoot (GHSA-jw8m class, #1365)', () => {
const originalStoragePath = process.env.STORAGE_PATH;
const storage = '/tmp/picpeak-test-storage';
beforeEach(() => {
process.env.STORAGE_PATH = storage;
});
afterAll(() => {
if (originalStoragePath === undefined) {
delete process.env.STORAGE_PATH;
} else {
process.env.STORAGE_PATH = originalStoragePath;
}
});
it.each([
path.join(storage, 'uploads', 'logos'),
path.join(storage, 'uploads', 'logos', 'sub'),
path.join(storage, 'uploads', 'favicons'),
path.join(storage, 'fonts'),
path.join(storage, 'fonts', 'inter'),
// Bundled fallback fonts — nodejs-owned per the Dockerfile's
// COPY --chown, and served at the same public /fonts route.
path.resolve(__dirname, '../../../assets/fonts'),
// Case-insensitive-but-preserving filesystems (APFS, NTFS, Docker
// Desktop bind mounts of either) resolve this to the same directory
// as uploads/logos even though path.resolve() never folds case.
path.join(storage, 'UPLOADS', 'Logos')
])('flags %s as publicly servable', (candidate) => {
expect(isUnderPubliclyServableRoot(candidate)).toBe(true);
});
it.each([
path.join(storage, 'backups'),
path.join(storage, 'uploads', 'contracts', 'signed'),
path.join(storage, 'uploads', 'transfers', '123'),
'/data/db-backups'
])('does not flag %s', (candidate) => {
expect(isUnderPubliclyServableRoot(candidate)).toBe(false);
});
it('backup() refuses a destination inside a publicly servable root without ever calling mkdir', async () => {
const publicPath = path.join(storage, 'uploads', 'logos');
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_destination_path', setting_value: JSON.stringify(publicPath) }
])
});
const mkdirSpy = jest.spyOn(fs, 'mkdir');
await expect(service.backup({})).rejects.toThrow('publicly served directory');
expect(mkdirSpy).not.toHaveBeenCalled();
mkdirSpy.mockRestore();
});
it('flags FRONTEND_DIR — the all-in-one image serves its built SPA unauthenticated', () => {
const originalFrontendDir = process.env.FRONTEND_DIR;
process.env.FRONTEND_DIR = '/app/frontend/dist';
try {
expect(isUnderPubliclyServableRoot('/app/frontend/dist')).toBe(true);
expect(isUnderPubliclyServableRoot(path.join('/app/frontend/dist', 'assets'))).toBe(true);
} finally {
if (originalFrontendDir === undefined) delete process.env.FRONTEND_DIR;
else process.env.FRONTEND_DIR = originalFrontendDir;
}
});
it('resolves a symlinked alias of a public root to the same real directory (all-in-one /app/storage -> /data/storage)', async () => {
const os = require('os');
const realRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-real-'));
const linkRoot = path.join(os.tmpdir(), `picpeak-link-${process.pid}-${Date.now()}`);
await fs.mkdir(path.join(realRoot, 'uploads', 'logos'), { recursive: true });
await fs.symlink(realRoot, linkRoot, 'dir');
try {
// STORAGE_PATH (what the guard's roots are built from) is the real
// path; the attacker-supplied destination goes through the symlink
// — exactly the all-in-one image's /app/storage -> /data/storage.
process.env.STORAGE_PATH = realRoot;
const aliased = path.join(linkRoot, 'uploads', 'logos');
expect(isUnderPubliclyServableRoot(aliased)).toBe(true);
} finally {
await fs.unlink(linkRoot);
await fs.rm(realRoot, { recursive: true, force: true });
}
});
});
describe('startScheduledBackups (#1365)', () => {
// Same key-mismatch bug as backup(): getBackupConfig() returns
// database_backup_*-prefixed keys, but this read `config.enabled` /
// `config.schedule` / `config.retentionDays` — always undefined, so
// the scheduler silently treated every install as disabled.
it('does not start the schedule while database_backup_enabled is false', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_enabled', setting_value: 'false' }
])
});
await startScheduledBackups();
expect(cron.schedule).not.toHaveBeenCalled();
});
it('starts the schedule with the configured cron when database_backup_enabled is true', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
{ setting_key: 'database_backup_schedule', setting_value: JSON.stringify('0 4 * * *') }
])
});
await startScheduledBackups();
expect(cron.schedule).toHaveBeenCalledWith('0 4 * * *', expect.any(Function));
});
it('re-reads retention on every tick instead of the value captured at schedule start (#1365)', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
{ setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(30) }
])
});
await startScheduledBackups();
const tick = cron.schedule.mock.calls[0][1];
// A /config update between schedule-start and this tick raised
// retention to 365 — the closed-over 30 must not be what runs.
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
{ setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(365) }
])
});
jest.spyOn(databaseBackupService, 'backup').mockResolvedValue({ success: true });
const cleanupSpy = jest.spyOn(databaseBackupService, 'cleanupOldBackups').mockResolvedValue(undefined);
await tick();
expect(cleanupSpy).toHaveBeenCalledWith(365);
jest.restoreAllMocks();
});
});
describe('cleanupOldBackups destructive-retention guard (#1365)', () => {
it.each([-1, 0, NaN, Infinity])('refuses retentionDays=%s without touching the database', async (bad) => {
const dbSpy = jest.fn();
db.mockImplementation(dbSpy);
await service.cleanupOldBackups(bad);
expect(dbSpy).not.toHaveBeenCalled();
});
});
describe('cleanupOldBackups', () => {
it('should delete old backup files and records', async () => {
const oldBackups = [
@@ -0,0 +1,125 @@
jest.mock('../../utils/logger');
jest.mock('fluent-ffmpeg');
jest.mock('../storage', () => ({
getStorage: jest.fn()
}));
jest.mock('../imageProcessor', () => ({
generateVideoPlaceholder: jest.fn(),
DEFAULT_THUMBNAIL_WIDTH: 300,
DEFAULT_THUMBNAIL_HEIGHT: 300
}));
const ffmpeg = require('fluent-ffmpeg');
const { getStorage } = require('../storage');
const { generateVideoPlaceholder } = require('../imageProcessor');
const {
extractVideoMetadata,
processUploadedVideo
} = require('../videoProcessor');
describe('extractVideoMetadata (#1370)', () => {
afterEach(() => jest.clearAllMocks());
it('returns null duration rather than 0 when ffprobe has none, so "unknown" and "a real 0s clip" stay distinguishable', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
cb(null, {
streams: [{ codec_type: 'video', width: 1920, height: 1080, codec_name: 'hevc' }],
format: {} // no duration field at all
});
});
const metadata = await extractVideoMetadata('/tmp/video.mp4');
expect(metadata.duration).toBeNull();
expect(metadata.width).toBe(1920);
expect(metadata.videoCodec).toBe('hevc');
});
it('floors a real duration', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
cb(null, { streams: [], format: { duration: 12.9 } });
});
const metadata = await extractVideoMetadata('/tmp/video.mp4');
expect(metadata.duration).toBe(12);
});
});
describe('processUploadedVideo degrades gracefully instead of rejecting the whole video (#1370)', () => {
let storage;
beforeEach(() => {
storage = { putFromFile: jest.fn().mockResolvedValue(undefined), exists: jest.fn().mockResolvedValue(true) };
getStorage.mockReturnValue(storage);
generateVideoPlaceholder.mockResolvedValue('thumbnails/thumb_placeholder.jpg');
});
afterEach(() => jest.clearAllMocks());
it('keeps the thumbnail when only metadata extraction fails', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => cb(new Error('moov atom not found')));
ffmpeg.mockImplementation(() => ({
screenshots: jest.fn(function screenshots({ filename, folder }) {
require('fs').writeFileSync(require('path').join(folder, filename), 'jpeg-bytes');
return this;
}),
on(event, handler) {
if (event === 'end') setImmediate(handler);
return this;
}
}));
const result = await processUploadedVideo('/tmp/video.mp4', 'thumbnails/thumb_video.jpg');
expect(result.success).toBe(true);
expect(result.metadata).toBeNull();
expect(result.thumbnailKey).toBe('thumbnails/thumb_video.jpg');
// A real thumbnail already succeeded — never touch the placeholder path.
expect(generateVideoPlaceholder).not.toHaveBeenCalled();
});
it('falls back to the SVG placeholder when thumbnail generation fails, so the gallery never falls back to rendering the raw video as an <img> (codex review)', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
cb(null, {
streams: [{ codec_type: 'video', width: 1080, height: 1920, codec_name: 'h264' }],
format: { duration: 5.4 }
});
});
ffmpeg.mockImplementation(() => ({
screenshots() { return this; },
on(event, handler) {
if (event === 'error') setImmediate(() => handler(new Error('ffmpeg seek failed')));
return this;
}
}));
const result = await processUploadedVideo('/tmp/video.mp4', 'thumbnails/thumb_wedding_001.jpg');
expect(result.success).toBe(true);
expect(result.metadata).toEqual(expect.objectContaining({ duration: 5, videoCodec: 'h264' }));
// thumbnailKey is always thumbnails/thumb_<name>.jpg — strip the prefix
// back to a filename so generateVideoPlaceholder recomputes the same key.
// Explicit width/height so generateVideoPlaceholder skips its DB-backed
// settings lookup — this can run inside an open per-file SQLite
// transaction (chunked video upload), where that lookup deadlocks.
expect(generateVideoPlaceholder).toHaveBeenCalledWith('wedding_001.jpg', { width: 300, height: 300 });
expect(result.thumbnailKey).toBe('thumbnails/thumb_placeholder.jpg');
expect(storage.putFromFile).not.toHaveBeenCalled();
});
it('throws when metadata, thumbnail generation, AND the placeholder all fail, so the caller surfaces a retryable failure instead of completing with nothing to show (codex review)', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => cb(new Error('Invalid data found when processing input')));
ffmpeg.mockImplementation(() => ({
screenshots() { return this; },
on(event, handler) {
if (event === 'error') setImmediate(() => handler(new Error('ffmpeg seek failed')));
return this;
}
}));
generateVideoPlaceholder.mockRejectedValue(new Error('sharp render failed'));
await expect(processUploadedVideo('/tmp/corrupt.mp4', 'thumbnails/thumb_corrupt.jpg'))
.rejects.toThrow('Unable to generate any thumbnail');
});
});

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