Compare commits

...

62 Commits

Author SHA1 Message Date
Paul Nothaft ce6dbdab56 chore(main): release 3.131.7-beta.0 (#1424)
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Successful in 54m28s
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/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/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
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-backend (linux/amd64, ubuntu-latest) (push) Successful in 56m46s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Successful in 13m23s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / smoke-aio (push) Failing after 11m48s
Release Please (Beta) / release-please (push) Failing after 1m37s
Release Please (Beta) / whatsnew (push) Waiting to run
Fresh-install smoke / fresh-install (push) Failing after 8m59s
Tests / backend (push) Failing after 1s
Schema drift (#530) / upgrade-from-bootstrap (push) Failing after 11m15s
Tests / nginx (nginx:1.28-alpine) (push) Failing after 17s
Tests / nginx (nginx:1.30-alpine) (push) Failing after 13s
Tests / ml (push) Failing after 2m29s
Tests / frontend (push) Failing after 19m6s
2026-09-11 13:43:55 +00:00
Paul Nothaft 7cd7654f99 fix(admin): keep header-style tiles from overflowing their cards (#1422)
Size the header-style and divider grids from their container width and wrap long translated labels within each card.
2026-09-11 15:37:21 +02:00
Paul Nothaft 415497ad06 chore(main): release 3.131.6-beta.0 (#1420)
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-11 11:42:49 +00:00
Paul Nothaft 98d25601b4 fix(gallery): cap how many cached zips rebuild at once in the background (#1418)
invalidateAll() invalidates every event holding a cached zip, and each
invalidate() arms its own debounce timer in the same tick — so they all fired
together and every one of them started building at once. Each build opens its
own storage reads, so flipping a global setting across 25 events was enough to
exhaust the S3 agent pool and stall uploads, thumbnails and gallery reads until
the burst drained. Nothing capped it.

Background rebuilds now run two at a time. The cap is deliberately only on that
path: a foreground generateZip — a guest actually waiting on a download — is
never queued behind a settings-change burst, which would trade one stall for
another.

stop() drains anything parked for a slot, so shutdown cannot hang on a queue
that will never move.

This is the second half of the problem. The first half — an individual build
opening one unbounded read per photo — is the storage-read guard that already
landed for the guest download routes and the job builder; the cached-zip
builder's own copy is still in an open PR.

Relates to issue 1399

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 13:36:27 +02:00
Paul Nothaft 2a04137a75 chore(main): release 3.131.5-beta.0 (#1419)
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-11 11:34:31 +00:00
Trung-Tin Pham f094cc06a7 fix(gallery): stop the pre-zip build leaking storage reads (#1402)
Building the download-all archive opened one storage read per photo and
handed each stream to archiver, which uses them one at a time. Every read
past the one being written parked an S3 socket with a full receive buffer,
and both early exits walked away from all of them: archiver's abort() does
not touch its source streams, and the error path only removed the temp
directory. Nothing else reclaims those sockets either, because the SDK arms
its socket timeout on a 3s delay and clears it as soon as the response
headers land. On a live server 43 of the 50 pooled sockets ended up stuck
for days and photo uploads stopped completing, with nothing logged.

The common way in is an ordinary upload. invalidate() runs on every photo
upload, delete and bulk edit, and it aborted an in-flight build.

Track every open read and destroy them on every exit path, cancel the
in-flight build from invalidate() rather than waiting for the loop to reach
its next version check, and cap reads in flight at 2. A build of 120 photos
peaked at 50 concurrent GETs before, the whole agent pool, which starved
uploads and thumbnails on its own.

Only S3 deployments are affected. Local filesystem installs take the
archive.file() branch and open no sockets.
2026-09-11 13:28:09 +02:00
Paul Nothaft ed7ddd1e60 chore(main): release 3.131.4-beta.0 (#1413)
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-11 08:53:37 +00:00
Paul Nothaft 70f5a8c54e fix(gallery): bound and reclaim storage reads in the remaining zip builders (#1410)
* fix(gallery): bound and reclaim storage reads in the remaining zip builders

PR 1402 fixes the cached-zip builder. The same unguarded pattern — one storage
read appended per photo, archiver draining one at a time, nothing destroying
the rest — is still present in three other places, two of which a gallery guest
reaches with no admin credentials:

- routes/gallery/downloads.js, download-all and download-selected
- services/downloadJobService.js, the custom-resolution job builder, which is
  worse in one respect: its per-photo catch skips a bad source without ever
  destroying the stream it had already opened, so every skipped photo leaked a
  socket for the life of the process

An unread S3 response body holds its socket open indefinitely — the SDK arms
its socket timeout on a 3s delay and clears it the moment response headers
land, so a fast response never gets one — and archiver's abort() does not touch
its source streams. That is the mechanism behind the incident described in PR
1402: 43 of 50 pooled sockets held with unread bytes, uploads and gallery reads
starved behind them, a process restart the only way out.

utils/archiveStreamGuard.js caps reads in flight at 2 and destroys whatever is
still open on every exit: an error, a failed append, and — for the two guest
routes — the client closing the tab mid-download, which previously left every
appended-but-undrained read parked forever.

Deliberately does not touch downloadZipService.js, so there is no conflict with
1402. Once that lands, its inline equivalent can move onto this helper.

Local-filesystem installs are unaffected either way: they take archiver's
archive.file(path) branch and open no sockets.

Relates to issue 1399

* fix(gallery): survive a cancelled download and a read that dies while queued

Two failures found reviewing the previous commit, both reachable on an
ordinary download.

A client hanging up mid-download aborted the archive, but the append loop's
`break` still fell through to archive.finalize(), which rejects with ABORTED.
The catch then called errorResponse over a response whose ZIP headers had
already gone out, throwing ERR_HTTP_HEADERS_SENT from an async Express 4
handler with nothing to catch it — an unhandled rejection, on a cancelled
download, which can take the process down. Both bulk routes now return without
finalizing, and the catch stays quiet once headers are sent.

A read that errored while still QUEUED behind another was absorbed by the
guard's own error listener. archiver had not attached its source listener yet,
so it never learned the stream had died, and the dead stream stayed in the
queue: the archive hung when it reached it, and in downloadJobService the build
held its concurrency slot with it. The guard now reports such a failure through
onFatalError so the caller aborts the archive and reclaims the rest. Streams the
guard destroyed itself are deliberately not reported — that is the caller's own
teardown, already running.

Relates to issue 1399

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:42:22 +02:00
Trung-Tin Pham 7c0c5c1cda fix(upload): let the csrf gate pass application/octet-stream chunks (#1401)
The chunked upload route reads the raw request body and its only client
sends application/octet-stream, but the CSRF gate on /api rejected every
such request with 415 before it reached the route. The endpoint had never
accepted a chunk.

The gate's origin check is the CSRF defence. Its Content-Type list only
has to keep out what a cross-site page can send without a preflight, and
octet-stream is not one of those: a form cannot produce it and fetch()
with it is not CORS-safelisted. Accept it. express.json leaves an
octet-stream body unread, so JSON routes see an empty body as before.

Fixes PicPeak/picpeak#1377
2026-09-11 10:42:07 +02:00
Paul Nothaft 4622478e44 fix(upload): stop buffering a chunk body before anything checks its size (#1406)
* fix(upload): stop buffering a chunk body before anything checks its size

The chunk route drained the whole request into an array and concatenated it
before calling uploadChunk — which is where every check lives. So a 300MB body
sent against an unknown upload id was read in full, cost ~300MB of heap, and
was only then answered with an error. The per-file cap was real but applied
after the damage, and nothing looked at Content-Length at all.

uploadChunk now takes the request stream itself and consumes nothing until the
upload id, the chunk index and the declared Content-Length have all been
checked against the remaining allowance. A body that clears those is streamed
straight to the chunk file under a hard byte cap, so a sender that lies about
its length — or sends none, the Transfer-Encoding: chunked case — is cut off
at the allowance instead of being read to the end.

A Buffer is still accepted, so the existing callers and chunkedUploadSizeCap
tests are untouched. Worth noting the old code silently did nothing when
handed a stream: fs.promises.writeFile accepts an async iterable, so the body
was written while `chunkData.length` was undefined and the cap comparison was
NaN > max, i.e. always false.

Reachable only for an authenticated admin holding photos.upload on an event
they own, and only once the CSRF gate lets application/octet-stream through.

Relates to issue 1403

* fix(upload): harden the chunk stream against abort, retry and cap failures

Three failure paths the streaming rewrite introduced, all found by external
review. None existed in the buffered version: the async iterator it replaced
rejected a dead request on its own, and never opened the chunk file until it
already held the whole body.

- An already-destroyed request hung the call forever. If the client hangs up
  while auth and ownership are awaiting the database, pipe() emits neither
  `end` nor `error`, so the promise never settled and the write descriptor
  stayed open. Checked up front now, alongside `aborted` and a `close` without
  `readableEnded` for a body cut short mid-flight.

- A failed re-send destroyed the chunk it was replacing. createWriteStream
  truncates on open, so re-sending an index and then failing left
  receivedChunks and chunkSizes still claiming the old copy: status reported
  100% and completeUpload died on ENOENT. Chunks are staged through a sibling
  .part file and renamed only on success.

- Tripping the cap stopped the 413 from reaching the client. `source` is the
  IncomingMessage, so destroying it destroyed the socket under the response
  and the client saw a connection reset instead of the size-limit JSON. The
  read is paused instead, which is all the cap needs.

Relates to issue 1403

* fix(upload): isolate chunk staging per attempt and close before cleanup

Two races found by a second review round, both in the staging logic added by
the previous commit.

- Two in-flight sends of the same chunk index shared one `.part` path, so
  whichever renamed first published bytes the other had already truncated. An
  acknowledged 10-byte chunk could end up 2 bytes. The staging suffix is now
  per-attempt rather than per-index.

- Unlinking the partial file raced the write stream's pending open(). destroy()
  does not await it, so the unlink failed with ENOENT and the open then
  recreated the `.part` file after cleanup had supposedly finished — reported
  reproducible in 121 of 300 immediately-failing streams. Cleanup now waits for
  the stream to close.

Relates to issue 1403

* fix(upload): revalidate the per-file cap before publishing a chunk

`allowance` is computed before the body arrives, so a chunk that completed
while this one was still streaming was not counted in it. Two overlapping
0.75MB chunks under a 1MB cap were therefore both accepted, leaving 1.5MB on
disk; enough concurrent streams could go well past the cap before anyone asked
to complete the upload.

The buffered version got this right for free, because it only ever checked
after reading the whole body. The streaming version keeps its pre-read check —
that is what makes a too-large request cheap — and asks again with the current
aggregate before renaming the staging file into place.

Relates to issue 1403

* fix(upload): answer client-caused chunk states with their own status code

An unknown, finished or expired upload id, and a complete call with chunks
missing, were plain Errors with no statusCode, so both routes fell through to
the blanket 500 and logged at error level. All four are the client's mistake:
they read as a backend fault in monitoring and invite a retry that can never
succeed.

They now carry 404, 409, 410 and 400 respectively, and both routes pass a
tagged status through instead of matching on the two they happened to know
about. Only genuinely unexpected errors reach the 500 and the error log.

No client is affected: uploadLargeFile, the only caller of this endpoint
family, still has no callers of its own.

Relates to issue 1403

* fix(upload): retire the connection after an early refusal, clean up a failed publish

Two more from review.

Refusing a body before reading it is the point of the streaming cap, but the
unread bytes are still in flight on a connection the response advertises as
keep-alive. Node does not drain them, so the NEXT request on that socket hangs
until it times out — reproducible with an 8MB body against a 1MB cap. The
error response now sets Connection: close whenever the request was not read to
the end.

A failed rename — ENOSPC, a vanished directory — left the fully written
staging file behind. Staging names are per-attempt, so a client that retries
instead of aborting accumulates one per try until the upload expires. The
partial is removed on that path too.

Relates to issue 1403

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:41:50 +02:00
Paul Nothaft f92d4bb2d9 fix(gallery): let an admin preview a draft through its short share URL (#1405)
fix(gallery): keep an admin draft preview out of the guest share-login flow

Making verify-token pass for a draft preview opened a path that did not exist
before it: the gallery bootstrap then called shareLinkLogin, which refuses a
draft AND records a failed login attempt against the caller's IP while doing
it. Five preview opens inside the attempt window therefore locked share-link
logins out for that IP — including for real guests, and including after the
gallery was published.

An admin preview does not need a guest session at all. The admin cookie plus
admin_preview=1 already authorizes every gallery call, which is exactly how
preview works on a published gallery, so the preview path loads the gallery
directly and never touches the login endpoint.

Deliberately not fixed by relaxing shareLinkLogin's draft check: that endpoint
mints a guest token, and a draft should not be handing those out.

Relates to issue 1386

fix(gallery): let an admin preview a draft through its short share URL

/info has honoured admin_preview since issue 868, but two sibling routes on the
short-URL path never did:

- GET /resolve/:identifier filtered drafts out through ACTIVE_EVENT_FILTER
  (shareLinkService.js), with no escape for a verified admin.
- GET /:slug/verify-token/:token repeated the same filter inline, so clearing
  the first would only have moved the 404 one step later.

With "use short gallery URLs" off the View Gallery link carries the slug,
GalleryPage never calls /resolve, and the preview worked. With it on the link
is the token form, GalleryPage resolves it first, and the draft answered
"Gallery Not Found".

resolveShareIdentifier takes an includeDrafts option, and /resolve reaches for
it only after the published lookup misses AND verifyAdminPreview accepts the
caller — so the published path keeps its single query and an unverified caller
never learns the draft exists. The frontend already sends admin_preview=1
(EventDetailsHeader.tsx:203, forwarded by config/api.ts:81); only the backend
had to change.

GHSA-rh8r's rule is unchanged and now pinned by test: a bare slug lookup still
never returns share_token, draft or not.

Relates to issue 1386
2026-09-11 10:41:25 +02:00
Paul Nothaft 1080388f28 fix(gallery): keep videos playable under enhanced and maximum protection (#1404)
Once an event left `standard` protection, both halves of the video path were
routed through /api/secure-images, and neither half can carry a video.

galleryQueryService emitted the secure template as a video's `url`. The
lightbox drops that straight into a <video> element; nothing substitutes the
`{{token}}` placeholder (the helper that could, secureToken.service.ts, has no
importers), so the request answered 403 "Invalid or expired token". Even with a
valid token it would still have failed — the secure-images route pipes every
byte through secureImageService.processProtectedImage, which calls sharp() and
throws on an mp4. routes/gallery/media.js bounced the JWT route to that same
endpoint before reaching its own video branch, so there was no way through.

Videos now keep the JWT route at every protection level, on both sides. That is
not a new exposure: thumbnails of those same videos have always been served
from it, and a valid gallery token is still required to reach it. Still images
are unaffected and keep bouncing to the secure endpoint.

VideoPlayer had no `error` listener, so all of this rendered as a poster frozen
at "0:00 / 0:00" behind a play button that did nothing — indistinguishable from
a codec the browser cannot decode, which is the other common cause (HEVC/H.265
phone footage plays in Safari and nowhere else). It now surfaces the failure
and names the codec case, since the answer there is to download the file.

Relates to issue 1370

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:41:17 +02:00
Paul Nothaft 316bcbd679 fix(backend): contain and sanitize the SQLite restore source path (#1384)
The restore flow accepted an unvalidated database.backup_file from
the manifest (absolute paths and traversal both worked, and no
containment check enforced the configured backup root), then
interpolated it unescaped into a `sqlite3 .restore '<path>'` command,
letting an attacker-chosen source file replace the live database.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:40:54 +02:00
Paul Nothaft ec03089d57 fix(backend): validate the S3 endpoint host before the restore download (#1383)
* fix(backend): validate the S3 endpoint host before the restore download

downloadFileFromS3() built an S3StorageAdapter and called .download()
directly, skipping the isHostAllowed() private-IP/DNS-rebinding guard
that testConnection() applies elsewhere — an admin with backup.restore
could point the configured S3 endpoint at an internal/metadata address
for unauthenticated egress via the server.

* fix(backend): pin the restore S3 download to its validated DNS resolution

isHostAllowed() was check-then-connect: the AWS SDK re-resolves the
endpoint hostname independently when it actually connects, so a DNS
rebinding condition between the preflight check and the real
connection could still reach a private/internal address. Reuses the
same pinnedRequestOptions() primitive webhookDeliveryWorker.js already
uses, wired into the S3Client's requestHandler.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:40:50 +02:00
Paul Nothaft b798d8e4c1 fix(backend): use the strong password generator for resets and enforce must_change_password (#1387)
Admin password reset generated a ~2^21-entropy password from a small
wordlist instead of the already-available generateSecurePassword(16),
and must_change_password was written on reset but never checked by
any route-blocking logic — a reset user could keep using the old
session/password indefinitely.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:40:46 +02:00
Paul Nothaft 59ea83c84e fix(backend): require actor to hold every permission of a role they grant (#1378)
* fix(backend): require actor to hold every permission of a role they grant

Any admin with `users.edit` could grant an arbitrary non-super_admin
role — including one carrying far more permissions than they
themselves hold — via PUT /api/admin/users/:id. The role-change path
never called the existing assertActorMayGrant() guard that already
protects role create/edit.

* fix(backend): apply the same role-grant guard to admin invitations

createInvitation() only blocked granting super_admin — the same
users.create-holder-can-invite-into-any-role escalation that
updateAdminUser() was fixed for (GHSA-rv8w-m6mx-7j4q) was still open
via POST /admin/users/invite. Reuses assertActorMayGrant().

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:40:42 +02:00
Paul Nothaft 38b0e1d584 fix(backend): validate event id before using it in the logo storage filename (#1382)
* fix(backend): validate event id before using it in the logo storage filename

The multer filename callback built the stored path directly from
req.params.id with no integer validation, letting a traversal payload
in the route param escape the intended uploads/logos/events/
directory — most directly reachable via a super_admin session, since
requireEventOwnership short-circuits with no DB lookup for that role.

* fix(backend): validate contract id before using it in the signed-PDF storage filename

Same pattern as the event-logo fix (GHSA-9q5j-vqfw-32hr) in a
different file adminContracts.js never touched: multer's filename
callback ran before express-validator's :id check, letting a
traversal payload escape uploads/contracts/signed/.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:40:38 +02:00
Paul Nothaft abc960170b fix(backend): validate business-profile logo uploads by content, not filename (#1381)
* fix(backend): validate business-profile logo uploads by content, not filename

The upload route skipped the shared validateFileType() helper every
sibling upload route uses, and derived the stored extension from the
client-supplied filename. A file could declare an image MIME type
while carrying an executable/HTML extension and arbitrary content,
then be served same-origin via the mass-assignable logoPath field.

* fix(backend): content-sniff business-profile logo uploads too

fileFilter paired the claimed MIME type against the extension but
never verified the actual bytes matched, unlike other upload routes
that already call validateFileContent(). Defense-in-depth: the
extension-confusion XSS itself was already closed (stored extension
is derived from the validated MIME, not client input), this closes
the remaining gap where declared-vs-actual content can still diverge.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:40:34 +02:00
Paul Nothaft cdde937d7f fix(backend): reject a replayed TOTP code within its validity window (#1389)
* fix(backend): reject a replayed TOTP code within its validity window

verifyTotp() was stateless — otplib's window:1 tolerance meant the
same 6-digit code could complete two independent logins inside its
~90s validity window. Track each admin's last-consumed step and
reject a code that doesn't advance past it.

* fix(backend): make the TOTP replay-tracking persist atomic

verifyTotpEncryptedStep() read two_factor_last_used_step, then a plain
UPDATE wrote the new step with no conditional guard — two concurrent
requests carrying the same captured code could both pass the check
before either UPDATE landed. The persist is now a conditional UPDATE
(only advances the step, checked via affected-row count), so a losing
concurrent request is correctly treated as a replay.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:40:27 +02:00
Paul Nothaft e3247911a0 fix(backend): shorten payment-check token TTL and notify admin on use (#1385)
The unauthenticated payment-check magic link (intentional, matches
publicQuotes.js) had a 30-day token lifetime and wrote to the invoice
ledger silently. Shortened the TTL and added a best-effort admin
notification on every write via this route, so the no-login
convenience stays but an admin always sees the action happen.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:40:24 +02:00
Paul Nothaft e290207934 fix(backend): enforce event ownership on short URL deletion (#1379)
GET and POST for an event's short URLs both required
requireEventOwnership; DELETE only checked events.edit permission,
letting any admin holding that permission delete another tenant's
branded gallery short URL. Resolve the short URL's event first, then
apply the same ownership check the other routes use.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:40:20 +02:00
Paul Nothaft f6b81fabf0 fix(backend): bump sharp, nodemailer, multer, js-yaml, joi for security fixes (#1374)
* 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.

* 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.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:40:17 +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
Paul Nothaft d36585379f assets: add vectorized logo mark as SVG (traced from picpeak-kamera-transparent.png) (#1348) 2026-09-07 22:47:50 +02:00
Paul Nothaft 7e9a61c245 chore(main): release 3.130.0-beta.0 (#1347)
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-07 20:36:18 +00:00
Paul Nothaft 810801a9ab fix(events): drop non-canonical keys from the event update before any check runs (#1346)
PUT /admin/events/:id spreads the body into the UPDATE. SQLite resolves
quoted identifiers case-insensitively, so `{ "Event_Name": ... }` lands
on event_name there — while every check in the handler (validators, the
field-level permission guards, the deny-set) keys on the exact lowercase
name. The deny-set already case-folded for its own columns; every other
column was reachable through a spelling variant.

Every events column and every input-only key the handler accepts is
lowercase snake_case, so a key with any uppercase in it is not something
a legitimate client sends. Such keys are now removed before anything
looks at the body. Postgres was unaffected (quoted identifiers are
case-sensitive there; a variant produced a 500 instead).

Surfaced by the Codex review of the folder-watcher change, where a
photos.upload guard on external_watch could be walked around this way.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-07 22:30:13 +02:00
Paul Nothaft 8cc7d7d14a feat(external-media): watch reference folders and import new files automatically (#1345)
* feat(external-media): watch reference folders and import new files automatically

Managed uploads dropped into storage/events/active are picked up by the
chokidar watcher; external media had no equivalent, so a NAS folder that
keeps growing needed an admin to open the event and press Import every
time. Relates to issue 1187.

- The import pass moves out of the route into
  services/externalImportService.js. The watcher and the Import button
  now run the identical function; the route only validates and maps
  errors to status codes.
- Mutual exclusion is the per-event claim from maintenanceJobState
  (`external_import:<id>`, seeded on demand by the new ensure()) instead
  of the in-process Set. The Set stopped a double-click in one process;
  the claim also stops the watcher on a second replica, or an admin
  clicking while the watcher is mid-run elsewhere. The run heartbeats so
  a claim from a dead process is taken over.
- services/externalMediaWatcher.js: per-event opt-in via the new
  events.external_watch column (migration 208), chokidar with
  awaitWriteFinish so a copy in flight is not imported half-written,
  debounced full pass per change, a timer sweep every 15 minutes as the
  fallback for NFS/SMB mounts that deliver no inotify events, optional
  stat-polling via EXTERNAL_MEDIA_WATCH_POLLING. The set of watched
  events is re-read every minute, so the toggle works from any replica.
  A watcher that just started runs one pass immediately.
- Deletions are ignored on purpose: a file vanishing from a NAS is at
  least as likely to be a reorganisation or a dropped mount as an
  intentional removal, and acting on it would delete a guest-visible
  photo. Rows whose file is gone stay, as they do today.
- Not gated on STORAGE_BACKEND: EXTERNAL_MEDIA_ROOT is always local.
- Quiet system passes stay out of the activity log; runs that imported
  something are logged with actor external-media-watcher.
- Frontend: "Watch folder for new files" checkbox under the external
  folder picker, status line in view mode, EN/DE strings.

* fix(external-media): close the review gaps in the folder watcher

Codex review of the watcher, round 1. All six findings were real:

- Enabling the watcher, or pointing an enabled one at another folder,
  now requires photos.upload — the permission the manual Import already
  requires. events.edit alone was a way around it. Only the transition
  is checked, so a role without photos.upload can still edit an
  already-watched event. The checkbox is disabled for such roles.
- Automatic passes defer files that are still changing: anything
  modified inside the stability window, or whose size moves across one
  wait of that window, is left for the next pass. chokidar's
  awaitWriteFinish only settles the file that fired the event, and the
  sweep sees no events at all, so a sibling still being copied could be
  inserted half-written and then skipped forever.
- Photos an admin deleted are not brought back by the sweep. The delete
  routes record the file in external_import_exclusions (migration 209);
  automatic passes skip the list, the manual Import ignores it and
  clears it for what it imports.
- The six EXTERNAL_MEDIA_WATCH* variables are forwarded in all three
  compose files; they were documented but the backend services use
  explicit environment lists, so the kill switch did nothing.
- A pass re-checks is_active / is_archived at run time, not only in the
  minutely reconcile.
- The lease is renewed on a timer for the whole run, walk included, and
  ownership is checked before the event row is touched.

* fix(external-media): make automatic passes follow the row, not rewrite it

Codex review round 2, four findings, all applied:

- The event update route drops non-canonical spellings of external_watch
  and external_path before the permission guard. SQLite resolves column
  names case-insensitively, so `External_Watch` reached the column while
  the guard only looked at the lowercase key.
- Exclusions are checked per file at insert time, not against a
  snapshot taken before the settle wait. A photo deleted during the wait
  was present in the snapshot and got re-inserted by the loop.
- An automatic pass no longer writes source_mode / external_path. It
  re-reads the row after the walk and the settle wait and stops if the
  folder changed or the event went managed; the manual Import is the
  only writer. The options are now `automatic` + `settleMs`.
- A pass that deferred files re-arms the debounced import, so a file
  copied just before the watcher started is not stranded when the sweep
  is disabled.

* fix(external-media): keep exclusions for replaced photos, stop a pass whose event stopped qualifying

Codex review round 3, both findings applied:

- recordExclusions keys on external_relpath alone. A replaced external
  photo becomes managed but keeps its relpath on purpose, and deleting
  that replacement must not republish the NAS original.
- An automatic pass checks the full watcher predicate (reference mode,
  same folder, watch on, active, not archived) before it inserts and on
  every heartbeat tick during the loop, and stops as soon as the event
  no longer qualifies.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-07 22:29:55 +02:00
Paul Nothaft 1b3f721d10 chore(main): release 3.129.0-beta.0 (#1344)
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 / 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
2026-09-07 18:47:26 +00:00
Paul Nothaft d6a0c4aff5 feat(cms): keep the editor toolbar in reach on long pages (#1335)
On the CMS page the editor has no bounded height, so a long document
scrolls the whole admin content area and the toolbar scrolled away with
it. Editing a 16-section privacy policy meant scrolling back to the top
for every heading or list.

The top toolbar block (mode/save row and formatting row) is now
`sticky top-0` from the md breakpoint up, pinned to the admin page's
scroller. The rounded wrapper clips with `overflow-clip` instead of
`overflow-hidden`, because hidden turns the wrapper into a scroll
container and the toolbar would pin to that instead of to the page. Not
below md: there the formatting row wraps to several lines and a
permanently stuck block would eat most of a phone's editing area.

Two things follow from pinning. The link-entry row moves inside the
sticky block: rendered below it, the URL field sat at the toolbar's
original document position, under the pinned toolbar. And ProseMirror's
selection scrolling gets a top threshold and margin sized from the
block's rendered height (ResizeObserver, re-applied through
editor.setOptions), because the formatting row wraps to two rows at
common desktop widths and the link row comes and goes; a constant would
leave the caret behind the toolbar half the time. Below md the offsets
are zero again.

Verified in Chromium against the CMS page with an 18-section document:
scrolled to the last sections, the toolbar stays at the top of the
content area; on main it is gone. A source-level test pins the sticky
block, the wrapper's clip, the link row's placement and the measured
offsets, since jsdom does not lay out.

Relates to issue 1289

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-07 20:41:29 +02:00
Paul Nothaft fb9da72f14 feat(security): opt-in recoverable gallery passwords (#1341)
* feat(security): opt-in recoverable gallery passwords

Gallery passwords are bcrypt hashes, so an admin who needs to hand a
password to a client a second time has to reset it, which invalidates
what the client already has. This adds a security setting,
security_gallery_password_recoverable, off by default, that keeps an
AES-256-GCM encrypted copy of each gallery password and client PIN next
to the hash. The key is derived from GALLERY_PASSWORD_ENCRYPTION_KEY or
JWT_SECRET.

While the setting is on:
- create, publish, send-later, edit, reset and the v1 API write the copy
  alongside the hash; turning a gallery's password requirement off
  clears it
- GET /api/admin/events/:id/password returns the copy to admins with
  events.edit and ownership, and writes a gallery_password_viewed
  activity entry on every real reveal
- resend-email uses the stored password instead of the "set at creation"
  sentinel, so the client receives what already works

Switching the setting off purges every stored copy. Login and hash
verification are untouched; the copy is never read on the gallery side.

The Security tab carries the toggle with a warning that stays visible,
and the event page shows "Show password" with copy buttons only while
the setting is on and the gallery has a secret.

Relates to issue 1271

* fix(security): close the write-versus-switch-off race in the password vault

The recoverable setting is read while an event insert is assembled and the
client-PIN hash awaits after that, so a settings request that switched the
feature off and purged in that gap was overtaken by the insert. Every write
site now re-reads the setting right after its statement and clears its own
row when the setting is off; the settings writer flips the value before it
purges, so either the purge or the re-check catches the row.

* fix(security): resend carries the stored client PIN and link; deterministic tamper test

The creation mail includes the client-access link and PIN; a resend only
sent the gallery password even when a stored PIN was available. The
ciphertext tamper assertion replaced the last two characters with a
constant, which was a no-op roughly once in 4096 runs.

* fix(security): drop the revealed password after Send gallery email

The send-later route can replace the password; the share card keys its
revealed copy on the event query's refetch time, so invalidate the event
after the send like the other password-changing mutations do.

* fix(security): purge leftovers before the setting write when turning recovery on

Switching on wrote the setting first and purged after, so a password write
that read the new "on" in between stored a copy the purge then deleted.
Turning on now purges before the write; turning off keeps purging after it,
which together with the write-site re-check leaves the vault holding
exactly what was written while the setting was on.

* chore(security): drop the duplicate rateLimitService import left by the rebase

* chore(usage): register the password recovery routes in the v5 coverage inventory

The inventory moved from v4 to v5 on main; the entry added by this branch
followed it.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-07 20:36:48 +02:00
Paul Nothaft 16b79ee119 chore(main): release 3.128.0-beta.0 (#1343)
Build and Push Docker Images / build-backend (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 / 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 / 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-07 18:10:48 +00:00
Paul Nothaft 5c1e38d921 feat(usage): distinguish real edits and template delivery with v5 consent (#1339)
* feat(usage): distinguish real edits and template delivery with v5 consent

* fix(usage): exclude queued test messages and count reorders as edits

- queueEmail carries usageEligible: false into email_data and the queue
  processor passes it on, so the dev tools' send-test-email no longer
  records email_template_delivery once the worker sends it.
- event-types/reorder and categories/reorder-global compare the persisted
  order before and after and record the v5 edit markers only when it
  changed, matching the display_order edit already counted on PUT.
- normalized() builds arrays with Array.from so a row array from the sqlite
  binding compares equal under Jest's separate realm.

* fix(usage): cover per-gallery category order and workflow test runs

- categories/reorder records category_editing when an event's override
  changes; reorder/:eventId records it when an override was actually
  removed.
- send_email and the collections handoff pass usageEligible: false for a
  workflow test run (engine.testRun sets __test), so a non-dry test send is
  not counted as template delivery.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-07 20:03:15 +02:00
Paul Nothaft 69754f8a2c fix(email): scrub gallery passwords from the sent-mail archive (#1340)
* fix(email): scrub gallery passwords from the sent-mail archive

The email queue kept every gallery password and client PIN in clear
text in email_data and rendered_html after the mail was sent, and the
Messages reading pane handed them back to any admin with the messaging
flag. A password hash in the events table bought nothing while the
plaintext sat next to it.

Once a mail is out, or its retries are exhausted, the processor now
masks secret-looking variables (password, passcode, pin) in email_data
and replaces their values in the rendered body, plain and HTML-escaped.
The reading pane applies the same masking to rows archived before this
change. Pending rows keep the real values so a retry still sends them.

Relates to issue 1271

* fix(email): keep a quoted ">" from cutting an attribute value out of redaction

The tag splitter stopped at the first ">", so a template attribute such as
title="{{gallery_password}} > details" left the password unmasked in the
archived HTML while email_data was already masked. The tokenizer is now
quote-aware; a tag with an unbalanced quote falls through as text and is
scrubbed there.

* fix(email): scrub secrets inside HTML comments in the archived body

A comment such as <!-- PIN: {{client_password}} --> was split off as a tag
and its body, which has no attribute, was never scrubbed. Comments are now
one segment and their content is masked whole.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-07 20:02:57 +02:00
Paul Nothaft 8017370271 feat(settings): expose the API rate limiter in the Security tab (#1338)
* feat(settings): expose the API rate limiter in the Security tab

The general per-IP limiter had six settings in app_settings and a
backend route to write them, and no screen. Installs ran on the code
fallback — 300 requests per 15 minutes per IP — with no way to see it,
which is how issue 1287 played out: a 546-photo gallery exhausted the
budget for one viewer and the operator learned about the setting from
a grep of the backend log.

Security tab: a card with the six settings, the validation ranges the
route enforces, a one-line explanation per field, and a note that the
unit is the client IP — an office or household behind one NAT shares
a budget, and behind a proxy TRUST_PROXY has to cover the proxy or
every visitor shares its address. The tab's Save button saves the
limiter through its own route. The limiter values are checked against
the route's ranges before anything is written and the limiter is
written first, so a rejected value cannot leave the password/session
settings half-saved behind a failure toast.

Backend, three things the screen needed:

- The settings read fills the six keys with the code defaults when
  they have no row, so the form shows the budget in force rather than
  an empty field; the defaults live in one exported constant the
  limiter itself reads.
- The write route upserts instead of updating: on a fresh install,
  which has no rows, the old UPDATE matched nothing and the route
  answered 200 while changing nothing.
- The live limiter instances move into rateLimitService and the
  write route rebuilds them. express-rate-limit fixes windowMs when an
  instance is built — max and skip re-read the settings per request,
  the window does not — so a saved window used to apply only after a
  restart. The gates in server.js resolve the instance per request
  through the service's getters. A rebuild starts fresh counters,
  which on a settings change is acceptable. The limiters get explicit
  MemoryStores and a rebuild shuts the superseded ones down, because a
  store keeps a cleanup interval alive for as long as it exists and
  dropping the reference alone would leak one timer per save.

Tests: the read surfaces defaults and honours the key filter; the
write creates rows on a fresh database, the limiter sees the values
immediately and hands the gates a fresh instance; existing rows are
updated not duplicated; out-of-range values are rejected. The tab
renders the values, edits through the hook state, carries the ranges,
saves with the tab's button, and the pre-write validation accepts the
bounds and rejects outside them and cleared fields.

Relates to issue 1337

* docs(security): point the rate limiter doc at the Security tab and the upsert

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-07 20:02:40 +02:00
290 changed files with 24119 additions and 9934 deletions
+22
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.
#
@@ -178,6 +183,17 @@ VITE_API_URL=/api
# lower to 1 on very small hosts. Default: 2
# FILE_WATCHER_CONCURRENCY=2
# External-media folder watcher (issue 1187). Reference-mode events can opt in
# per event (Event → Source Mode → "Watch folder for new files"); new images in
# the folder are then imported without pressing Import. Deleted files are
# never removed from the gallery.
# EXTERNAL_MEDIA_WATCH=true # global kill switch
# EXTERNAL_MEDIA_WATCH_POLLING=false # true = stat-polling instead of inotify (NFS/SMB mounts)
# EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS=5000
# EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS=900000 # timer-driven pass over every watched event; 0 disables
# EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS=10000 # quiet period after the last change before the import runs
# EXTERNAL_MEDIA_WATCH_STABILITY_MS=5000 # how long a file must stop growing before it counts as written
# Release Channel
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
# 'stable' uses the :stable tag (same as :latest on main)
@@ -342,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.127.2-beta.0"
".": "3.131.7-beta.0"
}
+142
View File
@@ -5,6 +5,148 @@ 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.7-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.6-beta.0...v3.131.7-beta.0) (2026-09-11)
### Bug Fixes
* **admin:** keep header-style tiles from overflowing their cards ([#1422](https://github.com/PicPeak/picpeak/issues/1422)) ([7cd7654](https://github.com/PicPeak/picpeak/commit/7cd7654f99967080724d7498b72bea4986ae825d))
## [3.131.6-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.5-beta.0...v3.131.6-beta.0) (2026-09-11)
### Bug Fixes
* **gallery:** cap how many cached zips rebuild at once in the background ([#1418](https://github.com/PicPeak/picpeak/issues/1418)) ([98d2560](https://github.com/PicPeak/picpeak/commit/98d25601b465ecc44d0875b52891073720fabbbc))
## [3.131.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.4-beta.0...v3.131.5-beta.0) (2026-09-11)
### Bug Fixes
* **gallery:** stop the pre-zip build leaking storage reads ([#1402](https://github.com/PicPeak/picpeak/issues/1402)) ([f094cc0](https://github.com/PicPeak/picpeak/commit/f094cc06a78e776795e60cb3b11e8653681a2f31))
## [3.131.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.3-beta.0...v3.131.4-beta.0) (2026-09-11)
### Bug Fixes
* **backend:** bump sharp, nodemailer, multer, js-yaml, joi for security fixes ([#1374](https://github.com/PicPeak/picpeak/issues/1374)) ([f6b81fa](https://github.com/PicPeak/picpeak/commit/f6b81fabf05ab0bbce48e63bbdf3812b30aa10de))
* **backend:** contain and sanitize the SQLite restore source path ([#1384](https://github.com/PicPeak/picpeak/issues/1384)) ([316bcbd](https://github.com/PicPeak/picpeak/commit/316bcbd67965ddec74801308a722b506bb8da265))
* **backend:** enforce event ownership on short URL deletion ([#1379](https://github.com/PicPeak/picpeak/issues/1379)) ([e290207](https://github.com/PicPeak/picpeak/commit/e290207934708f8bf41676aec39a43474f0f6172))
* **backend:** reject a replayed TOTP code within its validity window ([#1389](https://github.com/PicPeak/picpeak/issues/1389)) ([cdde937](https://github.com/PicPeak/picpeak/commit/cdde937d7fce130d67e331bd968cb2c4734902d4))
* **backend:** require actor to hold every permission of a role they grant ([#1378](https://github.com/PicPeak/picpeak/issues/1378)) ([59ea83c](https://github.com/PicPeak/picpeak/commit/59ea83c84efdcf6d488853a25ba05f1d15ef1150))
* **backend:** shorten payment-check token TTL and notify admin on use ([#1385](https://github.com/PicPeak/picpeak/issues/1385)) ([e324791](https://github.com/PicPeak/picpeak/commit/e3247911a071b5277d877a1fa0a39c544d3a03e8))
* **backend:** use the strong password generator for resets and enforce must_change_password ([#1387](https://github.com/PicPeak/picpeak/issues/1387)) ([b798d8e](https://github.com/PicPeak/picpeak/commit/b798d8e4c19541da6ee1f58c2756182817b6c716))
* **backend:** validate business-profile logo uploads by content, not filename ([#1381](https://github.com/PicPeak/picpeak/issues/1381)) ([abc9601](https://github.com/PicPeak/picpeak/commit/abc960170b1eac0fa1f3110015e8fce671d428ab))
* **backend:** validate event id before using it in the logo storage filename ([#1382](https://github.com/PicPeak/picpeak/issues/1382)) ([38b0e1d](https://github.com/PicPeak/picpeak/commit/38b0e1d5842217030e7dd48247e523ded4b19c58))
* **backend:** validate the S3 endpoint host before the restore download ([#1383](https://github.com/PicPeak/picpeak/issues/1383)) ([ec03089](https://github.com/PicPeak/picpeak/commit/ec03089d57c88ff0f7c21b6a4947ffb9ef9771f9))
* **gallery:** bound and reclaim storage reads in the remaining zip builders ([#1410](https://github.com/PicPeak/picpeak/issues/1410)) ([70f5a8c](https://github.com/PicPeak/picpeak/commit/70f5a8c54e096f70c030d63414d5954190cb68a8))
* **gallery:** keep an admin draft preview out of the guest share-login flow ([f92d4bb](https://github.com/PicPeak/picpeak/commit/f92d4bb2d9c2ea84f59dd4cfaa3a4272f1eec56b))
* **gallery:** keep videos playable under enhanced and maximum protection ([#1404](https://github.com/PicPeak/picpeak/issues/1404)) ([1080388](https://github.com/PicPeak/picpeak/commit/1080388f28b846553cd670c66e9632275eb9c994))
* **gallery:** let an admin preview a draft through its short share URL ([f92d4bb](https://github.com/PicPeak/picpeak/commit/f92d4bb2d9c2ea84f59dd4cfaa3a4272f1eec56b))
* **gallery:** let an admin preview a draft through its short share URL ([#1405](https://github.com/PicPeak/picpeak/issues/1405)) ([f92d4bb](https://github.com/PicPeak/picpeak/commit/f92d4bb2d9c2ea84f59dd4cfaa3a4272f1eec56b))
* **upload:** let the csrf gate pass application/octet-stream chunks ([#1401](https://github.com/PicPeak/picpeak/issues/1401)) ([7c0c5c1](https://github.com/PicPeak/picpeak/commit/7c0c5c1cda3921cbd9404dd77d64bdec7a9ae2aa))
* **upload:** stop buffering a chunk body before anything checks its size ([#1406](https://github.com/PicPeak/picpeak/issues/1406)) ([4622478](https://github.com/PicPeak/picpeak/commit/4622478e44d5e63da031a2f27c5a5c335282eacb))
## [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)
### Features
* **external-media:** watch reference folders and import new files automatically ([#1345](https://github.com/PicPeak/picpeak/issues/1345)) ([8cc7d7d](https://github.com/PicPeak/picpeak/commit/8cc7d7d14a93c5d4c397eaf928361c70810b7e0a))
### Bug Fixes
* **events:** drop non-canonical keys from the event update before any check runs ([#1346](https://github.com/PicPeak/picpeak/issues/1346)) ([810801a](https://github.com/PicPeak/picpeak/commit/810801a9ab5df49cf47cd7bf9446fe6a54e6808d))
## [3.129.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.128.0-beta.0...v3.129.0-beta.0) (2026-09-07)
### Features
* **cms:** keep the editor toolbar in reach on long pages ([#1335](https://github.com/PicPeak/picpeak/issues/1335)) ([d6a0c4a](https://github.com/PicPeak/picpeak/commit/d6a0c4aff52bab268799e9f7c2c5b62f2410c958))
* **security:** opt-in recoverable gallery passwords ([#1341](https://github.com/PicPeak/picpeak/issues/1341)) ([fb9da72](https://github.com/PicPeak/picpeak/commit/fb9da72f1402aa8aee7ce0e575907789ed9faf33))
## [3.128.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.127.2-beta.0...v3.128.0-beta.0) (2026-09-07)
### Features
* **settings:** expose the API rate limiter in the Security tab ([#1338](https://github.com/PicPeak/picpeak/issues/1338)) ([8017370](https://github.com/PicPeak/picpeak/commit/80173702712ffd2a8150d2f8326e1455fce1036a))
* **usage:** distinguish real edits and template delivery with v5 consent ([#1339](https://github.com/PicPeak/picpeak/issues/1339)) ([5c1e38d](https://github.com/PicPeak/picpeak/commit/5c1e38d921f7973633d6f9d12fde4c7588214d31))
### Bug Fixes
* **email:** scrub gallery passwords from the sent-mail archive ([#1340](https://github.com/PicPeak/picpeak/issues/1340)) ([69754f8](https://github.com/PicPeak/picpeak/commit/69754f8a2cc99eec318a310a60609894d757f515))
## [3.127.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.127.1-beta.0...v3.127.2-beta.0) (2026-09-07)
+6 -5
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
@@ -172,13 +172,14 @@ PicPeak runs on two long-lived branches:
| Branch | Role | What targets it |
|---|---|---|
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
| **`stable`** | Curated release channel. Production-recommended. | Urgent bugfix backports only — small, surgical PRs that land cleanly without dragging in unrelated changes. |
| **`stable`** | Curated release channel. Production-recommended. | Security fixes and regular bugfix backports, kept small and free of unrelated features. |
### Which branch should my PR target?
- **New feature** → target `main`.
- **Bugfix that ONLY affects active dev** → target `main`.
- **Bugfix that current stable users need** → open a small PR against `main`, AND a separate small PR against `stable` with the same change. Keep both surgical so each lands cleanly.
- **Bugfix that current stable users need** → target `main`; regular bug fixes are generally backported automatically to `stable`. Maintainers handle conflicts or create a separate focused backport PR when needed.
- **Security vulnerability** → report privately using [SECURITY.md](SECURITY.md). Security fixes are always released on both `stable` and `main`; coordinate any fix with the maintainers before opening a public PR.
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
@@ -196,6 +197,6 @@ See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteri
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
- Security issues: Open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
- Security vulnerabilities: Follow the [security policy](SECURITY.md) and use [private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
Thank you for contributing! 🎉
Thank you for contributing! 🎉
+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
+6 -1
View File
@@ -68,13 +68,18 @@ The actual mechanics, in order:
## Hotfix path (backport to current stable)
If a critical bug or security issue affects the current stable and `main` has moved too far for a full promotion to be appropriate, backport just the fix:
Regular bug fixes are generally backported automatically from `main` to `stable`. Keep backports focused on the fix, without unrelated features, and resolve conflicts manually when needed.
**Security fixes are always released on both `stable` and `main`.** Do not wait for a full promotion to deliver a security update. A fix first applied to `stable` must also be forward-ported to `main`; a fix first applied to `main` must also reach `stable`. See [SECURITY.md](SECURITY.md) for the support policy.
When a backport needs manual handling:
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
2. Cherry-pick or hand-write the minimal fix.
3. Open a PR to `stable` with the smallest possible diff.
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
6. For security fixes, verify that the fix has been published through **both** release channels; merging the code is only part of delivery.
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
+61 -68
View File
@@ -1,88 +1,81 @@
# Security Policy
## Scope
This policy covers the PicPeak backend, frontend, all-in-one (AIO) image, optional
ML component, and the Docker images published by the PicPeak project. Other
PicPeak repositories define their own supported versions and release channels.
## Supported Versions
We release patches for security vulnerabilities. Currently supported versions:
Security support follows the current release channels:
| Version | Supported |
| ------- | ------------------ |
| 2.x.x | :white_check_mark: |
| < 2.0 | :x: |
| Version or channel | Security support |
| --- | --- |
| Latest stable release from `stable` | Supported; security fixes are published through this channel |
| Latest beta release from `main` | Supported; security fixes are published through this channel |
| Superseded stable or beta releases | Upgrade to the latest release in the same channel; older releases are not maintained separately |
| 2.x and earlier | No longer supported |
See the [latest stable release](https://github.com/PicPeak/picpeak/releases/latest)
and [all releases, including betas](https://github.com/PicPeak/picpeak/releases).
Version numbers differ between channels; each channel receives its own updates.
### Security fixes and bug backports
**Security fixes are always released on both `stable` and `main`.** A fix that
lands on one branch must also reach the other branch and be published through
both release channels. Security updates do not wait for the next full
`main`-to-`stable` promotion.
Regular bug fixes are also generally backported automatically to `stable`.
Backports remain focused on the fix, without pulling in unrelated features.
Maintainers resolve conflicts or handle a backport manually when necessary.
The [release process](RELEASING.md) describes backports, forward-ports and
publication. Operators must apply the published updates to their installations.
## Reporting a Vulnerability
We take the security of PicPeak seriously. If you have discovered a security vulnerability, please follow these steps:
**Do not report vulnerabilities in public issues, discussions or pull requests.**
### 1. **Do NOT create a public GitHub issue**
Report privately through:
### 2. Report the vulnerability privately by:
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- **Alternative:** Email us at **info@picpeak.app** with the details
- Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
- [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) (preferred).
- Email **info@picpeak.app** if you cannot use GitHub's private reporting form.
### 3. You can expect:
- Acknowledgment within 48 hours
- Regular updates on our progress
- Credit in the fix announcement (unless you prefer to remain anonymous)
Include the affected component, version or image tag, deployment method,
reproduction steps, expected impact and any suggested fix. Share only the
information needed to reproduce the problem; remove credentials and personal
data from logs or examples.
## Security Measures
We aim to acknowledge reports within 48 hours. This is a response target, not a
guaranteed service level or a promised resolution time. We will provide progress
updates and coordinate disclosure with the reporter. Reporter credit is optional;
tell us if you prefer to remain anonymous.
PicPeak implements several security measures:
## Deployment Security
### Authentication & Authorization
- JWT-based authentication with secure token storage
- bcrypt password hashing with configurable rounds
- Role-based access control for admin functions
- Session timeout management
Security depends on both the software and its configuration. Operators should:
### Input Validation
- All user inputs are validated and sanitized
- SQL injection prevention through parameterized queries
- XSS protection via Content Security Policy
- File upload restrictions and validation
- Use HTTPS and configure the reverse proxy and trusted proxy settings correctly.
- Use strong credentials and keep deployment secrets private.
- Apply updates for the chosen release channel and restrict unnecessary network access.
- Keep backups and verify that they can be restored.
### Rate Limiting
- API rate limiting to prevent abuse
- Brute force protection on authentication endpoints
- Configurable limits per endpoint
### Data Protection
- HTTPS enforcement in production
- Secure cookie settings
- CORS configuration
- Sensitive data encryption
### Infrastructure
- Regular dependency updates
- Security headers (HSTS, X-Frame-Options, etc.)
- Activity logging for audit trails
- Automated backups
## Best Practices for Deployment
1. **Always use HTTPS** in production
2. **Change default passwords** immediately
3. **Keep dependencies updated** regularly
4. **Configure firewall rules** appropriately
5. **Monitor logs** for suspicious activity
6. **Backup regularly** and test restoration
See the deployment guides for [HTTPS](https://docs.picpeak.app/deployment/ssl-certificates),
[reverse proxies](https://docs.picpeak.app/deployment/reverse-proxy),
[security settings](https://docs.picpeak.app/guides/admin-settings/security)
and [backup and restore](https://docs.picpeak.app/guides/backup-restore).
## Vulnerability Disclosure
We believe in responsible disclosure. Once a vulnerability is fixed:
We coordinate disclosure with the reporter while preparing fixes. Security fixes
are published through both supported channels. Advisories and release notes
identify affected versions, the fixed version in each channel, the impact and
any required mitigation or upgrade steps. Reporter credit is included with
permission.
1. We'll publish a security advisory
2. Credit researchers (with permission)
3. Detail the impact and mitigation steps
4. Release patches for all supported versions
## Contact
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
Thank you for helping keep PicPeak and its users safe!
For ordinary bugs and support requests, use
[GitHub Issues](https://github.com/PicPeak/picpeak/issues) or
[GitHub Discussions](https://github.com/PicPeak/picpeak/discussions).
+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.
+11
View File
@@ -112,6 +112,17 @@ ARCHIVE_PATH=/app/storage/events/archived
# generation from exhausting memory on small hosts. Default: 2
# FILE_WATCHER_CONCURRENCY=2
# External-media folder watcher (issue 1187). Reference-mode events can opt in
# per event (Event → Source Mode → "Watch folder for new files"); new images in
# the folder are then imported without pressing Import. Deleted files are
# never removed from the gallery.
# EXTERNAL_MEDIA_WATCH=true # global kill switch
# EXTERNAL_MEDIA_WATCH_POLLING=false # true = stat-polling instead of inotify (NFS/SMB mounts)
# EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS=5000
# EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS=900000 # timer-driven pass over every watched event; 0 disables
# EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS=10000 # quiet period after the last change before the import runs
# EXTERNAL_MEDIA_WATCH_STABILITY_MS=5000 # how long a file must stop growing before it counts as written
# Analytics Backend Configuration (OPTIONAL)
# Used for server-side tracking only
# Primary configuration should be done through Admin UI > Settings > Analytics
@@ -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();
@@ -0,0 +1,195 @@
/**
* POST /api/admin/business-profile/logo and PUT /api/admin/business-profile
* — GHSA-6wrv-9pr4-hhmw regression coverage.
*
* The upload route used to take the stored file extension straight from
* the client-supplied filename and only checked `file.mimetype` against an
* allowlist — a file could declare an image MIME type while carrying a
* `.html`/`.js` extension and arbitrary content, land in the same-origin
* `/uploads/logos` static mount, and execute as script. The mass-assignable
* `logoPath` field on PUT compounded it: an attacker could point the
* "logo" at any other uploaded file.
*
* These tests pin:
* (a) a MIME/extension mismatch is rejected at upload,
* (b) the extension actually written to disk always matches the
* validated MIME type, never the client-supplied filename,
* (c) legitimate PNG/JPEG/SVG uploads still succeed,
* (d) `logoPath` on PUT cannot be set to an arbitrary string pointing at
* another file, only to a path the upload route itself produced.
*
* Defense-in-depth (not a re-opening of the above): fileFilter only pairs
* the claimed MIME type against the extension — it can't see the bytes,
* since it runs before multer finishes writing the stream to disk. A file
* whose declared MIME/extension pair is valid but whose actual content
* doesn't match (e.g. a PNG-declared upload that isn't really a PNG) is
* now caught by validateFileContent() (magic-number check) after multer
* writes it, closing the gap where declared-vs-actual content diverges.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bplogo-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bplogo-route-test-secret';
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
// Real magic-number-prefixed payloads, for content-sniffing to accept.
const REAL_PNG_BYTES = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
Buffer.from('not a real png body, but the header is real'),
]);
const REAL_JPEG_BYTES = Buffer.concat([
Buffer.from([0xFF, 0xD8, 0xFF]),
Buffer.from('not a real jpeg body, but the header is real'),
]);
describe('business profile — logo upload content/extension validation', () => {
let db;
let cleanup;
let app;
let token;
const uploadLogo = (buffer, filename, mimetype) => request(app)
.post('/api/admin/business-profile/logo')
.set('Authorization', `Bearer ${token}`)
.attach('logo', buffer, { filename, contentType: mimetype });
const put = (payload) => request(app)
.put('/api/admin/business-profile')
.set('Authorization', `Bearer ${token}`)
.send(payload);
const get = () => request(app)
.get('/api/admin/business-profile')
.set('Authorization', `Bearer ${token}`);
const profileOf = (res) => (res.body.data || res.body).profile;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = buildRouteApp('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
// fileFilter rejections surface via Express's generic error handler
// (the pre-existing behaviour of every sibling logo/favicon upload
// route in this codebase — none of them special-case multer's
// fileFilter `Error` into a 400 either), so the status code itself
// can be 400 or 500 depending on environment. What actually matters
// for GHSA-6wrv-9pr4-hhmw is that the request never succeeds and
// nothing with the dangerous extension is ever written to disk.
const logosDirFiles = () => {
const logosDir = path.join(process.env.STORAGE_PATH, 'uploads', 'logos');
return fs.existsSync(logosDir) ? fs.readdirSync(logosDir) : [];
};
it('rejects an HTML/script payload disguised as an image via mismatched extension', async () => {
const evil = Buffer.from('<script>alert(document.domain)</script>');
const res = await uploadLogo(evil, 'evil.html', 'image/svg+xml');
expect(res.status).not.toBe(200);
expect(logosDirFiles().some((f) => f.endsWith('.html'))).toBe(false);
});
it('rejects a .js file disguised with an image MIME type', async () => {
const evil = Buffer.from('alert(1)');
const res = await uploadLogo(evil, 'evil.js', 'image/png');
expect(res.status).not.toBe(200);
expect(logosDirFiles().some((f) => f.endsWith('.js'))).toBe(false);
});
it('rejects a disallowed MIME type outright', async () => {
const res = await uploadLogo(Buffer.from('whatever'), 'file.pdf', 'application/pdf');
expect(res.status).not.toBe(200);
expect(logosDirFiles().some((f) => f.endsWith('.pdf'))).toBe(false);
});
it('accepts a legitimate PNG upload and stores it with a .png extension', async () => {
const res = await uploadLogo(REAL_PNG_BYTES, 'logo.png', 'image/png');
expect(res.status).toBe(200);
const logoPath = (res.body.data || res.body).logoPath;
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.png$/);
const onDisk = path.join(process.env.STORAGE_PATH, logoPath.replace(/^\//, ''));
expect(fs.existsSync(onDisk)).toBe(true);
expect(profileOf(await get()).logoPath).toBe(logoPath);
});
it('accepts a legitimate JPEG upload and stores it with a .jpg extension', async () => {
const res = await uploadLogo(REAL_JPEG_BYTES, 'logo.jpg', 'image/jpeg');
expect(res.status).toBe(200);
const logoPath = (res.body.data || res.body).logoPath;
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.jpg$/);
});
it('rejects a PNG-declared upload whose bytes are not actually a PNG, and leaves nothing on disk', async () => {
const before = logosDirFiles();
const res = await uploadLogo(Buffer.from('totally not a png'), 'logo.png', 'image/png');
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/content does not match/i);
// No new file left behind: the rejected upload's own file was cleaned
// up, and every other file on disk (if any) is unchanged.
expect(logosDirFiles()).toEqual(before);
});
it('accepts a legitimate SVG upload and always stores it with a .svg extension, even under a spoofed filename', async () => {
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>');
// Client-declared filename ext is .svg here to pass validateFileType
// (mismatched ext is covered by the rejection tests above); the point
// of this test is that the ON-DISK extension comes from the MIME type
// lookup table, not path.extname(originalname).
const res = await uploadLogo(svg, 'vector-logo.svg', 'image/svg+xml');
expect(res.status).toBe(200);
const logoPath = (res.body.data || res.body).logoPath;
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.svg$/);
});
it('rejects logoPath on PUT set to an arbitrary string pointing at another file', async () => {
const before = profileOf(await get()).logoPath;
const res = await put({ logoPath: '/uploads/logos/cms-somepage-1234.png' });
expect(res.status).toBe(400);
expect(profileOf(await get()).logoPath).toBe(before);
});
it('rejects logoPath on PUT with a path-traversal payload', async () => {
const res = await put({ logoPath: '/uploads/logos/../../../../etc/passwd' });
expect(res.status).toBe(400);
});
it('accepts logoPath on PUT when it matches the pattern this route itself writes', async () => {
const upload = await uploadLogo(REAL_PNG_BYTES, 'logo2.png', 'image/png');
const uploadedPath = (upload.body.data || upload.body).logoPath;
// Round-trip: PUT-ing back the exact value the upload endpoint
// returned (what the frontend's generic profile save does) must
// keep working.
const res = await put({ logoPath: uploadedPath });
expect(res.status).toBe(200);
expect(profileOf(await get()).logoPath).toBe(uploadedPath);
});
it('still allows clearing logoPath with an empty string', async () => {
const res = await put({ logoPath: '' });
expect(res.status).toBe(200);
expect(profileOf(await get()).logoPath).toBe('');
});
});
@@ -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 () => {
@@ -0,0 +1,124 @@
/**
* GHSA-h4w8-57xq-53fx enforcement half: `must_change_password` was written
* by the admin password-reset flow (userManagementService.resetAdminPassword)
* and returned in a few response payloads, but no route-blocking logic ever
* checked it — a reset admin could keep using the old/weak password on every
* protected route indefinitely. adminAuth() is now the server-side backstop:
* a flagged admin gets 403 MUST_CHANGE_PASSWORD on everything except the
* routes they need to clear the flag (change-password) or leave (logout).
*
* Mirrors the mocking shape of adminAuthRoleFallback.test.js — a stub `db`
* chain, no real SQLite needed, so this stays a fast unit test.
*/
const jwt = require('jsonwebtoken');
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn().mockResolvedValue(false) }));
jest.mock('../../src/utils/logger', () => ({ warn: jest.fn(), error: jest.fn(), debug: jest.fn(), info: jest.fn() }));
let mockMustChangePassword = false;
const mockAdminRow = { id: 7, username: 'scoped', email: 's@example.com', password_changed_at: null, role_id: 1, role_name: 'editor' };
jest.mock('../../src/database/db', () => ({
db: () => ({
leftJoin() { return this; },
where() { return this; },
select() { return this; },
first: () => Promise.resolve({ ...mockAdminRow, must_change_password: mockMustChangePassword }),
}),
}));
const { adminAuth } = require('../../src/middleware/auth');
const SECRET = 'test-secret-for-must-change-password';
function makeReq(originalUrl) {
const token = jwt.sign(
{ id: mockAdminRow.id, type: 'admin' },
SECRET,
{ algorithm: 'HS256', issuer: 'picpeak-auth' },
);
return { headers: { authorization: `Bearer ${token}` }, ip: '127.0.0.1', connection: {}, originalUrl };
}
function makeRes() {
return {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
}
describe('adminAuth must_change_password enforcement (GHSA-h4w8-57xq-53fx)', () => {
const OLD_SECRET = process.env.JWT_SECRET;
beforeAll(() => { process.env.JWT_SECRET = SECRET; });
afterAll(() => { process.env.JWT_SECRET = OLD_SECRET; });
beforeEach(() => { mockMustChangePassword = false; });
it('blocks an arbitrary protected route with 403 MUST_CHANGE_PASSWORD when the flag is set', async () => {
mockMustChangePassword = true;
const req = makeReq('/api/admin/dashboard/stats');
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(403);
expect(res.body).toEqual(expect.objectContaining({ code: 'MUST_CHANGE_PASSWORD' }));
expect(req.admin).toBeUndefined();
});
it('does not block when the flag is not set', async () => {
mockMustChangePassword = false;
const req = makeReq('/api/admin/dashboard/stats');
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.admin.mustChangePassword).toBe(false);
});
it.each([
['/api/admin/auth/change-password'],
['/api/admin/auth/logout'],
])('still allows %s through when the flag is set', async (originalUrl) => {
mockMustChangePassword = true;
const req = makeReq(originalUrl);
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.admin.mustChangePassword).toBe(true);
expect(res.statusCode).toBeNull();
});
it('allows the exempt change-password path even with a query string', async () => {
mockMustChangePassword = true;
const req = makeReq('/api/admin/auth/change-password?foo=bar');
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).toHaveBeenCalled();
});
it('does not exempt a route that merely starts with the change-password path', async () => {
mockMustChangePassword = true;
const req = makeReq('/api/admin/auth/change-password-history');
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(403);
});
});
@@ -0,0 +1,75 @@
/**
* The chunked upload client posts each chunk as application/octet-stream and
* the route reads the raw request stream. The CSRF gate answered every such
* request 415 before it reached the route, so the endpoint never accepted a
* chunk (PicPeak/picpeak#1377).
*
* The gate's origin check is the CSRF defence. The Content-Type list only
* has to keep out what a cross-site page can send without a preflight, and
* application/octet-stream is not on that list: an HTML form cannot produce
* it and fetch() with it is not CORS-safelisted.
*/
const express = require('express');
const request = require('supertest');
jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }));
function buildApp() {
const app = express();
// Same order as server.js: scoped JSON parser, then the gate on /api.
app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' }));
app.use(express.json({ limit: '2mb' }));
app.use('/api', require('../../src/middleware/csrf'));
// Mirrors the chunk route in adminPhotos.js: consume the raw stream.
app.post('/api/admin/photos/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
res.json({ received: Buffer.concat(chunks).toString('base64') });
});
app.post('/api/admin/other', (req, res) => res.json({ body: req.body }));
return app;
}
const CHUNK_PATH = '/api/admin/photos/1/chunked-upload/abc/chunk/0';
describe('CSRF gate and application/octet-stream', () => {
it('lets a same-origin octet-stream chunk reach the route byte for byte', async () => {
// Not valid UTF-8, so a text decode anywhere on the path would show up.
const payload = Buffer.concat([Buffer.from('chunkbytes'), Buffer.from([0xff, 0x00, 0xfe])]);
const res = await request(buildApp())
.post(CHUNK_PATH)
.set('sec-fetch-site', 'same-origin')
.set('Content-Type', 'application/octet-stream')
.send(payload);
expect(res.status).toBe(200);
expect(Buffer.from(res.body.received, 'base64').equals(payload)).toBe(true);
});
it('still rejects a cross-site octet-stream post on origin', async () => {
const res = await request(buildApp())
.post(CHUNK_PATH)
.set('sec-fetch-site', 'cross-site')
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from('chunkbytes'));
expect(res.status).toBe(403);
});
it('still rejects the types a form can send', async () => {
const res = await request(buildApp())
.post('/api/admin/other')
.set('sec-fetch-site', 'same-origin')
.set('Content-Type', 'text/plain')
.send('x=1');
expect(res.status).toBe(415);
});
it('leaves a JSON route with an empty body on an octet-stream post', async () => {
const res = await request(buildApp())
.post('/api/admin/other')
.set('sec-fetch-site', 'same-origin')
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from('{"a":1}'));
expect(res.status).toBe(200);
expect(res.body.body).toEqual({});
});
});
@@ -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);
});
});
@@ -0,0 +1,126 @@
/**
* Same bug class as GHSA-9q5j-vqfw-32hr (fixed in adminEvents/logo.js) —
* the signed-PDF upload's multer `filename` callback built the stored
* path directly from `req.params.id` with no integer validation:
*
* filename: (req, file, cb) => {
* cb(null, `contract-${req.params.id}-${Date.now()}${ext}`);
* }
*
* `POST /:id/upload-signed-pdf` declares `param('id').isInt({ min: 1 })`,
* but express-validator's check only runs inside the route handler via
* validateRequest(req) — AFTER multer has already parsed the multipart
* body and invoked the filename callback. A traversal payload in the raw
* `:id` URL segment reaches multer completely unvalidated.
*
* Fixed by rejecting any non-positive-integer id before it is used to
* build the filename, independent of the declared-but-too-late
* express-validator check.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
// ALLOWED_MEDIA_TYPES in fileSecurityUtils.js only defines image/video
// entries, so the route's real fileFilter (validateFileType(..., ['application/pdf']))
// rejects every PDF upload with "Only PDF files are allowed" — a
// separate, pre-existing bug unrelated to the path-traversal fix under
// test here (also present in publicContracts.js, which is why neither
// suite exercises a successful upload). Stub validateFileType so this
// suite can drive the full route, including the filename-callback fix,
// end-to-end.
jest.mock('../../src/utils/fileSecurityUtils', () => {
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
return {
...actual,
validateFileType: (filename, mimetype, allowedTypes) => allowedTypes.includes(mimetype),
};
});
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-contracts-signed-pdf-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-contracts-signed-pdf-test-secret';
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
describe('POST /api/admin/contracts/:id/upload-signed-pdf — path traversal guard', () => {
let db; let cleanup; let app; let adminId; let customerId; let token;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
// Feature flag defaults OFF on a fresh install — the contracts
// router 403s every route until it's on.
await db('feature_flags').where({ key: 'contracts' }).update({ value: true });
app = buildRouteApp('/api/admin/contracts', require('../../src/routes/adminContracts'));
}, 120000);
afterAll(async () => { await cleanup(); });
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
const signedDir = () => path.join(process.env.STORAGE_PATH, 'uploads/contracts/signed');
async function insertContract(over = {}) {
const base = {
contract_number: `K-TEST-${Math.random().toString(16).slice(2, 8)}`,
customer_account_id: customerId,
title: 'Test Contract',
issue_date: new Date().toISOString().slice(0, 10),
status: 'sent',
language: 'de',
created_at: new Date().toISOString(),
...over,
};
const inserted = await db('contracts').insert(base).returning('id');
return inserted[0]?.id ?? inserted[0];
}
it('rejects a traversal payload in the id param instead of writing outside uploads/contracts/signed', async () => {
// '../../../../tmp/pwned' URL-encoded so the raw request path still
// has a single segment (matches Express's `:id`), but Express
// decodes the param back into literal '../' sequences before the
// route sees it.
const traversalId = encodeURIComponent('../../../../tmp/pwned');
const res = await auth(
request(app).post(`/api/admin/contracts/${traversalId}/upload-signed-pdf`)
).attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.body.error).toMatch(/invalid contract id/i);
// No file should have been written anywhere — the filename callback
// must error out before multer opens a write stream.
const escapedFile = path.join(os.tmpdir(), 'pwned');
expect(fs.existsSync(escapedFile)).toBe(false);
if (fs.existsSync(signedDir())) {
expect(fs.readdirSync(signedDir())).toHaveLength(0);
}
});
it('still accepts a normal numeric contract id', async () => {
const id = await insertContract();
const res = await auth(
request(app).post(`/api/admin/contracts/${id}/upload-signed-pdf`)
).attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(200);
const files = fs.readdirSync(signedDir());
expect(files.some((f) => f.startsWith(`contract-${id}-`))).toBe(true);
const row = await db('contracts').where({ id }).first();
expect(row.status).toBe('fully_signed');
expect(row.signed_pdf_path).toMatch(new RegExp(`contract-${id}-`));
});
});
@@ -0,0 +1,56 @@
/**
* The Messages reading pane must not serve passwords (see
* utils/emailSecretRedaction.js). Rows sent before the processor learned to
* scrub still carry the gallery password in email_data and rendered_html;
* the route redacts them on read.
*/
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-paneredact-')), 'db.sqlite');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'paneredact-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-paneredact-storage-'));
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp } = require('../integration/helpers/crmDb');
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
const { MASK } = require('../../src/utils/emailSecretRedaction');
describe('GET /admin/email/queue/:id redacts secrets from legacy rows', () => {
let db; let cleanup; let app; let token; let rowId;
const PASSWORD = 'Sunset-42!'; const PIN = 'Tom & Ada\'s 7788';
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
await db('feature_flags').insert({ key: 'messaging', value: true }).onConflict('key').merge({ value: true });
invalidateFeatureFlagCache();
const ins = await db('email_queue').insert({
recipient_email: 'client@example.com', email_type: 'gallery_created', status: 'sent',
created_at: new Date().toISOString(), sent_at: new Date().toISOString(), retry_count: 0,
email_data: JSON.stringify({ customer_name: 'Ada', gallery_password: PASSWORD, client_password: PIN, cc: ['second@example.com'] }),
rendered_html: `<ul><li>Password: ${PASSWORD}</li><li>PIN: Tom &amp; Ada&#39;s 7788</li></ul>`,
}).returning('id');
rowId = ins[0]?.id ?? ins[0];
app = buildRouteApp('/api/admin/email', require('../../src/routes/adminEmail'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('masks the password and the PIN in the rendered body, keeps the rest', async () => {
const res = await request(app).get(`/api/admin/email/queue/${rowId}`).set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.renderedHtml).not.toContain(PASSWORD);
expect(res.body.renderedHtml).not.toContain('Ada&#39;s 7788');
expect(res.body.renderedHtml).toContain(`Password: ${MASK}`);
expect(res.body.renderedHtml).toContain(`PIN: ${MASK}`);
expect(res.body.cc).toBe('second@example.com');
expect(JSON.stringify(res.body)).not.toContain(PASSWORD);
// the stored row is untouched by a read
const row = await db('email_queue').where('id', rowId).first();
expect(row.rendered_html).toContain(PASSWORD);
});
});
@@ -0,0 +1,212 @@
/**
* Opt-in recoverable gallery passwords (#1271).
*
* Off by default: nothing reversible is stored, the view route says so, and
* resend falls back to the security sentinel. On: every path that hashes a
* gallery or client password keeps an encrypted copy, the view route returns
* it and logs the reveal, resend uses it unchanged, disabling the gallery
* password clears it, and switching the setting off purges every copy.
*/
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-recover-')), 'db.sqlite');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'recover-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-recover-storage-'));
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
const vault = require('../../src/utils/galleryPasswordVault');
const PASSWORD = 'Meadow-Lark-77!';
const PIN = '4321';
describe('recoverable gallery passwords', () => {
let db; let cleanup; let app; let token; let adminId;
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
const setSetting = (value) => db('app_settings').insert({
setting_key: vault.SETTING_KEY, setting_value: JSON.stringify(value), setting_type: 'security',
}).onConflict('setting_key').merge({ setting_value: JSON.stringify(value) });
const createEvent = (over = {}) => auth(request(app).post('/api/admin/events')).send({
event_type: 'wedding', event_name: 'Recover Wedding', event_date: '2026-09-07',
customer_name: 'Ada', customer_email: 'ada@example.com', admin_email: 'admin@example.com',
require_password: true, password: PASSWORD, expiration_days: 30,
client_access_enabled: true, client_password: PIN, ...over,
});
const stored = (id) => db('events').where('id', id).first('password_recoverable', 'client_password_recoverable');
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => { res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code }); });
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('encrypts and decrypts, and a different ciphertext each time', () => {
const a = vault.encryptPassword(PASSWORD); const b = vault.encryptPassword(PASSWORD);
expect(a).not.toBe(b);
expect(vault.decryptPassword(a)).toBe(PASSWORD);
// flip one ciphertext character so the tamper is never a no-op
const [iv, tag, ct] = a.split('.');
const tampered = [iv, tag, (ct[0] === 'A' ? 'B' : 'A') + ct.slice(1)].join('.');
expect(() => vault.decryptPassword(tampered)).toThrow();
});
it('purges before the write when turning on and after it when off, never on a same-state on save', async () => {
// off → on: leftovers go first, so a copy stored under the new "on" is
// never deleted by the purge; on → off and off → off: after, so a write
// that still read "on" is caught (see purgePlanForSettingWrite).
expect(await vault.purgePlanForSettingWrite(true)).toEqual({ before: true, after: false });
expect(await vault.purgePlanForSettingWrite(false)).toEqual({ before: false, after: true });
await setSetting(true);
expect(await vault.purgePlanForSettingWrite('1')).toEqual({ before: false, after: false });
expect(await vault.purgePlanForSettingWrite(false)).toEqual({ before: false, after: true });
await setSetting(false);
});
it('with the setting off, creation stores nothing and the view route says the feature is off', async () => {
const res = await createEvent();
expect([200, 201]).toContain(res.status);
const row = await stored(res.body.id);
expect(row.password_recoverable).toBeNull();
expect(row.client_password_recoverable).toBeNull();
const view = await auth(request(app).get(`/api/admin/events/${res.body.id}/password`));
expect(view.status).toBe(200);
expect(view.body).toEqual({ enabled: false, password: null, client_password: null });
// the hash still works, i.e. nothing about login changed
const ev = await db('events').where('id', res.body.id).first();
expect(await bcrypt.compare(PASSWORD, ev.password_hash)).toBe(true);
});
describe('with the setting on', () => {
let id;
beforeAll(async () => {
await setSetting(true);
const res = await createEvent();
expect([200, 201]).toContain(res.status);
id = res.body.id;
});
it('creation keeps an encrypted copy of both passwords, never the plaintext', async () => {
const row = await stored(id);
expect(row.password_recoverable).toBeTruthy();
expect(row.password_recoverable).not.toContain(PASSWORD);
expect(vault.decryptPassword(row.password_recoverable)).toBe(PASSWORD);
expect(vault.decryptPassword(row.client_password_recoverable)).toBe(PIN);
});
it('the view route returns them and writes an activity-log entry', async () => {
const view = await auth(request(app).get(`/api/admin/events/${id}/password`));
expect(view.status).toBe(200);
expect(view.body).toEqual({ enabled: true, password: PASSWORD, client_password: PIN });
const log = await db('activity_logs').where({ activity_type: 'gallery_password_viewed' }).orderBy('id', 'desc').first();
expect(log).toBeTruthy();
expect(String(log.event_id)).toBe(String(id));
});
it('resend uses the stored password instead of the security sentinel', async () => {
const res = await auth(request(app).post(`/api/admin/events/${id}/resend-email`)).send({});
expect(res.status).toBe(200);
expect(res.body.usedStoredPassword).toBe(true);
const mail = await db('email_queue').where({ event_id: id, email_type: 'gallery_created' }).orderBy('id', 'desc').first();
const data = JSON.parse(mail.email_data);
expect(data.gallery_password).toBe(PASSWORD);
// client access is on for this event: the resend carries the stored PIN
// and the client link, as the creation mail did
expect(data.client_password).toBe(PIN);
expect(data.client_link).toMatch(/\/client-access\?token=[0-9a-f]+$/);
});
it('a reset replaces the stored copy', async () => {
const res = await auth(request(app).post(`/api/admin/events/${id}/reset-password`)).send({ sendEmail: false, password: 'Harbour-Light-91!' });
expect(res.status).toBe(200);
expect(vault.decryptPassword((await stored(id)).password_recoverable)).toBe('Harbour-Light-91!');
});
it('editing the client PIN and the gallery password updates the copies', async () => {
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({ client_password: '9999', password: 'Quiet-River-33!' });
expect(res.status).toBe(200);
const row = await stored(id);
expect(vault.decryptPassword(row.client_password_recoverable)).toBe('9999');
expect(vault.decryptPassword(row.password_recoverable)).toBe('Quiet-River-33!');
});
it('turning the gallery password off clears its copy', async () => {
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({ require_password: false });
expect(res.status).toBe(200);
const row = await stored(id);
expect(row.password_recoverable).toBeNull();
expect(row.client_password_recoverable).toBeTruthy();
});
it('switching the setting on again does not resurrect leftovers', async () => {
await setSetting(false);
const leftover = await createEvent({ event_name: 'Leftover Wedding' });
// a copy that survived the purge somehow (a write racing the switch-off)
await db('events').where('id', leftover.body.id).update({ password_recoverable: vault.encryptPassword('Leftover-1!') });
const res = await auth(request(app).put('/api/admin/settings/security')).send({ [vault.SETTING_KEY]: true });
expect(res.status).toBe(200);
expect((await stored(leftover.body.id)).password_recoverable).toBeNull();
// and a value the API may send as 1/"1" keeps the vault (no purge on a same-state save)
const keep = await createEvent({ event_name: 'Kept Wedding' });
expect((await stored(keep.body.id)).password_recoverable).toBeTruthy();
const same = await auth(request(app).put('/api/admin/settings/security')).send({ [vault.SETTING_KEY]: '1' });
expect(same.status).toBe(200);
expect((await stored(keep.body.id)).password_recoverable).toBeTruthy();
});
it('a creation in flight while the setting is switched off leaves no copy behind', async () => {
// The setting is read while the insert is assembled, then the client
// PIN hash awaits (crud.js). A switch-off that lands in that gap used to
// be overtaken by the insert; the write-site re-check clears the row.
const realHash = bcrypt.hash;
const spy = jest.spyOn(bcrypt, 'hash').mockImplementation(async (...args) => {
if (args[0] === PIN) {
const off = await auth(request(app).put('/api/admin/settings/security')).send({ [vault.SETTING_KEY]: false });
expect(off.status).toBe(200);
}
return realHash.apply(bcrypt, args);
});
try {
const res = await createEvent({ event_name: 'Racing Wedding' });
expect([200, 201]).toContain(res.status);
const row = await stored(res.body.id);
expect(row.password_recoverable).toBeNull();
expect(row.client_password_recoverable).toBeNull();
} finally {
spy.mockRestore();
await setSetting(true);
}
});
it('switching the setting off purges every stored copy', async () => {
const other = await createEvent({ event_name: 'Second Wedding' });
expect((await stored(other.body.id)).password_recoverable).toBeTruthy();
const res = await auth(request(app).put('/api/admin/settings/security')).send({ [vault.SETTING_KEY]: false });
expect(res.status).toBe(200);
for (const eid of [id, other.body.id]) {
const row = await stored(eid);
expect(row.password_recoverable).toBeNull();
expect(row.client_password_recoverable).toBeNull();
}
const view = await auth(request(app).get(`/api/admin/events/${other.body.id}/password`));
expect(view.body.enabled).toBe(false);
// and resend is back to the sentinel
const resend = await auth(request(app).post(`/api/admin/events/${other.body.id}/resend-email`)).send({});
expect(resend.body.usedStoredPassword).toBe(false);
});
});
});
@@ -0,0 +1,122 @@
/**
* GHSA-9q5j-vqfw-32hr — the event-logo upload's multer `filename` callback
* built the stored path directly from `req.params.id` with no integer
* validation:
*
* filename: (req, file, cb) => {
* cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
* }
*
* A traversal payload in the `:id` route param (URL-encoded so it still
* matches a single Express path segment, then decoded back into literal
* `../` sequences by Express before handlers see it) could escape the
* intended uploads/logos/events/ directory. Most directly reachable via a
* super_admin session: requireEventOwnership short-circuits with next() and
* zero DB lookup for that role (src/middleware/ownership.js), so nothing
* upstream of multer validates the id first.
*
* Fixed by rejecting any non-positive-integer id before it is used to build
* the filename, regardless of role or ownership-check ordering.
*/
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-events-logo-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-logo-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
async function insertEvent(db, adminId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'Test Wedding',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_by: adminId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('POST /api/admin/events/:id/logo — path traversal guard', () => {
let db; let cleanup; let app; let adminId; let token;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
// super_admin: requireEventOwnership short-circuits with no DB lookup
// for this role, so it reaches multer with nothing upstream having
// validated the id — the exact path GHSA-9q5j-vqfw-32hr exploited.
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { await cleanup(); });
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
const logoDir = () => path.join(process.env.STORAGE_PATH, 'uploads/logos/events');
it('rejects a traversal payload in the id param instead of writing outside uploads/logos/events', async () => {
// '../../../../tmp/pwned' URL-encoded so the raw request path still has
// a single segment (matches Express's `:id`), but Express decodes the
// param back into literal '../' sequences before the route sees it.
const traversalId = encodeURIComponent('../../../../tmp/pwned');
const res = await auth(
request(app).post(`/api/admin/events/${traversalId}/logo`)
).attach('logo', Buffer.from('fake image data'), 'logo.png');
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.body.error).toMatch(/invalid event id/i);
// No file should have been written anywhere — the filename callback
// must error out before multer opens a write stream.
const escapedFile = path.join(os.tmpdir(), 'pwned');
expect(fs.existsSync(escapedFile)).toBe(false);
if (fs.existsSync(logoDir())) {
expect(fs.readdirSync(logoDir())).toHaveLength(0);
}
});
it('still accepts a normal numeric event id', async () => {
const id = await insertEvent(db, adminId, { event_name: 'Logo Event' });
const res = await auth(
request(app).post(`/api/admin/events/${id}/logo`)
).attach('logo', Buffer.from('fake image data'), 'logo.png');
expect(res.status).toBe(200);
expect(res.body.hero_logo_url).toMatch(new RegExp(`^/uploads/logos/events/event-${id}-logo-`));
const files = fs.readdirSync(logoDir());
expect(files.some((f) => f.startsWith(`event-${id}-logo-`))).toBe(true);
const row = await db('events').where({ id }).first();
expect(row.hero_logo_url).toBe(res.body.hero_logo_url);
});
});
+213
View File
@@ -38,6 +38,7 @@ const { authenticator } = require('otplib');
const {
bootCrmDb, mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
const mfaService = require('../../src/services/mfaService');
jest.setTimeout(120000);
@@ -235,6 +236,138 @@ describe('MFA disable — /api/admin/auth/mfa/disable', () => {
expect(row.two_factor_secret).toBeNull();
expect(row.two_factor_recovery_codes).toBeNull();
});
// Concurrency regression: a plain UPDATE with no conditional guard let two
// requests carrying the same captured code both read the same
// two_factor_last_used_step and both persist, defeating replay protection.
// The guarded UPDATE (mfaService.persistTotpStep) makes only the first
// writer's affected-row count > 0; the loser must be rejected.
it('two concurrent disable requests with the SAME captured code: only one succeeds', async () => {
const admin = await seedAdmin();
const { secret, token } = await enroll(admin.id);
const code = authenticator.generate(secret);
const [r1, r2] = await Promise.all([
request(adminApp)
.post('/api/admin/auth/mfa/disable')
.set('Authorization', `Bearer ${token}`)
.send({ code }),
request(adminApp)
.post('/api/admin/auth/mfa/disable')
.set('Authorization', `Bearer ${token}`)
.send({ code }),
]);
expect([r1.status, r2.status].sort()).toEqual([200, 400]);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
});
});
describe('MFA regenerate recovery codes — /api/admin/auth/mfa/recovery-codes', () => {
it('a valid TOTP regenerates the recovery codes and persists the step', async () => {
const admin = await seedAdmin();
const { secret, token } = await enroll(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/recovery-codes')
.set('Authorization', `Bearer ${token}`)
.send({ code: authenticator.generate(secret) });
expect(res.status).toBe(200);
expect(res.body.recoveryCodes).toHaveLength(10);
});
it('a wrong code is rejected (400)', async () => {
const admin = await seedAdmin();
const { secret, token } = await enroll(admin.id);
const valid = authenticator.generate(secret);
const wrong = valid === '000000' ? '111111' : '000000';
const res = await request(adminApp)
.post('/api/admin/auth/mfa/recovery-codes')
.set('Authorization', `Bearer ${token}`)
.send({ code: wrong });
expect(res.status).toBe(400);
});
// Concurrency regression (see the disable test above for the mechanism):
// this is the endpoint called out as the worst lost-update case, since it
// both rotates the recovery codes and (previously) persisted the step in
// one unconditional UPDATE.
it('two concurrent regenerations with the SAME captured code: only one succeeds', async () => {
const admin = await seedAdmin();
const { secret, token } = await enroll(admin.id);
const code = authenticator.generate(secret);
const [r1, r2] = await Promise.all([
request(adminApp)
.post('/api/admin/auth/mfa/recovery-codes')
.set('Authorization', `Bearer ${token}`)
.send({ code }),
request(adminApp)
.post('/api/admin/auth/mfa/recovery-codes')
.set('Authorization', `Bearer ${token}`)
.send({ code }),
]);
expect([r1.status, r2.status].sort()).toEqual([200, 400]);
const winner = r1.status === 200 ? r1 : r2;
expect(winner.body.recoveryCodes).toHaveLength(10);
const row = await db('admin_users').where({ id: admin.id }).first();
expect(row.two_factor_last_used_step).not.toBeNull();
});
});
describe('mfaService.persistTotpStep — atomic replay-tracking persist', () => {
// Deterministic simulation of the race: two "concurrent" requests that
// read the SAME two_factor_last_used_step and computed the SAME totpStep
// from the same captured code. Calling persistTotpStep twice in a row with
// that identical totpStep reproduces exactly the DB-level outcome of a
// true race, without relying on event-loop timing.
it('the second writer with the same totpStep affects 0 rows and is rejected', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const row = await db('admin_users').where({ id: admin.id }).first();
const code = authenticator.generate(secret);
const totpStep = mfaService.verifyTotpEncryptedStep(code, row.two_factor_secret, null);
expect(totpStep).toEqual(expect.any(Number));
const first = await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
expect(first).toBe(true);
// The row's two_factor_last_used_step has now already advanced to
// totpStep by the time this "losing" write runs — the guard condition
// (whereNull OR < totpStep) is false, so 0 rows are affected.
const second = await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
expect(second).toBe(false);
const after = await db('admin_users').where({ id: admin.id }).first();
expect(Number(after.two_factor_last_used_step)).toBe(totpStep);
});
it('succeeds when the new step advances past the current one', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const row = await db('admin_users').where({ id: admin.id }).first();
const code = authenticator.generate(secret);
const totpStep = mfaService.verifyTotpEncryptedStep(code, row.two_factor_secret, null);
const ok = await mfaService.persistTotpStep(db, admin.id, totpStep, {});
expect(ok).toBe(true);
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
const nextCode = nextStepAuthenticator.generate(secret);
const nextStep = mfaService.verifyTotpEncryptedStep(nextCode, row.two_factor_secret, totpStep);
expect(nextStep).toBeGreaterThan(totpStep);
const advanced = await mfaService.persistTotpStep(db, admin.id, nextStep, {});
expect(advanced).toBe(true);
});
});
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
@@ -284,6 +417,86 @@ describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
expect(res.body.user.id).toBe(admin.id);
});
// GHSA-qcwx-r25m-j869: verifyTotp() was stateless, so otplib's window:1
// tolerance let the same 6-digit code complete two independent logins
// within its ~90s validity window. mfaService now tracks each admin's
// last-consumed TOTP step and rejects a code that doesn't advance past it.
it('#GHSA-qcwx-r25m-j869 — a TOTP code cannot be replayed into a second login', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const code = authenticator.generate(secret);
// First use of the code completes a login.
const c1 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const first = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c1.body.mfaToken, code });
expect(first.status).toBe(200);
expect(first.body.user).toBeDefined();
// Replaying the SAME code for an independent second login must fail,
// even though otplib's window:1 tolerance still considers it valid.
const c2 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const replay = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c2.body.mfaToken, code });
expect(replay.status).toBe(401);
expect(replay.body.code).toBe('MFA_INVALID');
expect(replay.body.user).toBeUndefined();
// A freshly generated code for the NEXT TOTP step is not a replay and
// succeeds. Generated via a cloned authenticator with a future epoch
// rather than mocking Date.now(), so mfaService's own step computation
// (real Date.now()) still lands the match one step ahead.
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
const nextCode = nextStepAuthenticator.generate(secret);
const c3 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const third = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c3.body.mfaToken, code: nextCode });
expect(third.status).toBe(200);
expect(third.body.user).toBeDefined();
expect(third.body.user.id).toBe(admin.id);
});
// Concurrency regression: verifyTotpEncryptedStep()'s "does this advance"
// check was read against a snapshot taken earlier in the request, then a
// PLAIN update persisted the step — two concurrent requests carrying the
// SAME captured code could both pass the check and both complete a login
// before either write landed. The persist is now a conditional UPDATE
// (mfaService.persistTotpStep), so only the first writer's affected-row
// count is > 0 and the other is correctly treated as a replay.
it('two concurrent login/mfa requests with the SAME captured code: only one completes', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const code = authenticator.generate(secret);
const c1 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const c2 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const [r1, r2] = await Promise.all([
request(authApp).post('/api/auth/admin/login/mfa').send({ mfaToken: c1.body.mfaToken, code }),
request(authApp).post('/api/auth/admin/login/mfa').send({ mfaToken: c2.body.mfaToken, code }),
]);
expect([r1.status, r2.status].sort()).toEqual([200, 401]);
const winner = r1.status === 200 ? r1 : r2;
const loser = r1.status === 200 ? r2 : r1;
expect(winner.body.user).toBeDefined();
expect(loser.body.user).toBeUndefined();
expect(loser.body.code).toBe('MFA_INVALID');
});
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
@@ -0,0 +1,123 @@
/**
* The general API rate limiter's settings, read and written by the admin
* (#1337).
*
* Until now the six rate_limit_* keys had a write route and no screen, and
* the write route used a plain UPDATE on a fresh install, which has no
* rows, it answered 200 and changed nothing. The settings read did not
* mention the keys at all when they had no row, so the budget in force
* (300 per 15 minutes per IP) was invisible. Pins: the read surfaces the
* defaults, the write creates rows, validation holds, and the limiter picks
* the new values up at once.
*/
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-ratelimit-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'ratelimit-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-ratelimit-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
const { clearPermissionCache } = require('../../src/middleware/permissions');
const rateLimitService = require('../../src/services/rateLimitService');
const { MemoryStore } = require('express-rate-limit');
describe('admin rate limiter settings', () => {
let db; let cleanup; let app; let tok; let general;
const auth = (req) => req.set('Authorization', `Bearer ${tok}`);
const rows = () => db('app_settings').where('setting_key', 'like', 'rate_limit_%').orderBy('setting_key');
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
tok = mintAdminToken(adminId);
await db('app_settings').where('setting_key', 'like', 'rate_limit_%').delete();
clearPermissionCache();
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('surfaces the code defaults in the settings read when no row exists', async () => {
expect(await rows()).toHaveLength(0);
const res = await auth(request(app).get('/api/admin/settings'));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
rate_limit_enabled: true, rate_limit_window_minutes: 15, rate_limit_max_requests: 300,
rate_limit_auth_max_requests: 5, rate_limit_skip_authenticated: true, rate_limit_public_endpoints_only: false,
});
// and only when asked for, with a key filter
const filtered = await auth(request(app).get('/api/admin/settings?keys=rate_limit_max_requests,general_site_url'));
expect(filtered.body.rate_limit_max_requests).toBe(300);
expect(filtered.body).not.toHaveProperty('rate_limit_window_minutes');
});
it('creates the rows on a fresh install and the limiter sees the change at once', async () => {
const before = await rateLimitService.getRateLimitSettings();
expect(before.maxRequests).toBe(300);
const res = await auth(request(app).put('/api/admin/settings/security/rate-limit')).send({
rate_limit_enabled: true, rate_limit_window_minutes: 10, rate_limit_max_requests: 5000,
rate_limit_auth_max_requests: 8, rate_limit_skip_authenticated: true, rate_limit_public_endpoints_only: false,
});
expect(res.status).toBe(200);
const stored = await rows();
expect(stored.map((r) => r.setting_key)).toEqual([
'rate_limit_auth_max_requests', 'rate_limit_enabled', 'rate_limit_max_requests',
'rate_limit_public_endpoints_only', 'rate_limit_skip_authenticated', 'rate_limit_window_minutes',
]);
expect(stored.every((r) => r.setting_type === 'security')).toBe(true);
const read = await auth(request(app).get('/api/admin/settings'));
expect(read.body.rate_limit_max_requests).toBe(5000);
expect(read.body.rate_limit_window_minutes).toBe(10);
// The route clears the limiter's 60-second cache, so the new budget applies now.
// The window is fixed per limiter instance, so the route rebuilds them too.
expect(rateLimitService.getGeneralLimiter()).toEqual(expect.any(Function));
expect(rateLimitService.getAuthLimiter()).toEqual(expect.any(Function));
general = rateLimitService.getGeneralLimiter();
const after = await rateLimitService.getRateLimitSettings();
expect(after.maxRequests).toBe(5000);
expect(after.windowMinutes).toBe(10);
expect(after.authMaxRequests).toBe(8);
});
it('updates existing rows rather than duplicating them', async () => {
// A rebuild must not leak the previous stores' cleanup intervals.
const shutdown = jest.spyOn(MemoryStore.prototype, 'shutdown');
await auth(request(app).put('/api/admin/settings/security/rate-limit')).send({
rate_limit_enabled: false, rate_limit_window_minutes: 15, rate_limit_max_requests: 300,
rate_limit_auth_max_requests: 5, rate_limit_skip_authenticated: false, rate_limit_public_endpoints_only: true,
});
expect(await rows()).toHaveLength(6);
const after = await rateLimitService.getRateLimitSettings();
expect(after).toMatchObject({ enabled: false, maxRequests: 300, skipAuthenticated: false, publicEndpointsOnly: true });
// and every save hands the gates a fresh instance, shutting the old stores down
expect(rateLimitService.getGeneralLimiter()).not.toBe(general);
expect(shutdown).toHaveBeenCalledTimes(2);
shutdown.mockRestore();
});
it('rejects values outside the documented ranges', async () => {
for (const bad of [
{ rate_limit_window_minutes: 0 }, { rate_limit_window_minutes: 61 },
{ rate_limit_max_requests: 9 }, { rate_limit_max_requests: 10001 },
{ rate_limit_auth_max_requests: 0 }, { rate_limit_enabled: 'yes' },
]) {
const res = await auth(request(app).put('/api/admin/settings/security/rate-limit')).send({
rate_limit_enabled: true, rate_limit_window_minutes: 15, rate_limit_max_requests: 300,
rate_limit_auth_max_requests: 5, rate_limit_skip_authenticated: true, rate_limit_public_endpoints_only: false,
...bad,
});
expect(res.status).toBe(400);
}
});
});
@@ -0,0 +1,151 @@
/**
* GHSA-9h7q-2jpf-vj85 DELETE /api/admin/short-urls/:id only checked
* `events.edit` permission, with no ownership scoping. GET and POST for an
* event's short URLs both chain requireEventOwnership; DELETE takes the
* short URL row's own :id (not :eventId), so any admin holding events.edit
* could delete another admin's branded gallery short URL. The route now
* resolves the short URL's event first and applies the same ownership
* predicate requireEventOwnership uses. super_admin keeps global access.
*/
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-suown-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'suown-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-suown-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
describe('short URL delete ownership scoping', () => {
let db; let cleanup; let app; let service;
let superTok; let ownerTok; let foreignTok;
let ownerId;
let foreignShortUrlId;
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
async function seedEvent(createdBy, slugSuffix) {
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [id] = await db('events').insert({
slug: `suown-${slugSuffix}`,
event_type: 'wedding',
event_name: 'Test Event',
event_date: '2026-08-01',
host_email: 'h@e.com',
admin_email: 'a@e.com',
password_hash: 'x',
share_link: `suown-${slugSuffix}`,
share_token: `suown-share-${slugSuffix}`,
expires_at: farFuture,
is_active: true,
is_archived: false,
created_by: createdBy,
created_at: new Date().toISOString(),
});
return db('events').where({ id }).first();
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
service = require('../../src/services/galleryShortUrlService');
const superIns = await db('admin_users').insert({
username: 'suown-super', email: 'suown-super@example.com',
password_hash: 'x', must_change_password: false, created_at: new Date(),
}).returning('id');
const superId = superIns[0]?.id ?? superIns[0];
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
const ownerIns = await db('admin_users').insert({
username: 'suown-owner', email: 'suown-owner@example.com',
password_hash: 'x', must_change_password: false, created_at: new Date(),
}).returning('id');
ownerId = ownerIns[0]?.id ?? ownerIns[0];
await assignAdminRole(db, ownerId, 'editor');
ownerTok = mintAdminToken(ownerId);
const foreignIns = await db('admin_users').insert({
username: 'suown-foreign', email: 'suown-foreign@example.com',
password_hash: 'x', must_change_password: false, created_at: new Date(),
}).returning('id');
const foreignId = foreignIns[0]?.id ?? foreignIns[0];
await assignAdminRole(db, foreignId, 'editor');
foreignTok = mintAdminToken(foreignId);
// Event owned by `owner`, NOT `foreign`.
await seedEvent(ownerId, 'owned');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin', require('../../src/routes/adminShortUrls'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
// Fresh short URL per DELETE test so earlier deletes don't interfere.
const event = await db('events').where({ created_by: ownerId }).first();
const row = await service.createShortUrl({
eventId: event.id,
customSlug: `suown-target-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
createdBy: ownerId,
});
foreignShortUrlId = row.id;
});
it('an admin who does not own the event cannot delete its short URL (403, row survives)', async () => {
const res = await auth(
request(app).delete(`/api/admin/short-urls/${foreignShortUrlId}`),
foreignTok,
);
expect(res.status).toBe(403);
const row = await db('gallery_short_urls').where({ id: foreignShortUrlId }).first();
expect(row).toBeDefined();
expect(row.deleted_at).toBeFalsy();
});
it('the owning admin can delete its own short URL', async () => {
const res = await auth(
request(app).delete(`/api/admin/short-urls/${foreignShortUrlId}`),
ownerTok,
);
expect(res.status).toBe(204);
const row = await db('gallery_short_urls').where({ id: foreignShortUrlId }).first();
expect(row.deleted_at).toBeTruthy();
});
it('super_admin can delete any short URL', async () => {
const res = await auth(
request(app).delete(`/api/admin/short-urls/${foreignShortUrlId}`),
superTok,
);
expect(res.status).toBe(204);
const row = await db('gallery_short_urls').where({ id: foreignShortUrlId }).first();
expect(row.deleted_at).toBeTruthy();
});
it('deleting a nonexistent short URL id returns 404', async () => {
const res = await auth(
request(app).delete('/api/admin/short-urls/9999999'),
superTok,
);
expect(res.status).toBe(404);
});
it('deleting a nonexistent short URL id as a non-owner also returns 404 (existence check runs first)', async () => {
const res = await auth(
request(app).delete('/api/admin/short-urls/9999999'),
foreignTok,
);
expect(res.status).toBe(404);
});
});
+18 -2
View File
@@ -29,6 +29,7 @@ jest.mock('../../src/services/productUsageService', () =>
'tick',
'status',
'dismiss',
'markPromptShown',
'enable',
'disable',
'abandon',
@@ -63,6 +64,11 @@ beforeAll(async () => {
t.integer('role_id');
t.boolean('is_active');
t.timestamp('password_changed_at');
// adminAuth() now selects this on every request (GHSA-h4w8-57xq-53fx
// must_change_password enforcement) — without the column the join
// throws and every route in this file 401s before reaching the
// permission check it's meant to test.
t.boolean('must_change_password');
});
await mockDb.schema.createTable('permissions', (t) => {
t.increments('id');
@@ -129,6 +135,7 @@ const ROUTES = [
['post', '/abandon'],
['post', '/retry'],
['post', '/dismiss'],
['post', '/prompt-seen'],
['get', '/preview'],
['get', '/export'],
['put', '/feedback-preferences'],
@@ -178,6 +185,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) => {
@@ -201,8 +217,8 @@ test('public/gallery paths and failed/unauthenticated admin operations never set
expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42');
});
test.each(['usage-consent.v2', 'usage-consent.v3', 'usage-consent.v4'])('consent upgrade accepts exactly the explicit %s choice, never extra fields', async (consent_version) => {
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v5' }, { consent_version, user: 'PRIVATE' }])
test.each(['usage-consent.v2', 'usage-consent.v3', 'usage-consent.v4', 'usage-consent.v5'])('consent upgrade accepts exactly the explicit %s choice, never extra fields', async (consent_version) => {
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v6' }, { consent_version, user: 'PRIVATE' }])
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`).send(data).expect(400);
expect(service.command).not.toHaveBeenCalled();
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`)
@@ -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);
});
@@ -137,11 +137,17 @@ describe('authorization / ownership gaps', () => {
// Case-variant keys — SQLite matches columns case-insensitively.
Password_Hash: 'case-hijack-hash',
Created_By: 88888,
// A case variant of an ORDINARY column must not reach the UPDATE
// either: field-level guards in the handler key on the exact name,
// and on SQLite the variant would still land on the real column.
Event_Name: 'case-variant-name',
Welcome_Message: 'case-variant-welcome',
});
expect(res.status).toBe(200);
const row = await db('events').where({ id: eventId }).first();
expect(row.event_name).toBe('After'); // legit field applied
expect(row.event_name).toBe('After'); // legit field applied; Event_Name variant dropped
expect(row.welcome_message).toBeFalsy(); // case variant of an ordinary column dropped
expect(row.created_by).toBe(superId); // ownership untouched (+ case-variant)
expect(row.slug).toBe('authz-mass-assign'); // routing identity untouched
expect(row.share_token).toBe(seedShareToken); // secret untouched
@@ -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,172 @@
/**
* Previewing an unpublished gallery through its SHORT share URL (#1386).
*
* /info has honoured admin_preview since #868, but two sibling routes never
* did, and both sit on the short-URL path:
*
* GET /resolve/:identifier filtered drafts out via ACTIVE_EVENT_FILTER
* GET /:slug/verify-token/:token same, inline
*
* With "use short gallery URLs" OFF the admin's View Gallery link carries the
* slug, GalleryPage never calls /resolve, and the preview worked. With it ON
* the link is the token form, GalleryPage resolves it first, and the draft
* 404'd as "Gallery Not Found" which is exactly what was reported.
*
* The relaxation is admin-preview-only, so the other half of these tests is
* the part that must NOT move: anonymous callers still get 404 for a draft,
* and GHSA-rh8r's rule (never hand a share_token back on a bare slug lookup)
* has to survive the new path too.
*/
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-draft-preview-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'draft-preview-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-draft-preview-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
// Share-token fixtures, deliberately low-entropy and obviously fake. They
// have to satisfy SHARE_TOKEN_REGEX (32 hex chars), and random-looking hex of
// that shape is exactly what secret scanners flag — GitGuardian raised two
// "Generic High Entropy Secret" findings on the first version of this file.
const DRAFT_SLUG = 'draft-preview-event';
const DRAFT_TOKEN = 'deadbeefdeadbeefdeadbeefdeadbeef';
const LIVE_SLUG = 'published-event';
const LIVE_TOKEN = 'feedfacefeedfacefeedfacefeedface';
describe('draft preview through the short share URL (#1386)', () => {
let db; let cleanup; let app; let adminId; let foreignId;
const asAdmin = (req, id = adminId) => req.set('Authorization', `Bearer ${mintAdminToken(id)}`);
async function insertEvent({ slug, token, isDraft }) {
await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-09-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${slug}/${token}`,
share_token: token,
require_password: 0,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: isDraft ? 1 : 0,
created_by: adminId,
created_at: new Date().toISOString(),
});
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId } = 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 insertEvent({ slug: DRAFT_SLUG, token: DRAFT_TOKEN, isDraft: true });
await insertEvent({ slug: LIVE_SLUG, token: LIVE_TOKEN, isDraft: false });
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('the reported case — admin previewing a draft', () => {
it('resolves the draft by share token (was 404 "Gallery Not Found")', async () => {
const res = await asAdmin(
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
);
expect(res.status).toBe(200);
expect(res.body.slug).toBe(DRAFT_SLUG);
expect(res.body.matchType).toBe('token');
});
it('resolves the draft by full share link', async () => {
const identifier = encodeURIComponent(`/gallery/${DRAFT_SLUG}/${DRAFT_TOKEN}`);
const res = await asAdmin(
request(app).get(`/api/gallery/resolve/${identifier}?admin_preview=1`),
);
expect(res.status).toBe(200);
expect(res.body.slug).toBe(DRAFT_SLUG);
});
it('clears verify-token for the draft, the next step of the same flow', async () => {
const res = await asAdmin(
request(app).get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}?admin_preview=1`),
);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
});
describe('what must not move', () => {
it('404s an anonymous resolve of the draft token', async () => {
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}`);
expect(res.status).toBe(404);
});
it('404s even with admin_preview=1 but no admin token', async () => {
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`);
expect(res.status).toBe(404);
});
it('404s for an admin who cannot access this event', async () => {
const res = await asAdmin(
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
foreignId,
);
expect(res.status).toBe(404);
});
it('404s an anonymous verify-token for the draft', async () => {
const res = await request(app)
.get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}`);
expect(res.status).toBe(404);
});
it('still withholds the share_token on a bare slug lookup (GHSA-rh8r)', async () => {
// The draft path must not become a way around the token-withholding rule.
const res = await asAdmin(
request(app).get(`/api/gallery/resolve/${DRAFT_SLUG}?admin_preview=1`),
);
expect(res.status).toBe(200);
expect(res.body.matchType).toBe('slug');
expect(res.body.token).toBeUndefined();
expect(res.body.share_link).toBeUndefined();
expect(res.body.share_url).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain(DRAFT_TOKEN);
});
it('leaves the published gallery resolving anonymously, as before', async () => {
const res = await request(app).get(`/api/gallery/resolve/${LIVE_TOKEN}`);
expect(res.status).toBe(200);
expect(res.body.slug).toBe(LIVE_SLUG);
expect(res.body.token).toBe(LIVE_TOKEN);
});
it('still 404s an identifier that matches nothing', async () => {
const res = await asAdmin(
request(app).get('/api/gallery/resolve/no-such-gallery?admin_preview=1'),
);
expect(res.status).toBe(404);
});
});
});
@@ -0,0 +1,163 @@
/**
* Videos under enhanced/maximum image protection (#1370).
*
* Both halves of the video path used to be routed through /api/secure-images
* once an event left `standard` protection, and neither half could carry a
* video:
*
* 1. galleryQueryService emitted `/api/secure-images/{slug}/secure/{id}/{{token}}`
* as the video's `url`. The lightbox drops that straight into a <video>
* element, nothing substitutes `{{token}}` (the helper that could is
* unreferenced), and the route answers 403 "Invalid or expired token".
* 2. Even with a valid token it would still fail: the secure-images route
* pipes every byte through secureImageService.processProtectedImage,
* which calls sharp() and throws on an mp4 404.
*
* The guest saw a poster frozen at 0:00 with no error of any kind.
*
* Videos now keep the JWT route at every protection level. That is not a new
* exposure thumbnails of those same videos have always been served from it
* so these tests also pin the inverse: still images must keep bouncing to the
* secure endpoint. Every assertion here fails on the unfixed code except the
* two guarding images.
*/
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-video-urls-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'video-urls-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-video-urls-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'protected-video-gallery';
const VIDEO_BYTES = Buffer.from('not really an mp4, but the route only streams bytes');
describe('videos stay playable under enhanced/maximum protection (#1370)', () => {
let db; let cleanup; let app; let eventId; let videoId; let imageId;
async function setProtection(level) {
await db('events').where('id', eventId).update({ protection_level: level });
}
async function photoPayload(id) {
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
expect(res.status).toBe(200);
const photo = res.body.photos.find((p) => p.id === id);
expect(photo).toBeDefined();
return photo;
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const ev = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Protected Video',
event_date: '2026-09-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/s`,
share_token: 'protected-video-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
// Password-free so verifyGalleryAccess takes the public path, same as
// the sibling gallery suites.
require_password: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = ev[0]?.id ?? ev[0];
const mediaDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG, 'individual');
fs.mkdirSync(mediaDir, { recursive: true });
fs.writeFileSync(path.join(mediaDir, 'clip.mp4'), VIDEO_BYTES);
fs.writeFileSync(path.join(mediaDir, 'still.jpg'), Buffer.from('jpeg-ish'));
const vid = await db('photos').insert({
event_id: eventId,
filename: 'clip.mp4',
path: `${SLUG}/individual/clip.mp4`,
type: 'individual',
media_type: 'video',
mime_type: 'video/mp4',
duration: 43,
uploaded_at: new Date().toISOString(),
}).returning('id');
videoId = vid[0]?.id ?? vid[0];
const img = await db('photos').insert({
event_id: eventId,
filename: 'still.jpg',
path: `${SLUG}/individual/still.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
imageId = img[0]?.id ?? img[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe.each(['enhanced', 'maximum'])('protection_level = %s', (level) => {
beforeAll(async () => { await setProtection(level); });
test('the video url is the JWT route, not a {{token}} template', async () => {
const photo = await photoPayload(videoId);
expect(photo.url).toBe(`/api/gallery/${SLUG}/photo/${videoId}`);
expect(photo.url).not.toContain('{{token}}');
expect(photo.requires_token).toBe(false);
});
test('the video streams instead of bouncing to the secure endpoint', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/photo/${videoId}`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
expect(res.headers['accept-ranges']).toBe('bytes');
expect(Buffer.from(res.body)).toEqual(VIDEO_BYTES);
});
test('range requests still work, so seeking is possible', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photo/${videoId}`)
.set('Range', 'bytes=0-9');
expect(res.status).toBe(206);
expect(res.headers['content-range']).toBe(`bytes 0-9/${VIDEO_BYTES.length}`);
});
test('still images keep bouncing to the secure endpoint', async () => {
const photo = await photoPayload(imageId);
expect(photo.url).toBe(`/api/secure-images/${SLUG}/secure/${imageId}/{{token}}`);
expect(photo.requires_token).toBe(true);
const res = await request(app).get(`/api/gallery/${SLUG}/photo/${imageId}`);
expect(res.status).toBe(302);
expect(res.body.error).toBe('Secure access required');
});
});
describe('protection_level = standard', () => {
beforeAll(async () => { await setProtection('standard'); });
test('both media types take the JWT route, as before', async () => {
expect((await photoPayload(videoId)).url).toBe(`/api/gallery/${SLUG}/photo/${videoId}`);
expect((await photoPayload(imageId)).url).toBe(`/api/gallery/${SLUG}/photo/${imageId}`);
});
});
});
@@ -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', () => {
@@ -22,29 +22,38 @@ process.env.JWT_SECRET = process.env.JWT_SECRET || 'restorepath-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
// `bootCrmDb()` hands back the process-wide `db` singleton (module cache —
// see its own comment), so it must only be called ONCE per test file: a
// second call re-runs migrations against the same connection, and the first
// call's `cleanup()` (db.destroy()) would tear down the connection both
// describe blocks below share. Boot once at file scope; each describe below
// only touches app_settings / env vars, never the connection lifecycle.
let db; let cleanup; let checkRestorePathsAllowed;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function setBackupSetting(key, value) {
const existing = await db('app_settings').where({ setting_key: key }).first();
if (existing) {
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
} else {
await db('app_settings').insert({
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'backup',
});
}
}
describe('restore path allowlist (GHSA-fw4c)', () => {
let db; let cleanup; let checkRestorePathsAllowed;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// Configure a backup root so the allowlist is actually active.
for (const [key, value] of [['backup_destination_path', '/backup']]) {
const existing = await db('app_settings').where({ setting_key: key }).first();
if (existing) {
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
} else {
await db('app_settings').insert({
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'backup',
});
}
}
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
await setBackupSetting('backup_destination_path', '/backup');
});
it('allows the wizard\'s source TYPE tokens', async () => {
for (const source of ['local', 's3', 'upload']) {
@@ -84,3 +93,89 @@ describe('restore path allowlist (GHSA-fw4c)', () => {
expect(err).toBeNull();
});
});
/**
* GHSA-xfvx-j447-732c: `checkRestorePathsAllowed` constrained the top-level
* `source`/`manifestPath` request fields (GHSA-fw4c above), but never looked
* INSIDE the manifest itself. `manifest.database.backup_file` handed
* straight to restoreService's candidate resolution and eventually
* interpolated into `sqlite3 .restore '<path>'` was unchecked, so an
* absolute path there could point the restore at an arbitrary file even
* though `source`/`manifestPath` both passed containment.
*/
describe('restore path allowlist — manifest database.backup_file containment (GHSA-xfvx)', () => {
let tmpRoot;
beforeAll(async () => {
await setBackupSetting('backup_destination_path', '/backup');
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-xfvx-manifest-'));
// Additional allowed root via the documented escape hatch — keeps this
// describe block's fixtures out of the shared '/backup' root above.
process.env.RESTORE_ALLOWED_ROOTS = tmpRoot;
});
afterAll(() => {
delete process.env.RESTORE_ALLOWED_ROOTS;
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
const writeManifest = (name, databaseSection) => {
const manifestPath = path.join(tmpRoot, name);
fs.writeFileSync(manifestPath, JSON.stringify({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 0, manifest: [] },
database: databaseSection,
verification: { total_checksum: null, checksum_algorithm: null },
}));
return manifestPath;
};
it('rejects a manifest whose database.backup_file is an absolute path outside every configured root', async () => {
const manifestPath = writeManifest('evil-1.json', { backup_file: '/etc/passwd' });
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
expect(err).toMatch(/database\.backup_file must be inside a configured backup location/i);
});
it('accepts a manifest whose database.backup_file is an absolute path inside a configured root', async () => {
const dbFile = path.join(tmpRoot, 'database', 'picpeak-db-sqlite-1.sql.gz');
fs.mkdirSync(path.dirname(dbFile), { recursive: true });
fs.writeFileSync(dbFile, 'not a real sqlite dump, just a fixture');
const manifestPath = writeManifest('legit-1.json', { backup_file: dbFile });
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
expect(err).toBeNull();
});
it('does not choke on a manifest whose database.backup_file is a legitimate relative path', async () => {
// Relative candidates are resolved against restoreService's own
// `backupPath` (which this route-level pre-check doesn't have — it only
// sees `source`/`manifestPath`), so this layer intentionally defers
// relative-path containment to restoreService.performDatabaseRestore
// and must not false-positive here.
const manifestPath = writeManifest('legit-2.json', { backup_file: 'database/picpeak-db-sqlite-1.sql.gz' });
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
expect(err).toBeNull();
});
it('rejects everything when no backup location is configured at all (fail closed, not fail open)', async () => {
// Simulate an install that never had backup_destination_path /
// backup_manifest_path seeded/configured, and isn't using the
// RESTORE_ALLOWED_ROOTS escape hatch either.
const savedRoots = process.env.RESTORE_ALLOWED_ROOTS;
delete process.env.RESTORE_ALLOWED_ROOTS;
await db('app_settings').whereIn('setting_key', ['backup_destination_path', 'backup_manifest_path']).del();
try {
const err = await checkRestorePathsAllowed({
source: '/backup/run-1', manifestPath: '/backup/run-1/manifest.json',
});
expect(err).toMatch(/no backup location is configured/i);
} finally {
process.env.RESTORE_ALLOWED_ROOTS = savedRoots;
await setBackupSetting('backup_destination_path', '/backup');
}
});
});
@@ -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 () => {
@@ -0,0 +1,131 @@
const express = require('express');
const request = require('supertest');
const knex = require('knex');
const { changedFields, settingsChanged } = require('../../src/usage/adoptionEvidence');
jest.mock('../../src/middleware/auth', () => ({ adminAuth: (req, res, next) => { req.admin = { id: 1 }; next(); } }));
jest.mock('../../src/middleware/permissions', () => ({ requirePermission: () => (req, res, next) => next() }));
jest.mock('../../src/middleware/requireFeatureFlag', () => ({ requireFeatureFlag: () => (req, res, next) => next() }));
jest.mock('../../src/services/productUsageService', () => ({ markUsed: jest.fn().mockResolvedValue() }));
jest.mock('../../src/services/emailProcessor', () => ({
htmlToText: body => body, wrapEmailHtml: jest.fn(async body => body), buildSignatureTextFor: jest.fn(async () => ''),
}));
jest.mock('../../src/services/businessProfileService', () => ({ getEmailSignature: jest.fn(async () => null) }));
describe('v5 evidence comes from real edits, not the generic successful-route marker', () => {
let db, app;
const marker = require('../../src/services/productUsageService').markUsed;
const recorded = () => marker.mock.calls.flatMap(([keys]) => keys).filter(key => /_editing$/.test(key));
beforeAll(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
jest.doMock('../../src/database/db', () => ({ db, logActivity: jest.fn(async () => {}) }));
await db.schema.createTable('cms_pages', t => {
t.increments('id'); t.string('slug');
for (const key of ['title_en', 'title_de', 'content_en', 'content_de', 'logo_url', 'external_url']) t.text(key);
t.boolean('use_external_url').defaultTo(false); t.boolean('show_in_footer').defaultTo(true); t.timestamp('updated_at');
});
await db.schema.createTable('email_templates', t => { t.increments('id'); t.string('template_key'); t.timestamp('updated_at'); });
await db.schema.createTable('email_template_translations', t => {
t.increments('id'); t.integer('template_id'); t.string('language');
for (const key of ['subject', 'body_html', 'body_text']) t.text(key);
t.timestamp('updated_at'); t.timestamp('created_at');
});
await db.schema.createTable('app_settings', t => { t.string('setting_key').primary(); t.text('setting_value'); });
await db.schema.createTable('event_types', t => {
t.increments('id'); for (const key of ['name', 'slug_prefix', 'emoji', 'theme_preset', 'theme_config']) t.text(key);
t.integer('display_order'); t.boolean('is_active'); t.boolean('is_system'); t.timestamp('updated_at'); t.timestamp('created_at');
});
await db.schema.createTable('events', t => { t.increments('id'); t.integer('created_by'); });
await db.schema.createTable('event_category_order', t => { t.integer('event_id'); t.integer('category_id'); t.integer('position'); });
await db.schema.createTable('photo_categories', t => {
t.increments('id'); t.text('name'); t.text('slug'); t.integer('hero_photo_id'); t.integer('event_id');
t.integer('display_order'); t.boolean('allow_downloads'); t.boolean('is_folder'); t.boolean('is_global');
});
app = express(); app.use(express.json());
app.use(require('../../src/middleware/productUsage').productUsage);
app.use('/cms', require('../../src/routes/adminCMS'));
app.use('/email', require('../../src/routes/adminEmail'));
app.use('/event-types', require('../../src/routes/adminEventTypes'));
app.use('/categories', require('../../src/routes/adminCategories'));
});
beforeEach(async () => {
marker.mockClear();
for (const table of ['cms_pages', 'email_templates', 'email_template_translations', 'app_settings', 'event_types', 'photo_categories', 'events', 'event_category_order']) await db(table).delete();
await db('cms_pages').insert({ slug: 'privacy', title_en: 'Privacy', content_en: 'Seeded content' });
await db('email_templates').insert({ id: 1, template_key: 'PRIVATE-template' });
await db('email_template_translations').insert({ template_id: 1, language: 'en', subject: 'Seeded subject', body_html: 'Seeded body', body_text: '' });
});
afterAll(() => db.destroy());
test('CMS reads, unchanged saves and external content do not imply internal content editing', async () => {
await request(app).get('/cms/pages').expect(200);
await request(app).put('/cms/pages/privacy').send({ content_en: 'Seeded content' }).expect(200);
await request(app).put('/cms/pages/privacy').send({ use_external_url: true, external_url: 'https://example.test/privacy', content_en: 'Other' }).expect(200);
expect(recorded()).toEqual([]);
await request(app).put('/cms/pages/privacy').send({ use_external_url: false, content_en: 'PRIVATE real content' }).expect(200);
expect(recorded()).toEqual(['cms_content_editing']);
expect(JSON.stringify(marker.mock.calls)).not.toMatch(/PRIVATE|example\.test|privacy/);
});
test('template preview, empty and unchanged saves are excluded; actual content changes count', async () => {
await request(app).post('/email/templates/PRIVATE-template/preview').send({}).expect(200);
await request(app).put('/email/templates/PRIVATE-template').send({ translations: {} }).expect(200);
const translation = { subject: 'Seeded subject', body_html: 'Seeded body', body_text: '' };
await request(app).put('/email/templates/PRIVATE-template').send({ translations: { en: translation } }).expect(200);
expect(recorded()).toEqual([]);
await request(app).put('/email/templates/PRIVATE-template').send({ translations: { en: { ...translation, subject: 'PRIVATE custom subject' } } }).expect(200);
expect(recorded()).toEqual(['email_template_editing']);
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
});
test('failed saves do not count; new nonempty templates do', async () => {
await request(app).put('/email/templates/missing').send({ translations: {} }).expect(404);
await request(app).post('/email/templates').send({ template_key: 'empty', translations: {} }).expect(201);
expect(recorded()).toEqual([]);
await request(app).post('/email/templates').send({ template_key: 'custom', translations: { de: { subject: 'Privat' } } }).expect(201);
expect(recorded()).toEqual(['email_template_editing']);
});
test('seeded event types and categories count only after a real edit, not identical saves', async () => {
await db('event_types').insert({ id: 1, name: 'Wedding', slug_prefix: 'wedding', is_active: true, is_system: true });
await db('photo_categories').insert({ id: 1, name: 'All', slug: 'all', is_global: true, is_folder: false });
await request(app).get('/event-types').expect(200);
await request(app).get('/categories/global').expect(200);
await request(app).put('/event-types/1').send({ name: 'Wedding', is_active: true }).expect(200);
await request(app).put('/categories/1').send({ name: 'All', is_folder: false }).expect(200);
expect(recorded()).toEqual([]);
await request(app).put('/event-types/1').send({ name: 'PRIVATE event type' }).expect(200);
await request(app).put('/categories/1').send({ name: 'PRIVATE category' }).expect(200);
expect(recorded()).toEqual(['event_type_editing', 'category_editing']);
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
});
test('reordering event types and global categories counts only when the order changes', async () => {
await db('event_types').insert([
{ id: 1, name: 'A', slug_prefix: 'a', display_order: 1, is_active: true, is_system: true },
{ id: 2, name: 'B', slug_prefix: 'b', display_order: 2, is_active: true, is_system: true },
]);
await db('photo_categories').insert([
{ id: 1, name: 'A', slug: 'a', is_global: true, is_folder: false, display_order: 1 },
{ id: 2, name: 'B', slug: 'b', is_global: true, is_folder: false, display_order: 2 },
]);
await db('events').insert({ id: 1 });
await request(app).post('/event-types/reorder').send({ orderedIds: [1, 2] }).expect(200);
await request(app).post('/categories/reorder-global').send({ orderedIds: [1, 2] }).expect(200);
await request(app).delete('/categories/reorder/1').expect(200); // no override to reset
expect(recorded()).toEqual([]);
await request(app).post('/event-types/reorder').send({ orderedIds: [2, 1] }).expect(200);
await request(app).post('/categories/reorder-global').send({ orderedIds: [2, 1] }).expect(200);
await request(app).post('/categories/reorder').send({ event_id: 1, orderedIds: [2, 1] }).expect(200);
expect(recorded()).toEqual(['event_type_editing', 'category_editing', 'category_editing']);
marker.mockClear();
await request(app).post('/categories/reorder').send({ event_id: 1, orderedIds: [2, 1] }).expect(200); // same override again
expect(recorded()).toEqual([]);
await request(app).delete('/categories/reorder/1').expect(200);
expect(recorded()).toEqual(['category_editing']);
});
test('settings compare persisted values, not timestamps, JSON order or defaults materialized as rows', async () => {
await db('app_settings').insert({ setting_key: 'theme_config', setting_value: JSON.stringify({ a: 1, b: 2 }) });
expect(await settingsChanged(db, { theme_config: { b: 2, a: 1 } }, ['theme_config'])).toBe(false);
expect(await settingsChanged(db, { theme_config: { b: 2, a: 2 } }, ['theme_config'])).toBe(true);
expect(await settingsChanged(db, { missing: 'fallback' }, ['missing'])).toBe(false);
expect(await settingsChanged(db, { secret: 'PRIVATE' }, ['theme_config'])).toBe(false);
expect(changedFields({ enabled: 1, updated_at: 'old' }, { enabled: true, updated_at: 'new' }, ['enabled'])).toBe(false);
expect(changedFields({ body: '' }, { body: null }, ['body'])).toBe(false);
});
});
@@ -0,0 +1,294 @@
/**
* A rejected chunk must not cost its own size in memory (#1403).
*
* The route used to drain the whole request into an array and `Buffer.concat`
* it before calling uploadChunk, which is where every check lives the
* per-file cap, the chunk index, and even "does this upload id exist". So a
* 300MB body against an unknown upload id was read in full, added ~300MB to
* RSS, and was then answered with an error. The size cap was real but only
* applied after the damage.
*
* The contract these tests pin: uploadChunk consumes NOTHING until every check
* has passed, and once it does start reading it stops at the remaining
* allowance rather than trusting the sender.
*/
const path = require('path');
const os = require('os');
const fs = require('fs').promises;
const { Readable } = require('stream');
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-stream-test-${process.pid}`);
const chunkedUpload = require('../../src/services/chunkedUploadService');
const MB = 1024 * 1024;
const init = (overrides = {}) => chunkedUpload.initializeUpload({
filename: 'clip.mp4',
fileSize: 1,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 2,
maxFileSizeBytes: 1 * MB,
...overrides,
});
/**
* A readable that reports how much of it was actually pulled. Bytes are
* generated lazily, so "never read" really means the body never materialized.
*/
function countingSource(totalBytes, sliceSize = 64 * 1024) {
let remaining = totalBytes;
const source = new Readable({
read() {
if (remaining <= 0) return this.push(null);
const n = Math.min(sliceSize, remaining);
remaining -= n;
source.bytesRead += n;
this.push(Buffer.alloc(n));
},
});
source.bytesRead = 0;
return source;
}
describe('chunked upload streams the body under a cap (#1403)', () => {
afterAll(async () => {
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
});
describe('refused before the body is read', () => {
it('reads nothing for an unknown upload id', async () => {
const source = countingSource(8 * MB);
await expect(chunkedUpload.uploadChunk('does-not-exist', 0, source, { declaredBytes: 8 * MB }))
.rejects.toThrow('Upload not found or expired');
expect(source.bytesRead).toBe(0);
});
it('reads nothing for an out-of-range chunk index', async () => {
const { uploadId } = await init();
const source = countingSource(8 * MB);
await expect(chunkedUpload.uploadChunk(uploadId, 99, source, { declaredBytes: 8 * MB }))
.rejects.toMatchObject({ code: 'INVALID_CHUNK', statusCode: 400 });
expect(source.bytesRead).toBe(0);
});
it('reads nothing when Content-Length already exceeds the cap', async () => {
const { uploadId } = await init();
const source = countingSource(8 * MB);
await expect(chunkedUpload.uploadChunk(uploadId, 0, source, { declaredBytes: 8 * MB }))
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
expect(source.bytesRead).toBe(0);
expect(chunkedUpload.getUploadStatus(uploadId)).toBeNull();
});
it('counts what earlier chunks already banked when checking Content-Length', async () => {
const { uploadId } = await init();
await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.75 * MB));
const source = countingSource(0.5 * MB);
// 0.75MB banked + 0.5MB declared > the 1MB cap.
await expect(chunkedUpload.uploadChunk(uploadId, 1, source, { declaredBytes: 0.5 * MB }))
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
expect(source.bytesRead).toBe(0);
});
});
describe('a sender that lies, or says nothing', () => {
it('stops at the allowance instead of reading the whole body', async () => {
const { uploadId } = await init();
// No declaredBytes at all — the Transfer-Encoding: chunked case.
const source = countingSource(8 * MB);
await expect(chunkedUpload.uploadChunk(uploadId, 0, source))
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
// The overshoot is whatever the readable had already buffered ahead when
// the cap tripped — a small constant tied to highWaterMark, NOT a
// function of the body size. That is the whole claim: 8MB offered, ~1MB
// read. The slack is deliberately loose so this doesn't turn into a
// Node-version canary.
expect(source.bytesRead).toBeLessThan(2 * MB);
});
it('leaves no partial chunk file behind when it cuts a body off', async () => {
const { uploadId } = await init();
const meta = chunkedUpload.getUploadStatus(uploadId);
await expect(chunkedUpload.uploadChunk(uploadId, 0, countingSource(8 * MB)))
.rejects.toMatchObject({ statusCode: 413 });
// abortUpload removes the whole directory; assert nothing survived it.
await expect(fs.readdir(path.join(process.env.STORAGE_PATH, 'chunks', uploadId)))
.rejects.toMatchObject({ code: 'ENOENT' });
expect(meta).not.toBeNull();
});
});
// Every case here was found by an external review of the first cut of this
// fix. All three are regressions the buffered version did not have: the
// async iterator it replaced rejected a dead request on its own, and never
// opened the chunk file at all until it had the whole body in hand.
describe('failure paths the streaming rewrite introduced', () => {
it('rejects an already-destroyed request instead of hanging forever', async () => {
const { uploadId } = await init();
const source = countingSource(1024);
source.destroy();
// pipe() on a dead stream emits neither `end` nor `error`, so without an
// explicit check this promise never settles and the write fd leaks.
await expect(chunkedUpload.uploadChunk(uploadId, 0, source))
.rejects.toMatchObject({ code: 'CHUNK_PREMATURE_CLOSE', statusCode: 400 });
});
it('leaves a previously banked chunk intact when a re-send fails', async () => {
const { uploadId } = await init();
await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(1000));
const chunkPath = path.join(process.env.STORAGE_PATH, 'chunks', uploadId, 'chunk_000000');
expect((await fs.stat(chunkPath)).size).toBe(1000);
// Re-send the same index, then fail it mid-flight.
const source = new Readable({ read() {} });
const pending = chunkedUpload.uploadChunk(uploadId, 0, source);
source.push(Buffer.alloc(10));
source.destroy(new Error('client went away'));
await expect(pending).rejects.toThrow();
// The banked copy must still be there: receivedChunks/chunkSizes still
// count it, so a truncated file here means status reports 100% and
// completeUpload dies on ENOENT.
expect((await fs.stat(chunkPath)).size).toBe(1000);
// Still counted as received — which is exactly why the file has to still
// be there and be the full 1000 bytes.
expect(chunkedUpload.getUploadStatus(uploadId).receivedChunks).toBe(1);
});
it('keeps two in-flight sends of the same chunk off each other\'s staging file', async () => {
const { uploadId } = await init();
const chunkPath = path.join(process.env.STORAGE_PATH, 'chunks', uploadId, 'chunk_000000');
// Two requests for the same index, overlapping. A shared .part path let
// whichever renamed first publish bytes the other had already truncated.
const slow = new Readable({ read() {} });
const doomed = new Readable({ read() {} });
const slowDone = chunkedUpload.uploadChunk(uploadId, 0, slow);
const doomedDone = chunkedUpload.uploadChunk(uploadId, 0, doomed);
doomed.push(Buffer.alloc(2));
doomed.destroy(new Error('retry gave up'));
await expect(doomedDone).rejects.toThrow();
slow.push(Buffer.alloc(10));
slow.push(null);
await expect(slowDone).resolves.toBeTruthy();
// The surviving attempt's 10 bytes, not the failed one's 2.
expect((await fs.stat(chunkPath)).size).toBe(10);
});
it('leaves no staging files behind after a failure', async () => {
const { uploadId } = await init();
await expect(chunkedUpload.uploadChunk(uploadId, 0, countingSource(8 * MB)))
.rejects.toMatchObject({ statusCode: 413 });
// The cap path aborts the whole upload, so the directory is gone; what
// must not happen is a .part file reappearing after cleanup because the
// write stream's open() was still pending when the unlink ran.
await new Promise((r) => setTimeout(r, 50));
await expect(fs.readdir(path.join(process.env.STORAGE_PATH, 'chunks', uploadId)))
.rejects.toMatchObject({ code: 'ENOENT' });
});
it('counts chunks that landed while another was still streaming', async () => {
const { uploadId } = await init({ totalChunks: 3 });
// Start a slow 0.75MB chunk. Its allowance is computed now, when nothing
// else is banked.
const slow = new Readable({ read() {} });
const slowDone = chunkedUpload.uploadChunk(uploadId, 0, slow);
// A second 0.75MB chunk completes in the meantime.
await chunkedUpload.uploadChunk(uploadId, 1, Buffer.alloc(0.75 * MB));
// Finishing the first must not publish: 1.5MB against a 1MB cap.
slow.push(Buffer.alloc(0.75 * MB));
slow.push(null);
await expect(slowDone).rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
expect(chunkedUpload.getUploadStatus(uploadId)).toBeNull();
});
it('removes the staging file when publishing it fails', async () => {
const { uploadId } = await init();
const dir = path.join(process.env.STORAGE_PATH, 'chunks', uploadId);
// Make the rename fail by putting a directory where the chunk goes.
await fs.mkdir(path.join(dir, 'chunk_000000'), { recursive: true });
await expect(chunkedUpload.uploadChunk(uploadId, 0, countingSource(1024)))
.rejects.toThrow();
// The fully written .part must not survive a failed publish — its name is
// per-attempt, so retries would otherwise pile them up until expiry.
const leftovers = (await fs.readdir(dir)).filter((f) => f.endsWith('.part'));
expect(leftovers).toEqual([]);
});
it('does not destroy the request stream when it trips the cap', async () => {
const { uploadId } = await init();
const source = countingSource(8 * MB);
await expect(chunkedUpload.uploadChunk(uploadId, 0, source))
.rejects.toMatchObject({ statusCode: 413 });
// `source` stands in for the IncomingMessage. Destroying it would take
// the socket down before the route could send its 413 JSON, so the client
// would see a connection reset instead of the error.
expect(source.destroyed).toBe(false);
});
});
// These were plain Errors, so the routes answered 500 for what are plainly
// client mistakes — a backend fault in monitoring, and an invitation to
// retry something that can never succeed.
describe('client-caused states carry their own status code', () => {
it('404s an unknown upload id rather than 500', async () => {
await expect(chunkedUpload.uploadChunk('does-not-exist', 0, Buffer.alloc(10)))
.rejects.toMatchObject({ statusCode: 404 });
});
// 409 (wrong status) and 410 (expired) share uploadStateError with the two
// covered here. Reaching them from the public surface needs either a clock
// or a setter the service does not expose, and a test that pretends to
// exercise them while actually hitting the 404 path is worse than none.
it('404s completing an unknown upload rather than 500', async () => {
await expect(chunkedUpload.completeUpload('does-not-exist'))
.rejects.toMatchObject({ statusCode: 404 });
});
it('400s completing an upload that is missing chunks', async () => {
const { uploadId } = await init();
await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(10));
await expect(chunkedUpload.completeUpload(uploadId))
.rejects.toMatchObject({ statusCode: 400 });
});
});
describe('the happy path still works', () => {
it('writes a streamed chunk and reports progress', async () => {
const { uploadId } = await init();
const result = await chunkedUpload.uploadChunk(uploadId, 0, countingSource(0.25 * MB), {
declaredBytes: 0.25 * MB,
});
expect(result).toMatchObject({ chunkIndex: 0, received: 1, expected: 2, complete: false });
const chunkPath = path.join(process.env.STORAGE_PATH, 'chunks', uploadId, 'chunk_000000');
expect((await fs.stat(chunkPath)).size).toBe(0.25 * MB);
});
it('still accepts a Buffer, the shape the service was written for', async () => {
const { uploadId } = await init();
const result = await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.25 * MB));
expect(result).toMatchObject({ chunkIndex: 0, received: 1 });
});
it('lets a re-sent chunk replace itself without double-counting', async () => {
const { uploadId } = await init();
await chunkedUpload.uploadChunk(uploadId, 0, countingSource(0.6 * MB), { declaredBytes: 0.6 * MB });
// Same index again: the first copy's 0.6MB must not count toward the cap.
await expect(
chunkedUpload.uploadChunk(uploadId, 0, countingSource(0.6 * MB), { declaredBytes: 0.6 * MB }),
).resolves.toBeTruthy();
});
});
});
@@ -0,0 +1,128 @@
/**
* Background zip rebuilds are capped (#1399).
*
* invalidateAll() invalidates every event holding a cached zip, and each
* invalidate() arms its own debounce timer in the same tick so they all fire
* together. Every build opens its own storage reads, so a settings change
* across 25 events was enough to exhaust the S3 agent pool and stall uploads,
* thumbnails and gallery reads until the burst drained.
*
* The cap is on the BACKGROUND path only: a guest waiting on a download must
* not be queued behind a settings-change burst.
*/
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
const { db } = require('../../src/database/db');
const service = require('../../src/services/downloadZipService');
const flush = () => new Promise((r) => setImmediate(r));
describe('downloadZipService background regen concurrency (#1399)', () => {
let peak;
let inFlight;
let release;
beforeEach(() => {
// setImmediate must stay real: the flush() helper below rides on it, and
// jest's modern fake timers mock it too.
jest.useFakeTimers({ doNotFake: ['setImmediate'] });
peak = 0;
inFlight = 0;
release = [];
service.stopped = false;
service.regenActive = 0;
service.regenWaiters = [];
service.debounceTimers.clear();
service.activeBuilds.clear();
jest.spyOn(service, 'generateZip').mockImplementation(() => {
inFlight += 1;
peak = Math.max(peak, inFlight);
return new Promise((resolve) => {
release.push(() => { inFlight -= 1; resolve(); });
});
});
jest.spyOn(service, '_cleanup').mockResolvedValue(undefined);
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('never runs more than two rebuilds at once, however many fire together', async () => {
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1 }));
db.mockReturnValue({
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
});
await service.invalidateAll();
// Every debounce timer was armed in the same tick — fire them all.
jest.runAllTimers();
await flush();
expect(peak).toBe(2);
expect(service.generateZip).toHaveBeenCalledTimes(2);
});
it('starts the next rebuild as each one finishes', async () => {
const rows = Array.from({ length: 5 }, (_, i) => ({ id: i + 1 }));
db.mockReturnValue({
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
});
await service.invalidateAll();
jest.runAllTimers();
await flush();
expect(service.generateZip).toHaveBeenCalledTimes(2);
release.shift()();
await flush();
expect(service.generateZip).toHaveBeenCalledTimes(3);
expect(peak).toBe(2);
while (release.length) { release.shift()(); await flush(); }
expect(service.generateZip).toHaveBeenCalledTimes(5);
expect(peak).toBe(2);
});
it('does not queue a foreground download behind the burst', async () => {
const rows = Array.from({ length: 6 }, (_, i) => ({ id: i + 1 }));
db.mockReturnValue({
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
});
await service.invalidateAll();
jest.runAllTimers();
await flush();
expect(service.generateZip).toHaveBeenCalledTimes(2);
// A guest asking for a zip right now calls generateZip directly. It must
// not park behind the two rebuilds already holding the slots.
service.generateZip(999);
await flush();
expect(service.generateZip).toHaveBeenCalledWith(999);
expect(inFlight).toBe(3);
});
it('releases anything parked for a slot on shutdown', async () => {
const rows = Array.from({ length: 6 }, (_, i) => ({ id: i + 1 }));
db.mockReturnValue({
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
});
await service.invalidateAll();
jest.runAllTimers();
await flush();
expect(service.regenWaiters.length).toBeGreaterThan(0);
// stop() must not hang on a queue that will never drain.
const stopping = service.stop();
release.forEach((fn) => fn());
await expect(stopping).resolves.toBeUndefined();
expect(service.regenWaiters).toHaveLength(0);
});
});
@@ -0,0 +1,191 @@
/**
* A failed pre-zip build must not leave storage reads open.
*
* The builder opened one storage read per photo and handed the raw stream to
* archiver. archiver drains its queue one entry at a time, so on an S3 backend
* every photo beyond the one being written parked a socket with a full receive
* buffer, and the error path (a source stream dying, or a photo upload
* invalidating the build) walked away from all of them. archiver's abort()
* does not touch the source streams, and the AWS SDK arms its socket timeout
* on a 3s delay then clears it once the response headers arrive, so nothing
* ever reclaimed those sockets. On a live server 43 of the 50 pooled sockets
* ended up stuck for days and photo uploads stopped completing.
*/
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-zipleak-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'zipleak-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-zipleak-storage-'));
const { Readable } = require('stream');
const PHOTO_COUNT = 6;
const MAX_INFLIGHT_READS = 2;
// One storage read. It never ends on its own, which is what a large photo
// looks like to the builder: the bytes only move while archiver pulls them.
class StoredObject extends Readable {
constructor(key, failAfterReads, chunks) {
super();
this.key = key;
this.failAfterReads = failAfterReads;
this.chunks = chunks;
this.reads = 0;
}
_read() {
this.reads += 1;
if (this.failAfterReads && this.reads > this.failAfterReads) {
// What a dropped connection to S3 looks like in Node.
this.destroy(new Error('aborted'));
return;
}
this.push(this.reads > this.chunks ? null : Buffer.alloc(4096, 1));
}
}
const reads = { opened: [], live: 0, peak: 0 };
const failingKey = { value: null };
const onOpen = { fn: null };
// A read only finishes when the build pulls the whole object. Photos big
// enough to matter never finish inside one archiver turn, and a stream that
// ends on its own would be auto-destroyed and hide the leak.
const objectChunks = { value: Number.POSITIVE_INFINITY };
function openStoredObject(key) {
const stream = new StoredObject(key, key === failingKey.value ? 1 : 0, objectChunks.value);
reads.opened.push(stream);
reads.live += 1;
if (reads.live > reads.peak) reads.peak = reads.live;
let settled = false;
const settle = () => { if (!settled) { settled = true; reads.live -= 1; } };
stream.once('end', settle);
stream.once('close', settle);
if (onOpen.fn) onOpen.fn(reads.opened.length);
return stream;
}
const mockStorage = {
kind: () => 's3',
get: jest.fn(async (key) => openStoredObject(key)),
getToFile: jest.fn(async () => undefined),
putFromFile: jest.fn(async () => undefined),
stat: jest.fn(async () => ({ size: 1234, mtime: new Date() })),
delete: jest.fn(async () => undefined),
exists: jest.fn(async () => true),
};
jest.mock('../../src/services/storage', () => ({
getStorage: () => mockStorage,
initStorage: async () => mockStorage,
}));
// Nothing to resize or watermark, so the builder takes the stream-from-storage
// branch, which is the one that holds sockets.
jest.mock('../../src/services/downloadRendition', () => ({
renderPhotoForDownload: jest.fn(async () => null),
}));
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const downloadZipService = require('../../src/services/downloadZipService');
describe('pre-zip build releases its storage reads', () => {
let db; let cleanup; let eventId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const ev = await db('events').insert({
slug: 'zipleak',
event_type: 'wedding',
event_name: 'Zip Leak',
event_date: '2026-09-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: '/gallery/zipleak/s',
share_token: 'zipleak-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
require_password: 0,
allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = ev[0]?.id ?? ev[0];
for (let i = 0; i < PHOTO_COUNT; i += 1) {
await db('photos').insert({
event_id: eventId,
filename: `photo-${i}.jpg`,
path: `zipleak/photo-${i}.jpg`,
type: 'individual',
source_origin: 'managed',
mime_type: 'image/jpeg',
visibility: 'visible',
uploaded_at: new Date(Date.now() - i * 1000).toISOString(),
});
}
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(() => {
reads.opened = [];
reads.live = 0;
reads.peak = 0;
failingKey.value = null;
onOpen.fn = null;
objectChunks.value = Number.POSITIVE_INFINITY;
mockStorage.get.mockClear();
downloadZipService.versions.clear();
downloadZipService.activeBuilds.clear();
});
it('destroys every open read when a source stream dies mid-build', async () => {
// The oldest photo is written first, so failing it strands the rest.
failingKey.value = 'events/active/zipleak/photo-0.jpg';
const result = await downloadZipService.generateZip(eventId);
expect(result.success).toBe(false);
expect(reads.opened.length).toBeGreaterThan(1);
const stranded = reads.opened.filter((s) => !s.destroyed);
expect(stranded.map((s) => s.key)).toEqual([]);
});
it('destroys every open read when an upload invalidates the build', async () => {
// What adminPhotos does on every upload, delete and bulk edit, landing
// while the archive is half built.
onOpen.fn = (count) => {
if (count !== 2) return;
downloadZipService.invalidate(eventId);
// invalidate() also schedules a rebuild; this test is not about that.
clearTimeout(downloadZipService.debounceTimers.get(eventId));
downloadZipService.debounceTimers.delete(eventId);
};
const result = await downloadZipService.generateZip(eventId);
expect(result).toEqual({ success: false, error: 'Build invalidated' });
expect(reads.opened.filter((s) => !s.destroyed).map((s) => s.key)).toEqual([]);
});
it('never holds more storage reads open than the build needs', async () => {
objectChunks.value = 8;
const result = await downloadZipService.generateZip(eventId);
expect(result.success).toBe(true);
expect(mockStorage.get).toHaveBeenCalledTimes(PHOTO_COUNT);
expect(reads.peak).toBeLessThanOrEqual(MAX_INFLIGHT_READS);
});
});
@@ -0,0 +1,121 @@
/**
* Passwords leave the email archive once a row is final (see
* utils/emailSecretRedaction.js). The gallery-created email carries the
* gallery password and the client PIN; after the mail is out or after the
* row is out of retries neither survives in email_data or rendered_html.
*/
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-mailredact-')), 'db.sqlite');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mailredact-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-mailredact-storage-'));
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { MASK } = require('../../src/utils/emailSecretRedaction');
function stubWebhookTransport(impl) {
const transport = require('../../src/services/emailWebhookTransport');
const savedFrom = process.env.EMAIL_FROM;
process.env.EMAIL_FROM = 'noreply@example.com';
const mails = [];
const enabled = jest.spyOn(transport, 'isEnabled').mockReturnValue(true);
// mockRestore() wipes mock.calls, so keep our own copy of what went out
const send = jest.spyOn(transport, 'send').mockImplementation(async (mail) => { mails.push(mail); return impl(mail); });
return { mails, restore() { enabled.mockRestore(); send.mockRestore(); if (savedFrom === undefined) delete process.env.EMAIL_FROM; else process.env.EMAIL_FROM = savedFrom; } };
}
describe('email archive redaction', () => {
let db; let cleanup; let eventId;
const PASSWORD = 'Sunset-42!'; const PIN = '7788';
const queue = (extra = {}) => db('email_queue').insert({
event_id: eventId, recipient_email: 'client@example.com', email_type: 'gallery_created',
email_data: JSON.stringify({
customer_name: 'Ada', host_name: 'Ada', event_name: 'Redaction Wedding', event_date: '2026-09-07',
gallery_link: 'https://photos.example/gallery/redaction-wedding/tok', gallery_password: PASSWORD,
client_link: 'https://photos.example/gallery/redaction-wedding/client-access?token=abc', client_password: PIN,
expiry_date: null, welcome_message: '',
}),
status: 'pending', created_at: new Date().toISOString(), scheduled_at: new Date().toISOString(), retry_count: 0,
...extra,
}).returning('id').then((r) => r[0]?.id ?? r[0]);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const ins = await db('events').insert({
slug: 'redaction-wedding', event_type: 'wedding', event_name: 'Redaction Wedding', event_date: '2026-09-07',
customer_email: 'client@example.com', customer_name: 'Ada', password_hash: 'x', share_link: '/gallery/redaction-wedding/tok',
share_token: 'tok', expires_at: new Date(Date.now() + 86400000).toISOString(), is_active: true, created_at: new Date().toISOString(),
}).returning('id');
eventId = ins[0]?.id ?? ins[0];
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('scrubs the variables and the rendered body once the mail is out', async () => {
const id = await queue();
const stub = stubWebhookTransport(async () => ({ messageId: 'sent-1' }));
try {
const { processEmailQueue } = require('../../src/services/emailProcessor');
await processEmailQueue({ ignoreSchedule: true, onlyId: id });
} finally { stub.restore(); }
const row = await db('email_queue').where('id', id).first();
expect(row.status).toBe('sent');
const data = JSON.parse(row.email_data);
expect(data.gallery_password).toBe(MASK);
expect(data.client_password).toBe(MASK);
expect(data.customer_name).toBe('Ada');
expect(row.rendered_html).toBeTruthy();
expect(row.rendered_html).not.toContain(PASSWORD);
expect(row.rendered_html).not.toContain(PIN);
expect(row.rendered_html).toContain(MASK);
// the mail itself went out with the real password; only the archive lost it
expect(stub.mails).toHaveLength(1);
expect(String(stub.mails[0].html)).toContain(PASSWORD);
expect(String(stub.mails[0].html)).not.toContain(MASK);
// Messages "resend" copies the archived variables into a new pending
// row and "retry" re-queues the row itself: both mails must say the
// password is not shown rather than print the mask (or the password).
const { resendEmail, retryEmail } = require('../../src/services/projectService');
const resent = await resendEmail(id);
await retryEmail(id);
for (const rowId of [resent.id, id]) {
const stub2 = stubWebhookTransport(async () => ({ messageId: `sent-again-${rowId}` }));
try {
const { processEmailQueue } = require('../../src/services/emailProcessor');
await processEmailQueue({ ignoreSchedule: true, onlyId: rowId });
} finally { stub2.restore(); }
expect(stub2.mails).toHaveLength(1);
const html = String(stub2.mails[0].html);
expect(html).not.toContain(MASK);
expect(html).not.toContain(PASSWORD);
expect(html).not.toContain(PIN);
expect(html).not.toContain('{{password_security_message}}');
expect(html).toContain('security');
// archived again without the password (mask or sentinel, never the value)
expect(JSON.parse((await db('email_queue').where('id', rowId).first()).email_data).gallery_password).not.toBe(PASSWORD);
}
});
it('keeps the variables in the clear while the row can still be retried', async () => {
const id = await queue({ retry_count: 1 });
const stub = stubWebhookTransport(async () => { throw new Error('transport down'); });
try {
const { processEmailQueue } = require('../../src/services/emailProcessor');
await processEmailQueue({ ignoreSchedule: true, onlyId: id });
let row = await db('email_queue').where('id', id).first();
expect(row.retry_count).toBe(2);
expect(JSON.parse(row.email_data).gallery_password).toBe(PASSWORD);
// out of retries — a Messages "retry" resets the counter and this row
// must still be able to send the real password
await processEmailQueue({ ignoreSchedule: true, onlyId: id });
row = await db('email_queue').where('id', id).first();
expect(row.retry_count).toBe(3);
expect(row.status).not.toBe('sent');
expect(JSON.parse(row.email_data).gallery_password).toBe(PASSWORD);
} finally { stub.restore(); }
});
});
@@ -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,361 @@
/**
* External-media folder watcher (issue 1187).
*
* Drives the real service against a real temp folder and the real import
* pass (sharp and thumbnail generation mocked, as in the other external
* import suites). Covers the contracts the feature rests on:
*
* - only events that opted in, are in reference mode, live and not
* archived get a watcher, and reconcile() follows the row both ways;
* - the timer sweep imports what appeared since the last pass, through the
* same code path as the Import button, and never deletes;
* - a change in the folder triggers a debounced import on its own;
* - a claim held elsewhere makes the watcher stand down rather than walk
* the tree a second time;
* - quiet system passes stay out of the activity log, real imports go in.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
// Fixture files are written "in the past": an automatic pass leaves a file
// modified inside the settle window for the next pass (that is the point of
// the check), so anything a test expects to be imported straight away must
// not look like a copy still in flight.
const writeOld = async (file, content) => {
await fs.promises.writeFile(file, content);
const old = new Date(Date.now() - 60000);
await fs.promises.utimes(file, old, old);
};
const waitFor = async (predicate, { timeoutMs = 8000, stepMs = 50 } = {}) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) return true;
await new Promise((r) => setTimeout(r, stepMs));
}
return false;
};
describe('externalMediaWatcher (issue 1187)', () => {
let tmpDir; let mediaRoot; let db; let watcher; let jobState;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extwatch-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
await fs.promises.mkdir(path.join(mediaRoot, 'other'), { recursive: true });
await writeOld(path.join(mediaRoot, 'nas', 'individual', 'a.jpg'), 'not-a-real-jpeg');
process.env.NODE_ENV = 'test';
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extwatch-secret';
// Short timers, and stat-polling so the change test does not depend on
// the host's inotify/FSEvents behaviour for a temp directory.
process.env.EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS = '100';
process.env.EXTERNAL_MEDIA_WATCH_STABILITY_MS = '150';
process.env.EXTERNAL_MEDIA_WATCH_POLLING = 'true';
process.env.EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS = '100';
process.env.EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS = '0';
process.env.EXTERNAL_MEDIA_WATCH_RECONCILE_INTERVAL_MS = '3600000';
jest.resetModules();
jest.doMock('sharp', () => () => ({ metadata: async () => ({ width: 100, height: 200 }) }));
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'),
extractCaptureDate: jest.fn(async () => null),
orientedDimensions: (m) => ({ width: m.width, height: m.height }),
ensureThumbnail: jest.fn(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('../integration/helpers/crmDb').bootCrmDb());
watcher = require('../../src/services/externalMediaWatcher');
jobState = require('../../src/services/maintenanceJobState');
}, 180000);
afterAll(async () => {
await watcher.stopExternalMediaWatcher();
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
await watcher.stopExternalMediaWatcher();
await db('activity_logs').del();
await db('photos').del();
await db('external_import_exclusions').del();
await db('events').del();
});
async function seedEvent(overrides = {}) {
const [e] = await db('events').insert({
slug: `extwatch-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'extwatch',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `extwatch-${Math.random()}`,
expires_at: new Date().toISOString(),
source_mode: 'reference',
external_path: 'nas',
external_watch: 1,
is_active: 1,
is_archived: 0,
...overrides,
}).returning('id');
return typeof e === 'object' ? e.id : e;
}
const relpaths = async (eventId) => (await db('photos').where({ event_id: eventId }).select('external_relpath'))
.map((r) => r.external_relpath).sort();
it('lists only reference events that opted in, are active and not archived', async () => {
const watched = await seedEvent();
await seedEvent({ external_watch: 0 });
await seedEvent({ source_mode: 'managed', external_path: null });
await seedEvent({ is_archived: 1 });
await seedEvent({ is_active: 0 });
const rows = await watcher.listWatchedEvents();
expect(rows.map((r) => r.id)).toEqual([watched]);
});
it('reconcile starts a watcher for an opted-in event, imports once, and stops it when the row changes', async () => {
const eventId = await seedEvent();
await watcher.reconcile();
expect(watcher.watchedEventIds()).toEqual([eventId]);
// Ticking the box is enough: what is already in the folder comes in now,
// not at the next sweep.
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'a.jpg')]);
// A folder that does not exist is not watched — and is retried, not failed.
await db('events').where('id', eventId).update({ external_path: 'does-not-exist' });
await watcher.reconcile();
expect(watcher.watchedEventIds()).toEqual([]);
await db('events').where('id', eventId).update({ external_path: 'nas' });
await watcher.reconcile();
expect(watcher.watchedEventIds()).toEqual([eventId]);
await db('events').where('id', eventId).update({ external_watch: 0 });
await watcher.reconcile();
expect(watcher.watchedEventIds()).toEqual([]);
});
it('sweep imports new files through the shared import pass and never deletes', async () => {
const eventId = await seedEvent();
await watcher.reconcile();
await watcher.sweep();
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'a.jpg')]);
// An import that changed something is logged, with the system actor ...
expect(await db('activity_logs').where({ activity_type: 'external_import_completed' }).count('* as n').first())
.toMatchObject({ n: 1 });
await writeOld(path.join(mediaRoot, 'nas', 'individual', 'b.jpg'), 'also-not-a-jpeg');
await fs.promises.unlink(path.join(mediaRoot, 'nas', 'individual', 'a.jpg'));
await watcher.sweep();
// ... b.jpg is in, a.jpg's row is kept although the file is gone.
expect(await relpaths(eventId)).toEqual([
path.join('nas', 'individual', 'a.jpg'),
path.join('nas', 'individual', 'b.jpg'),
]);
const logs = await db('activity_logs').where({ activity_type: 'external_import_completed' });
expect(logs).toHaveLength(2);
expect(logs.every((l) => l.actor_type === 'system')).toBe(true);
// ... a quiet pass is not: no third entry.
await watcher.sweep();
expect(await db('activity_logs').where({ activity_type: 'external_import_completed' }).count('* as n').first())
.toMatchObject({ n: 2 });
// Leave the folder as the next test expects it.
await fs.promises.unlink(path.join(mediaRoot, 'nas', 'individual', 'b.jpg'));
await writeOld(path.join(mediaRoot, 'nas', 'individual', 'a.jpg'), 'not-a-real-jpeg');
});
it('a file appearing in the folder triggers a debounced import on its own', async () => {
const eventId = await seedEvent();
await watcher.reconcile();
await watcher.sweep();
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'a.jpg')]);
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', 'c.jpg'), 'new-arrival');
const arrived = await waitFor(async () => (await relpaths(eventId)).includes(path.join('nas', 'individual', 'c.jpg')));
expect(arrived).toBe(true);
await fs.promises.unlink(path.join(mediaRoot, 'nas', 'individual', 'c.jpg'));
}, 20000);
it('stands down while another runner holds the claim for the event', async () => {
const eventId = await seedEvent();
const { jobNameFor } = require('../../src/services/externalImportService');
await jobState.ensure(jobNameFor(eventId));
const token = await jobState.claim(jobNameFor(eventId));
expect(token).toBeTruthy();
expect(await watcher.runImport(eventId, 'sweep')).toBeNull();
expect(await relpaths(eventId)).toEqual([]);
await jobState.release(jobNameFor(eventId), token);
const result = await watcher.runImport(eventId, 'sweep');
expect(result).toMatchObject({ imported: 1 });
});
it('does not bring back a photo an admin deleted, until the manual Import asks for it', async () => {
const eventId = await seedEvent();
const { importExternalFolder, recordExclusions } = require('../../src/services/externalImportService');
await watcher.reconcile();
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'a.jpg')]);
// The delete routes record the exclusion before removing the row.
const [row] = await db('photos').where({ event_id: eventId });
await recordExclusions(eventId, [row]);
await db('photos').where({ id: row.id }).del();
const swept = await watcher.runImport(eventId, 'sweep');
expect(swept).toMatchObject({ imported: 0, excluded: 1 });
expect(await relpaths(eventId)).toEqual([]);
// Pressing Import is the explicit intent: the file comes back and the
// exclusion is cleared, so later automatic passes keep it.
const manual = await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'admin' } });
expect(manual).toMatchObject({ imported: 1 });
expect(await db('external_import_exclusions').where({ event_id: eventId })).toHaveLength(0);
});
it('leaves a file that is still changing for the next pass', async () => {
const eventId = await seedEvent();
const { importExternalFolder } = require('../../src/services/externalImportService');
const growing = path.join(mediaRoot, 'nas', 'individual', 'growing.jpg');
await fs.promises.writeFile(growing, 'part-one');
// mtime is "now", inside the settle window: deferred, not inserted.
const first = await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'system' }, automatic: true, settleMs: 300 });
expect(first).toMatchObject({ imported: 1, deferred: 1 });
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'a.jpg')]);
// Old mtime but the size moves during the wait: still deferred.
const old = new Date(Date.now() - 60000);
await fs.promises.utimes(growing, old, old);
const grow = setTimeout(() => fs.promises.appendFile(growing, '-part-two').then(() => fs.promises.utimes(growing, old, old)), 100);
const second = await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'system' }, automatic: true, settleMs: 300 });
clearTimeout(grow);
expect(second).toMatchObject({ imported: 0, deferred: 1 });
// Quiet now: imported.
await fs.promises.utimes(growing, old, old);
const third = await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'system' }, automatic: true, settleMs: 300 });
expect(third).toMatchObject({ imported: 1, deferred: 0 });
await fs.promises.unlink(growing);
});
it('a photo deleted while the pass is settling stays deleted', async () => {
const eventId = await seedEvent();
const { importExternalFolder, recordExclusions } = require('../../src/services/externalImportService');
await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'admin' } });
const [row] = await db('photos').where({ event_id: eventId });
// A new file makes the pass wait for the settle window; inside that
// window the admin deletes a.jpg. The snapshot taken before the wait
// saw a.jpg as present, so only a per-file check can keep it out.
await writeOld(path.join(mediaRoot, 'nas', 'individual', 'd.jpg'), 'new-file');
setTimeout(async () => {
await recordExclusions(eventId, [row]);
await db('photos').where({ id: row.id }).del();
}, 100);
const result = await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'system' }, automatic: true, settleMs: 400 });
expect(result).toMatchObject({ imported: 1, excluded: 1 });
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'd.jpg')]);
await fs.promises.unlink(path.join(mediaRoot, 'nas', 'individual', 'd.jpg'));
});
it('records an exclusion for a replaced external photo too', async () => {
const eventId = await seedEvent();
const { importExternalFolder, recordExclusions } = require('../../src/services/externalImportService');
await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'admin' } });
const [row] = await db('photos').where({ event_id: eventId });
// photoReplacementService flips the row to managed but keeps the relpath.
await db('photos').where({ id: row.id }).update({ source_origin: 'managed' });
const replaced = await db('photos').where({ id: row.id }).first();
await recordExclusions(eventId, [replaced]);
await db('photos').where({ id: row.id }).del();
expect(await watcher.runImport(eventId, 'sweep')).toMatchObject({ imported: 0, excluded: 1 });
expect(await relpaths(eventId)).toEqual([]);
});
it('an automatic pass stops when the event stopped qualifying since it was scheduled', async () => {
const eventId = await seedEvent();
const { importExternalFolder } = require('../../src/services/externalImportService');
await db('events').where('id', eventId).update({ external_watch: 0 });
expect(await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'system' }, automatic: true }))
.toMatchObject({ imported: 0 });
expect(await relpaths(eventId)).toEqual([]);
});
it('an automatic pass never rewrites the event folder, and stops if it moved', async () => {
const eventId = await seedEvent();
const { importExternalFolder } = require('../../src/services/externalImportService');
// The pass was started for 'nas' but the admin has since pointed the
// event at 'other': nothing imported, row untouched.
await db('events').where('id', eventId).update({ external_path: 'other' });
const result = await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'system' }, automatic: true });
expect(result).toMatchObject({ imported: 0 });
expect(await relpaths(eventId)).toEqual([]);
expect((await db('events').where('id', eventId).first()).external_path).toBe('other');
// The manual Import is what writes the folder onto the event.
await importExternalFolder({ eventId, externalPath: 'nas', actor: { type: 'admin' } });
expect((await db('events').where('id', eventId).first()).external_path).toBe('nas');
});
it('re-arms itself for a file that was deferred, so a disabled sweep is not needed', async () => {
const eventId = await seedEvent();
// Written just now: the pass that starts with the watcher defers it.
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', 'e.jpg'), 'fresh');
await watcher.reconcile();
expect(await relpaths(eventId)).toEqual([path.join('nas', 'individual', 'a.jpg')]);
const arrived = await waitFor(async () => (await relpaths(eventId)).includes(path.join('nas', 'individual', 'e.jpg')));
expect(arrived).toBe(true);
await fs.promises.unlink(path.join(mediaRoot, 'nas', 'individual', 'e.jpg'));
}, 20000);
it('skips an event that was archived or deactivated after it was scheduled', async () => {
const archived = await seedEvent();
await db('events').where('id', archived).update({ is_archived: 1 });
expect(await watcher.runImport(archived, 'change')).toBeNull();
const inactive = await seedEvent();
await db('events').where('id', inactive).update({ is_active: 0 });
expect(await watcher.runImport(inactive, 'sweep')).toBeNull();
expect(await db('photos').count('* as n').first()).toMatchObject({ n: 0 });
});
it('follows the row at run time: an event that opted out since scheduling is skipped', async () => {
const eventId = await seedEvent();
await db('events').where('id', eventId).update({ external_watch: 0 });
expect(await watcher.runImport(eventId, 'change')).toBeNull();
expect(await relpaths(eventId)).toEqual([]);
});
});
@@ -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(() => {});
}
});
});
});
@@ -102,6 +102,7 @@ jest.mock('../../src/utils/logger', () => ({
}));
const invoiceService = require('../../src/services/invoiceService');
const emailProcessor = require('../../src/services/emailProcessor');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
@@ -261,7 +262,10 @@ describe('invoiceService.releaseForDelivery', () => {
});
describe('invoiceService.recordPaymentCheckAction', () => {
beforeEach(() => resetChains());
beforeEach(() => {
resetChains();
emailProcessor.queueEmail.mockClear();
});
it('rejects invalid actions', async () => {
await expect(invoiceService.recordPaymentCheckAction({
@@ -321,6 +325,71 @@ describe('invoiceService.recordPaymentCheckAction', () => {
token: 'a'.repeat(64), action: 'partial', amountMinor: 9999,
})).rejects.toMatchObject({ statusCode: 400 });
});
// GHSA-wg94-f86h-vq68 hardening: every write via this unauthenticated
// route notifies the admin. Uses 'paid_full' as the exercised action —
// it stays inside markPaid (no workflow-engine / PDF-rendering
// dependencies to stub) while still going through the full
// recordPaymentCheckAction write path.
it('queues an admin notification email after a successful action', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() + 86400000),
};
pickChainFor('invoices')._firstValue = {
id: 5, invoice_number: 'INV-0005', status: 'overdue',
total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
customer_account_id: 7, created_by_admin_id: 42,
currency: 'CHF', language: 'de', event_id: null,
};
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
pickChainFor('business_profile')._firstValue = null;
pickChainFor('customer_accounts')._firstValue = { id: 7, email: 'c@example.com', display_name: 'Test Customer' };
const result = await invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'paid_full', ip: '203.0.113.7',
});
expect(result).toEqual({ applied: 'paid_full' });
expect(emailProcessor.queueEmail).toHaveBeenCalledTimes(1);
const [, recipientEmail, templateKey, data] = emailProcessor.queueEmail.mock.calls[0];
expect(recipientEmail).toBe('admin@example.com');
expect(templateKey).toBe('invoice_payment_check_action_recorded');
expect(data.invoice_number).toBe('INV-0005');
expect(data.action).toBe('paid_full');
expect(data.ip).toBe('203.0.113.7');
});
it('does not fail (or roll back) the ledger write when the admin notification fails to send', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() + 86400000),
};
pickChainFor('invoices')._firstValue = {
id: 5, invoice_number: 'INV-0005', status: 'overdue',
total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
customer_account_id: 7, created_by_admin_id: 42,
currency: 'CHF', language: 'de', event_id: null,
};
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
pickChainFor('business_profile')._firstValue = null;
pickChainFor('customer_accounts')._firstValue = { id: 7, email: 'c@example.com', display_name: 'Test Customer' };
emailProcessor.queueEmail.mockRejectedValueOnce(new Error('smtp down'));
// The write itself (token consumption + markPaid) must still
// succeed — the notification is best-effort only.
const result = await invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'paid_full', ip: '203.0.113.7',
});
expect(result).toEqual({ applied: 'paid_full' });
// Token was actually consumed (the real assertion that the write
// committed): the mock chain's .update() ran with used_at set.
const tokenChain = pickChainFor('invoice_payment_check_tokens');
expect(tokenChain.update).toHaveBeenCalledWith(
expect.objectContaining({ used_at: expect.any(Date), used_action: 'paid_full' }),
);
});
});
describe('invoiceService.queuePaymentCheckEmail', () => {
@@ -371,4 +440,35 @@ describe('invoiceService.queuePaymentCheckEmail', () => {
expect(res.sent).toBe(true);
expect(res.token).toMatch(/^[a-f0-9]{64}$/);
});
// GHSA-wg94-f86h-vq68 hardening: token TTL shortened from 30 days to 72h.
it('mints a token with a ~72h TTL, not the old 30-day window', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'overdue',
customer_account_id: 5,
created_by_admin_id: 42,
total_amount_minor: 10000,
currency: 'CHF',
language: 'de',
reminder_level: 0,
due_date: '2026-05-01',
last_payment_check_at: null,
event_id: null,
};
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
pickChainFor('business_profile')._firstValue = null;
pickChainFor('customer_accounts')._firstValue = { id: 5, email: 'c@example.com', display_name: 'Test' };
const before = Date.now();
const res = await invoiceService.queuePaymentCheckEmail(1);
expect(res.sent).toBe(true);
const tokenChain = pickChainFor('invoice_payment_check_tokens');
const insertedRow = tokenChain.insert.mock.calls[0][0];
const ttlMs = new Date(insertedRow.expires_at).getTime() - before;
expect(ttlMs).toBeGreaterThan(71 * 60 * 60 * 1000);
expect(ttlMs).toBeLessThanOrEqual(72 * 60 * 60 * 1000 + 5000);
// Well under the old 30-day TTL — the actual regression guard.
expect(ttlMs).toBeLessThan(24 * 60 * 60 * 1000 * 30);
});
});
@@ -92,6 +92,52 @@ describe('mfaService — TOTP verification', () => {
});
});
describe('mfaService — replay protection (GHSA-qcwx-r25m-j869)', () => {
it('verifyTotp accepts a code once and rejects the same code as a replay', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
// First use: no lastUsedStep yet, so it's accepted.
expect(mfaService.verifyTotp(code, secret)).toBe(true);
// Simulate persisting the matched step and replaying the same code: the
// matched step must strictly advance past lastUsedStep, so this fails.
const step = mfaService.currentTotpStep();
expect(mfaService.verifyTotp(code, secret, step)).toBe(false);
// A lastUsedStep the code hasn't caught up to yet also rejects it.
expect(mfaService.verifyTotp(code, secret, step + 1)).toBe(false);
});
it('verifyTotpEncryptedStep returns the matched step on success and null on replay', () => {
const secret = mfaService.generateSecret();
const stored = mfaService.encryptSecret(secret);
const code = authenticator.generate(secret);
const step = mfaService.verifyTotpEncryptedStep(code, stored, null);
expect(step).toEqual(expect.any(Number));
expect(step).toBeGreaterThan(0);
// Replaying the same code against the just-persisted step is rejected.
expect(mfaService.verifyTotpEncryptedStep(code, stored, step)).toBeNull();
});
it('a freshly generated code for the next TOTP step is accepted after a replay is rejected', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
const step = mfaService.verifyTotpEncryptedStep(code, mfaService.encryptSecret(secret), null)
|| mfaService.currentTotpStep();
// Same-step replay: rejected.
expect(mfaService.verifyTotp(code, secret, step)).toBe(false);
// A code minted for the next step (via a cloned authenticator with a
// future epoch, not by mocking Date.now()) advances past last_used_step.
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
const nextCode = nextStepAuthenticator.generate(secret);
expect(mfaService.verifyTotp(nextCode, secret, step)).toBe(true);
});
});
describe('mfaService — otpauth URI / QR', () => {
it('builds an otpauth:// URI containing issuer, account and secret', () => {
const secret = mfaService.generateSecret();
@@ -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,173 @@
/**
* GHSA-xfvx-j447-732c: the SQLite restore path let an attacker-influenced
* `manifest.database.backup_file` replace the live database.
*
* Two independent bugs, both fixed here:
*
* 1. Candidate resolution (restoreService.js's performDatabaseRestore,
* ~L1000) tried an absolute `dbBackupFile` and a
* `path.join(backupPath, dbBackupFile)` candidate with NO check that
* the resolved path actually stayed inside the configured backup
* root a manifest could point `.restore` at any file on disk.
*
* 2. The resolved path was interpolated unescaped into a
* `sqlite3 .restore '<path>'` dot-command string. sqlite3's CLI
* parses that string itself (not the shell), so a single quote in
* the path breaks out of the quoted argument regardless of
* spawn()'s `shell: false` argv separation.
*
* These tests pin the fix directly against the exported helpers
* (`resolveContainedDbBackupCandidates`, `assertSafeSqlitePath`,
* `isContainedInRoots`, `getConfiguredBackupRoots`) the exact functions
* `performDatabaseRestore` calls before ever running `sqlite3 .restore`
* rather than driving the full restore (which does a real `db.destroy()` +
* live-file swap against the shared app db and isn't worth the added
* fragility for what's fundamentally a path-validation contract).
*/
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-restoresvc-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restoresvc-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('restoreService — sqlite restore path safety (GHSA-xfvx)', () => {
let db; let cleanup; let _internal;
let backupPath;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// The restore run's resolved local backup root — analogous to
// `localBackupPath` in restoreService.restore(). Real directory with a
// real database/ subfolder, matching what a genuine backup run leaves
// on disk.
backupPath = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-xfvx-backuproot-'));
fs.mkdirSync(path.join(backupPath, 'database'), { recursive: true });
({ _internal } = require('../../src/services/restoreService'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('assertSafeSqlitePath — the sqlite3 dot-command injection gate', () => {
it.each([
['/backup/database/picpeak-db-sqlite-1.sql'],
[`${backupPath || '/backup'}/database/picpeak-db-sqlite-2024-01-01.sql.gz`],
])('accepts a normal backup path: %s', (p) => {
expect(() => _internal.assertSafeSqlitePath(p)).not.toThrow();
});
it.each([
['/backup/database/x\'; DROP TABLE admin_users; --.sql'],
['/backup/database/x\' .restore \'/etc/passwd'],
['/backup/database/x\n.shell rm -rf /'],
['/backup/database/has space.sql'],
['/backup/database/semi;colon.sql'],
[null],
[undefined],
[42],
])('rejects an unsafe/non-string path: %j', (p) => {
expect(() => _internal.assertSafeSqlitePath(p)).toThrow(/unsafe path/i);
});
});
describe('isContainedInRoots', () => {
it('accepts a path inside a root', () => {
expect(_internal.isContainedInRoots('/backup/database/x.sql', ['/backup'])).toBe(true);
});
it('accepts a root path equal to the root itself', () => {
expect(_internal.isContainedInRoots('/backup', ['/backup'])).toBe(true);
});
it('rejects a path outside every root', () => {
expect(_internal.isContainedInRoots('/etc/passwd', ['/backup'])).toBe(false);
});
it('rejects a sibling directory that merely shares a prefix', () => {
// '/backup-evil' starts with the string '/backup' but is NOT inside it.
expect(_internal.isContainedInRoots('/backup-evil/x.sql', ['/backup'])).toBe(false);
});
it('rejects a `..`-traversal path that resolves outside the root', () => {
expect(_internal.isContainedInRoots('/backup/../etc/passwd', ['/backup'])).toBe(false);
});
});
describe('getConfiguredBackupRoots', () => {
afterEach(async () => {
delete process.env.RESTORE_ALLOWED_ROOTS;
await db('app_settings').whereIn('setting_key', ['backup_destination_path', 'backup_manifest_path']).del();
});
it('always includes the trusted root even with nothing else configured', async () => {
const roots = await _internal.getConfiguredBackupRoots('/some/trusted/backup-path');
expect(roots).toContain(path.resolve('/some/trusted/backup-path'));
});
it('adds configured backup_destination_path / backup_manifest_path and RESTORE_ALLOWED_ROOTS', async () => {
await db('app_settings').insert([
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify('/backup/dest'), setting_type: 'backup' },
{ setting_key: 'backup_manifest_path', setting_value: JSON.stringify('/backup/manifests'), setting_type: 'backup' },
]);
process.env.RESTORE_ALLOWED_ROOTS = '/extra/root';
const roots = await _internal.getConfiguredBackupRoots('/trusted');
expect(roots).toEqual(expect.arrayContaining([
path.resolve('/trusted'),
path.resolve('/backup/dest'),
path.resolve('/backup/manifests'),
path.resolve('/extra/root'),
]));
});
});
describe('resolveContainedDbBackupCandidates — the manifest.database.backup_file gate', () => {
it('rejects an absolute backup_file outside every configured root, but still offers the safe legacy basename candidate', async () => {
const candidates = await _internal.resolveContainedDbBackupCandidates(
backupPath, '/etc/passwd', () => {}
);
// The raw absolute escape must NOT be present.
expect(candidates).not.toContain('/etc/passwd');
// Candidate (3), the basename-only legacy reconstruct, is inherently
// safe (can't escape backupPath) and stays available as a fallback.
expect(candidates).toContain(path.join(backupPath, 'database', 'passwd'));
});
it('rejects a `..`-traversal relative backup_file, keeping only the contained legacy candidate', async () => {
const candidates = await _internal.resolveContainedDbBackupCandidates(
backupPath, '../../../../etc/passwd', () => {}
);
const escaped = candidates.some((c) => !_internal.isContainedInRoots(c, [path.resolve(backupPath)]));
expect(escaped).toBe(false);
expect(candidates).toContain(path.join(backupPath, 'database', 'passwd'));
});
it('accepts a legitimate relative backup_file recorded by a real backup run', async () => {
const candidates = await _internal.resolveContainedDbBackupCandidates(
backupPath, 'database/picpeak-db-sqlite-2024-01-01.sql.gz', () => {}
);
expect(candidates).toContain(path.join(backupPath, 'database', 'picpeak-db-sqlite-2024-01-01.sql.gz'));
// Every returned candidate must actually be safe to use.
for (const c of candidates) {
expect(_internal.isContainedInRoots(c, [path.resolve(backupPath)])).toBe(true);
}
});
it('accepts a legitimate absolute backup_file that IS inside backupPath (the real dumper shape)', async () => {
const absFile = path.join(backupPath, 'database', 'picpeak-db-sqlite-2024-02-02.sql.gz');
const candidates = await _internal.resolveContainedDbBackupCandidates(
backupPath, absFile, () => {}
);
expect(candidates).toContain(absFile);
});
});
});
@@ -0,0 +1,146 @@
/**
* DNS-rebinding follow-up to the blind-SSRF fix in restoreServiceS3Ssrf.test.js
* (GHSA-vm2x-c628-3cx5).
*
* isHostAllowed()/validateExternalUrlAsync() are check-then-connect on their
* own: they resolve the S3 endpoint hostname once to vet it, then hand a
* bare hostname to the AWS SDK, which resolves it AGAIN when it actually
* connects. An attacker who controls DNS for the endpoint hostname (or an
* infra DNS-rebinding condition) can answer the first lookup with a public
* IP and the second with a private/metadata one.
*
* downloadFileFromS3() now builds pinned http/https agents (pinnedRequest.js
* the same primitive webhookDeliveryWorker.js and emailWebhookTransport.js
* use for outbound HTTP) from the validated address and passes them into
* S3StorageAdapter, which threads them into the S3Client's NodeHttpHandler
* requestHandler. This asserts that wiring: the agents S3StorageAdapter
* receives resolve the endpoint hostname to ONLY the address vetted during
* validation, and never fall through to a second, real DNS lookup that a
* rebinding attacker could answer differently.
*/
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-restores3pin-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restores3pin-test-secret';
jest.mock('dns', () => {
const actual = jest.requireActual('dns');
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() }, lookup: jest.fn() };
});
let capturedConfig;
jest.mock('../../src/services/storage/s3Storage', () =>
jest.fn().mockImplementation((config) => {
capturedConfig = config;
return { download: jest.fn().mockResolvedValue(undefined) };
})
);
const dns = require('dns');
const promiseLookup = dns.promises.lookup;
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
const { RestoreService } = require('../../src/services/restoreService');
describe('downloadFileFromS3 DNS-rebinding pinning', () => {
let restoreService;
let originalNodeEnv;
beforeEach(() => {
restoreService = new RestoreService();
capturedConfig = undefined;
promiseLookup.mockReset();
dns.lookup.mockReset();
S3StorageAdapter.mockClear();
originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
});
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
capturedConfig?.httpAgent?.destroy();
capturedConfig?.httpsAgent?.destroy();
});
it('passes pinned http/https agents into S3StorageAdapter built from the validated address', async () => {
promiseLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
await restoreService.downloadFileFromS3(
's3://backups/manifest.json',
'/tmp/whatever/manifest.json',
{ endpoint: 'rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
);
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
expect(capturedConfig.httpAgent).toBeInstanceOf(require('http').Agent);
expect(capturedConfig.httpsAgent).toBeInstanceOf(require('https').Agent);
});
it('the pinned agent never performs a second DNS lookup — rebinding to a private IP on the real resolver is ignored', async () => {
// First (validation) lookup: public IP, passes the preflight.
promiseLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
// If the pinned agent ever fell through to a real lookup, this would
// hand back a private/metadata address — simulating the rebind.
dns.lookup.mockImplementation((_hostname, options, callback) => {
if (typeof options === 'function') { callback = options; options = {}; }
callback(null, ...(options?.all ? [[{ address: '169.254.169.254', family: 4 }]] : ['169.254.169.254', 4]));
});
await restoreService.downloadFileFromS3(
's3://backups/manifest.json',
'/tmp/whatever/manifest.json',
{ endpoint: 'rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
);
const pinnedLookup = capturedConfig.httpAgent.options.lookup;
expect(typeof pinnedLookup).toBe('function');
const result = await new Promise((resolve, reject) => {
pinnedLookup('rebind.example.com', {}, (err, address, family) => {
if (err) return reject(err);
resolve({ address, family });
});
});
// Only the address vetted during validation is ever handed back —
// never the private address the real resolver would now answer with.
expect(result).toEqual({ address: '93.184.216.34', family: 4 });
expect(dns.lookup).not.toHaveBeenCalled();
});
it('rejects a lookup for any hostname other than the one that was validated', async () => {
promiseLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
await restoreService.downloadFileFromS3(
's3://backups/manifest.json',
'/tmp/whatever/manifest.json',
{ endpoint: 'rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
);
const pinnedLookup = capturedConfig.httpAgent.options.lookup;
await expect(new Promise((resolve, reject) => {
pinnedLookup('attacker-controlled.example', {}, (err, address) => {
if (err) return reject(err);
resolve(address);
});
})).rejects.toThrow(/hostname changed/i);
});
it('does not pin agents when no custom endpoint is configured (default AWS, no rebinding surface)', async () => {
await restoreService.downloadFileFromS3(
's3://backups/manifest.json',
'/tmp/whatever/manifest.json',
{ accessKeyId: 'k', secretAccessKey: 's' }
);
expect(promiseLookup).not.toHaveBeenCalled();
expect(capturedConfig.httpAgent).toBeUndefined();
expect(capturedConfig.httpsAgent).toBeUndefined();
});
});
@@ -0,0 +1,109 @@
/**
* Blind SSRF via the restore S3 download path (GHSA-vm2x-c628-3cx5).
*
* downloadFileFromS3() built a bare S3StorageAdapter and called .download()
* directly, never running the DNS-resolving isHostAllowed() guard that
* testConnection() applies elsewhere so an admin with backup.restore could
* point the request-supplied S3 endpoint at an internal/metadata address for
* unauthenticated egress via the server. `s3Config` here is fully attacker
* controlled (POST /api/admin/restore/validate and /restore/start take it
* straight from the request body see routes/adminRestore.js), unlike the
* scheduled-backup S3 endpoint, which is vetted at settings-save time.
*/
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-restores3ssrf-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restores3ssrf-test-secret';
jest.mock('dns', () => {
const actual = jest.requireActual('dns');
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() } };
});
jest.mock('../../src/services/storage/s3Storage', () =>
jest.fn().mockImplementation(() => ({
download: jest.fn().mockResolvedValue(undefined),
}))
);
const dns = require('dns');
const lookup = dns.promises.lookup;
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
const { RestoreService } = require('../../src/services/restoreService');
describe('downloadFileFromS3 SSRF guard (GHSA-vm2x-c628-3cx5)', () => {
let restoreService;
let originalNodeEnv;
beforeEach(() => {
restoreService = new RestoreService();
lookup.mockReset();
S3StorageAdapter.mockClear();
originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
});
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
});
it('rejects an endpoint hostname that resolves to a private/internal address before any network call', async () => {
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
await expect(
restoreService.downloadFileFromS3(
's3://backups/manifest.json',
'/tmp/whatever/manifest.json',
{ endpoint: 'evil-rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
)
).rejects.toThrow(/private or internal network address/i);
expect(S3StorageAdapter).not.toHaveBeenCalled();
});
it('rejects an endpoint hostname that resolves to the cloud metadata address', async () => {
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
await expect(
restoreService.downloadFileFromS3(
's3://backups/manifest.json',
'/tmp/whatever/manifest.json',
{ endpoint: 'metadata-rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
)
).rejects.toThrow(/private or internal network address/i);
expect(S3StorageAdapter).not.toHaveBeenCalled();
});
it('allows a legitimate public S3 endpoint through to download()', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
await restoreService.downloadFileFromS3(
's3://backups/manifest.json',
'/tmp/whatever/manifest.json',
{ endpoint: 's3.example-cdn.com', accessKeyId: 'k', secretAccessKey: 's' }
);
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
});
it('does not require the guard outside production (dev MinIO stays usable), but still downloads', async () => {
process.env.NODE_ENV = 'development';
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]); // would be rejected in prod
await restoreService.downloadFileFromS3(
's3://backups/manifest.json',
'/tmp/whatever/manifest.json',
{ endpoint: 'localhost:9000', accessKeyId: 'k', secretAccessKey: 's' }
);
expect(lookup).not.toHaveBeenCalled();
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
});
});
@@ -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);
});
@@ -1,8 +1,8 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const catalog = require('../../src/usage/features.v4.json');
const inventory = require('../../../docs/usage-coverage.v4.json');
const catalog = require('../../src/usage/features.v5.json');
const inventory = require('../../../docs/usage-coverage.v5.json');
const protocol = require('../../src/usage/schema.cjs');
const { RULES_V2, capabilityKeys } = require('../../src/usage/capabilityRules');
const { acceptedUpload, capabilityEvidence } = require('../../src/usage/capabilityEvidence');
@@ -52,22 +52,22 @@ test('all current settings tabs have an explicit scope decision', () => {
}
});
test('v1/v2/v3 wire validation is immutable; v4 catalog, UI and translated descriptions agree', () => {
test('v1/v2/v3 wire validation is immutable; v5 catalog, UI and translated descriptions agree', () => {
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v1'].properties)).digest('hex'))
.toBe('cc8d0a865d21e36d2b24d23ca6aa8dd8d48000cb17aef83996786f70755bc922');
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v2'].properties)).digest('hex'))
.toBe('159821cf45c1951016d33a4ed9ca55a0a7ee1b60dd715b803fcfed33e5c8a846');
expect(protocol.FEATURE_KEYS).toHaveLength(86);
expect(protocol.FEATURE_KEYS).toHaveLength(87);
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v3'].properties)).digest('hex'))
.toBe('93214702c79f47823f154544ebad6612dd313604f69e60b86de4c0e4c904571a');
expect(protocol.FEATURE_KEYS).toContain('gallery_downloads_restricted');
expect(protocol.FEATURE_KEYS).not.toContain('gallery_downloads');
expect(protocol.ALL_FEATURE_KEYS).toHaveLength(87);
expect(protocol.ALL_FEATURE_KEYS).toHaveLength(94);
expect(protocol.ALL_FEATURE_KEYS).toContain('gallery_downloads');
expect(protocol.LEGACY_FEATURE_KEYS).toHaveLength(19);
expect(inventory.configuration_only).toHaveLength(23);
const frontend = path.resolve(__dirname, '../../../frontend');
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v4.json')))).toEqual(catalog);
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v5.json')))).toEqual(catalog);
// The catalog is source, and source is English only: its strings are the
// en locale verbatim. Every other language lives in its locale file and
// must cover every key and field, but says whatever its translator chose.
@@ -85,11 +85,11 @@ test('v1/v2/v3 wire validation is immutable; v4 catalog, UI and translated descr
});
test('every used field has either a fixed route rule or explicit trusted success evidence', () => {
const explicit = ['custom_css', 'oauth', 'smtp', 'email_webhook', 'whatsapp', 'incoming_mail',
const explicit = ['cms_content_editing', 'email_template_editing', 'email_template_delivery', 'branding_editing', 'seo_editing', 'event_type_editing', 'category_editing', 'custom_css', 'oauth', 'smtp', 'email_webhook', 'whatsapp', 'incoming_mail',
'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage', 's3_backups', 'api_integration', 'photo_xmp_export', 'photo_replacement', 'photo_admin_marks', 'crm_invoice_import', 'crm_combined_billing', 'crm_monthly_billing_manual', 'crm_document_conversion'];
const covered = new Set([...explicit, ...RULES_V2.flatMap(([, , keys]) => keys)]);
expect(protocol.FEATURE_KEYS.filter((key) => protocol.observesUse(key)).filter((key) => !covered.has(key))).toEqual([]);
for (const key of covered) expect(protocol.observesUse(key)).toBe(true);
for (const key of covered) expect(protocol.ALL_FEATURES[key].used).toBeTruthy();
});
test.each([
@@ -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');
@@ -7,13 +7,13 @@ const signHistorical = (packet, id) => {
return { ...signed, signature: crypto.sign(null, Buffer.from(p.canonical(signed)), id.private_key).toString('base64url') };
};
describe.each(['usage.v1', 'usage.v2', 'usage.v3', 'usage.v4'])('%s receiver compatibility never loosens the PicPeak sender', version => {
describe.each(['usage.v1', 'usage.v2', 'usage.v3', 'usage.v4', 'usage.v5'])('%s receiver compatibility never loosens the PicPeak sender', version => {
test('complete original reports still sign and verify', () => {
const id = p.generateIdentity();
const packet = p.makePacket(id, 'report', 1, {
picpeak_version: '1.0.0', report_date: '2026-09-06', generated_at: new Date(now).toISOString(),
features: p.emptyFeatures(version), gallery_layouts: [],
...(['usage.v3', 'usage.v4'].includes(version) ? { inventory: { galleries: 0, photos: 0 } } : {}),
...(['usage.v3', 'usage.v4', 'usage.v5'].includes(version) ? { inventory: { galleries: 0, photos: 0 } } : {}),
}, version);
const envelope = p.signPacket(packet, id, new Date(now));
expect(p.verifyEnvelope(envelope, now)).toEqual(packet);
@@ -67,6 +67,14 @@ test('the collector has no way in: no inbound route and no scheduled pull', () =
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
if (entry.name === 'productUsageService.js') continue;
if (entry.name === 'emailProcessor.js') {
// v5 explicitly consents to one local background-mail bit, never a send
// to the collector. No mail details may be passed into the usage API.
const email = fs.readFileSync(path.join(dir, entry.name), 'utf8');
expect(email).toContain(".markUsed(['email_template_delivery'])");
expect(email).not.toMatch(/productUsageService'\)\.(?:tick|enable|command|deliver)/);
continue;
}
expect(fs.readFileSync(path.join(dir, entry.name), 'utf8'))
.not.toContain('productUsageService');
}
@@ -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,82 @@
const knex = require('knex');
jest.mock('nodemailer', () => ({ createTransport: jest.fn() }));
jest.mock('../../src/services/emailWebhookTransport', () => ({ isEnabled: jest.fn(), send: jest.fn() }));
jest.mock('../../src/services/productUsageService', () => ({ markUsed: jest.fn().mockResolvedValue() }));
jest.mock('../../src/services/businessProfileService', () => ({ getEmailSignature: jest.fn(async () => null) }));
describe('template delivery is a coarse transport-acceptance bit', () => {
let db, sendTemplateEmail, queueEmail, processEmailQueue;
const sendMail = jest.fn();
const webhook = require('../../src/services/emailWebhookTransport');
const marker = require('../../src/services/productUsageService').markUsed;
beforeAll(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
jest.doMock('../../src/database/db', () => ({ db }));
await db.schema.createTable('email_configs', t => {
t.increments('id'); for (const key of ['smtp_host', 'smtp_user', 'smtp_pass', 'from_name', 'from_email']) t.string(key);
t.integer('smtp_port'); t.boolean('smtp_secure'); t.boolean('tls_reject_unauthorized');
});
await db('email_configs').insert({ smtp_host: 'smtp.example.test', smtp_port: 587, from_email: 'sender@example.test' });
await db.schema.createTable('email_templates', t => { t.increments('id'); t.string('template_key'); t.text('subject'); t.text('body_html'); });
await db('email_templates').insert({ template_key: 'PRIVATE-template', subject: 'PRIVATE subject', body_html: '<p>PRIVATE body</p>' });
await db.schema.createTable('app_settings', t => { t.string('setting_key').primary(); t.text('setting_value'); });
await db.schema.createTable('email_queue', t => {
t.increments('id'); t.integer('event_id'); t.integer('campaign_id'); t.string('recipient_email'); t.string('email_type');
t.text('email_data'); t.string('status'); t.integer('retry_count'); t.text('error_message'); t.text('rendered_html');
t.timestamp('scheduled_at'); t.timestamp('created_at'); t.timestamp('sent_at');
});
require('nodemailer').createTransport.mockReturnValue({ sendMail, verify: jest.fn(async () => true) });
({ sendTemplateEmail, queueEmail, processEmailQueue } = require('../../src/services/emailProcessor'));
});
beforeEach(() => {
marker.mockClear(); marker.mockResolvedValue(); webhook.isEnabled.mockReturnValue(false);
sendMail.mockReset(); sendMail.mockResolvedValue({ messageId: 'PRIVATE-message', accepted: ['PRIVATE@example.test'] });
});
afterAll(() => db.destroy());
const send = options => sendTemplateEmail('PRIVATE@example.test', 'PRIVATE-template', { __language: 'en' }, options);
test('successful real SMTP send transmits only the fixed capability key', async () => {
await send();
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
});
test('rejected recipients, failed sends, missing templates and explicit tests do not count', async () => {
await send({ usageEligible: false });
sendMail.mockResolvedValueOnce({ messageId: 'private', accepted: [] }); await send();
sendMail.mockRejectedValueOnce(new Error('mail failed')); await expect(send()).rejects.toThrow('mail failed');
await expect(sendTemplateEmail('private@example.test', 'missing', { __language: 'en' })).rejects.toThrow('not found');
expect(marker).not.toHaveBeenCalled();
});
test('a queued test message keeps its exclusion through the queue processor; a queued real one counts', async () => {
await queueEmail(null, 'PRIVATE@example.test', 'PRIVATE-template', { __language: 'en' }, { usageEligible: false });
await processEmailQueue();
expect(sendMail).toHaveBeenCalledTimes(1);
expect(marker).not.toHaveBeenCalled();
await queueEmail(null, 'PRIVATE@example.test', 'PRIVATE-template', { __language: 'en' });
await processEmailQueue();
expect(sendMail).toHaveBeenCalledTimes(2);
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
expect(await db('email_queue').where('status', 'sent').count({ n: '*' }).first()).toMatchObject({ n: 2 });
});
test('a workflow test run (engine.testRun, __test) queues a test message; a real run counts', async () => {
require('../../src/services/workflows/actions');
const sendEmail = require('../../src/services/workflows/registry').getAction('send_email');
const ctx = (vars) => ({ node: { config: { to: 'PRIVATE@example.test', emailType: 'PRIVATE-template', recipientClass: 'admin' } }, vars });
await sendEmail(ctx({ __test: true, emailData: { __language: 'en' } }));
await processEmailQueue();
expect(sendMail).toHaveBeenCalledTimes(1);
expect(marker).not.toHaveBeenCalled();
await sendEmail(ctx({ emailData: { __language: 'en' } }));
await processEmailQueue();
expect(sendMail).toHaveBeenCalledTimes(2);
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
});
test('webhook success counts; failure does not; marker failure never retries successful mail', async () => {
webhook.isEnabled.mockReturnValue(true); webhook.send.mockResolvedValue({ messageId: 'private' });
marker.mockRejectedValueOnce(new Error('usage unavailable'));
await expect(send()).resolves.toMatchObject({ success: true });
expect(webhook.send).toHaveBeenCalledTimes(1);
marker.mockClear(); webhook.send.mockRejectedValueOnce(new Error('webhook failed'));
await expect(send()).rejects.toThrow('webhook failed');
expect(marker).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,100 @@
/**
* GHSA-h4w8-57xq-53fx entropy half: resetAdminPassword used to mint the
* emailed temp password with generateReadablePassword() 10 adjectives x
* 10 nouns x crypto.randomInt(1000,9999) x 5 specials, ~2^21 possibilities,
* brute-forceable. It now uses generateSecurePassword(16) (90-char charset),
* same as every other security-sensitive password path in this file.
*
* Verified against a real SQLite DB (full core-migration set) so the
* emailed plaintext, the stored hash, and must_change_password are all
* checked end to end rather than against a mock.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
// bootCrmDb() sets TEST_DATABASE_PATH itself, but only in time for requires
// that happen AFTER it runs (inside beforeAll). userManagementService.js
// requires database/db.js at module load — i.e. before beforeAll — so that
// connection has to be pointed at a fresh, unused test DB up front, or it
// falls back to the shared default path and collides with whatever another
// test file already migrated onto it. Same workaround as
// userManagementService.activateDelete.test.js.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-reset-pw-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'reset-pw-test-secret';
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
const userManagementService = require('../../src/services/userManagementService');
// The wordlist generateReadablePassword() used to produce:
// <Adjective><Noun><4 digits><1 special>, e.g. "SwiftEagle4821!"
const READABLE_WORDLIST_PATTERN = /^(Swift|Bright|Strong|Happy|Clever|Brave|Noble|Quick|Sharp|Bold)(Eagle|Mountain|River|Thunder|Forest|Ocean|Falcon|Dragon|Phoenix|Tiger)\d{4}[!@#$%]$/;
describe('userManagementService.resetAdminPassword (GHSA-h4w8-57xq-53fx)', () => {
let db;
let cleanup;
let actorId;
let targetId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: actorId } = await seedMinimal(db));
await assignAdminRole(db, actorId, 'super_admin');
const editor = await db('roles').where({ name: 'editor' }).first();
const targetInsert = await db('admin_users').insert({
username: 'reset-target', email: 'reset-target@example.com',
password_hash: await bcrypt.hash('old-password', 4),
role_id: editor?.id || null,
is_active: 1, must_change_password: false, created_at: new Date().toISOString(),
}).returning('id');
targetId = targetInsert[0]?.id ?? targetInsert[0];
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('generates a high-entropy password, not one drawn from the adjective/noun wordlist', async () => {
const before = await db('admin_users').where({ id: targetId }).first();
await userManagementService.resetAdminPassword(targetId, actorId);
const emailRow = await db('email_queue')
.where({ recipient_email: 'reset-target@example.com', email_type: 'admin_password_reset' })
.orderBy('id', 'desc')
.first();
expect(emailRow).toBeDefined();
const emailData = JSON.parse(emailRow.email_data);
const newPassword = emailData.new_password;
// generateSecurePassword(16): fixed 16-char length, not the wordlist's
// variable-length "WordWord####!" shape.
expect(newPassword).toHaveLength(16);
expect(newPassword).not.toMatch(READABLE_WORDLIST_PATTERN);
// generateSecurePassword guarantees at least one of each character class.
expect(newPassword).toMatch(/[a-z]/);
expect(newPassword).toMatch(/[A-Z]/);
expect(newPassword).toMatch(/[0-9]/);
expect(newPassword).toMatch(/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/);
// The emailed plaintext actually matches what got persisted.
const after = await db('admin_users').where({ id: targetId }).first();
expect(after.password_hash).not.toBe(before.password_hash);
await expect(bcrypt.compare(newPassword, after.password_hash)).resolves.toBe(true);
});
it('sets must_change_password so the enforcement backstop kicks in on next login', async () => {
await db('admin_users').where({ id: targetId }).update({ must_change_password: false });
await userManagementService.resetAdminPassword(targetId, actorId);
const after = await db('admin_users').where({ id: targetId }).first();
expect(after.must_change_password === true || after.must_change_password === 1).toBe(true);
});
});
@@ -0,0 +1,235 @@
/**
* Privilege-escalation guard for PUT /api/admin/users/:id and
* POST /api/admin/users/invite (GHSA-rv8w-m6mx-7j4q).
*
* updateAdminUser's role-change path previously enforced only:
* (a) non-super_admin actors can't grant the super_admin role
* (b) no self-role-update / demoting the last super_admin
* It never checked whether the ACTOR's own permission set covers the
* permissions carried by the role being granted so an admin holding
* only `users.edit` could hand any other admin a role (including the
* built-in `admin` role) carrying far more permissions than the actor
* itself held.
*
* createInvitation() had the identical gap: it only ever blocked
* granting super_admin, so an admin holding only `users.create` could
* invite a brand-new admin into any other role including one carrying
* far more permissions than the inviter itself held via
* POST /admin/users/invite.
*
* The fix reuses assertActorMayGrant() the same containment already
* applied to roles.manage (see adminRolesGuards.test.js) inside both
* updateAdminUser's role_id branch and createInvitation().
*
* Both describe blocks below share a single bootCrmDb() call: the
* `db` module (`src/database/db.js`) is a singleton keyed off
* TEST_DATABASE_PATH at first require, and bootCrmDb's own comment
* warns that a second call after the first's cleanup() destroys the
* pool, leaving "Unable to acquire a connection" for every later query.
*/
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-rolegrantguard-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'rolegrantguard-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-rolegrantguard-storage-'));
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
const svc = require('../../src/services/userManagementService');
const { clearPermissionCache } = require('../../src/middleware/permissions');
let db; let cleanup;
let superId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: superId } = await seedMinimal(db));
await assignAdminRole(db, superId, 'super_admin');
clearPermissionCache();
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('updateAdminUser — role-grant privilege-escalation guard (GHSA-rv8w-m6mx-7j4q)', () => {
let limitedRoleId; let limitedId; // holds only users.edit + events.view
let powerfulRoleId; // carries settings.banking, which limitedId does NOT hold
let modestRoleId; // carries only events.view, a subset of what limitedId holds
let targetId; // account whose role limitedId will try to change
beforeAll(async () => {
// The attacker in GHSA-rv8w-m6mx-7j4q: users.edit only, nothing else.
const limitedRole = await svc.createRole(
{ name: 'limited_user_editor', permissions: ['users.edit', 'events.view'] },
superId,
);
limitedRoleId = limitedRole.id;
const limitedIns = await db('admin_users').insert({
username: 'limited', email: 'limited@example.com', password_hash: 'x',
role_id: limitedRoleId, must_change_password: false, created_at: new Date(),
}).returning('id');
limitedId = limitedIns[0]?.id ?? limitedIns[0];
// A role carrying a permission the limited actor does not hold.
const powerfulRole = await svc.createRole(
{ name: 'powerful_role', permissions: ['users.edit', 'settings.banking'] },
superId,
);
powerfulRoleId = powerfulRole.id;
// A role whose permissions ARE a subset of what the limited actor holds.
const modestRole = await svc.createRole(
{ name: 'modest_role', permissions: ['events.view'] },
superId,
);
modestRoleId = modestRole.id;
clearPermissionCache();
}, 120000);
beforeEach(async () => {
// Fresh target for every test, role reset to modestRole so role-change
// assertions always start from a known baseline.
const existing = await db('admin_users').where({ username: 'target' }).first();
if (existing) {
targetId = existing.id;
await db('admin_users').where({ id: targetId }).update({ role_id: modestRoleId });
} else {
const ins = await db('admin_users').insert({
username: 'target', email: 'target@example.com', password_hash: 'x',
role_id: modestRoleId, must_change_password: false, created_at: new Date(),
}).returning('id');
targetId = ins[0]?.id ?? ins[0];
}
});
it('refuses to let an admin grant a role carrying permissions the admin lacks', async () => {
await expect(
svc.updateAdminUser(
targetId,
{ role_id: powerfulRoleId },
limitedId,
{ roleName: 'limited_user_editor' },
),
).rejects.toThrow(/only grant permissions your own role/i);
// Target's role must be unchanged.
const row = await db('admin_users').where({ id: targetId }).first();
expect(row.role_id).toBe(modestRoleId);
});
it('refuses to let an admin grant the built-in admin role beyond its own permissions', async () => {
const adminRole = await db('roles').where({ name: 'admin' }).first();
await expect(
svc.updateAdminUser(
targetId,
{ role_id: adminRole.id },
limitedId,
{ roleName: 'limited_user_editor' },
),
).rejects.toThrow(/only grant permissions your own role/i);
});
it('allows an admin to grant a role whose permissions it already holds', async () => {
const updated = await svc.updateAdminUser(
targetId,
{ role_id: limitedRoleId },
limitedId,
{ roleName: 'limited_user_editor' },
);
expect(updated.role_id).toBe(limitedRoleId);
});
it('super_admin can still grant any role, including one carrying more permissions than a limited actor holds', async () => {
const updated = await svc.updateAdminUser(
targetId,
{ role_id: powerfulRoleId },
superId,
{ roleName: 'super_admin' },
);
expect(updated.role_id).toBe(powerfulRoleId);
});
});
describe('createInvitation — role-grant privilege-escalation guard (GHSA-rv8w-m6mx-7j4q)', () => {
let limitedRoleId; let limitedId; // holds only users.create + events.view
let powerfulRoleId; // carries settings.banking, which limitedId does NOT hold
let modestRoleId; // carries only events.view, a subset of what limitedId holds
let inviteCounter = 0;
beforeAll(async () => {
const limitedRole = await svc.createRole(
{ name: 'limited_inviter', permissions: ['users.create', 'events.view'] },
superId,
);
limitedRoleId = limitedRole.id;
const limitedIns = await db('admin_users').insert({
username: 'limited_inviter', email: 'limited_inviter@example.com', password_hash: 'x',
role_id: limitedRoleId, must_change_password: false, created_at: new Date(),
}).returning('id');
limitedId = limitedIns[0]?.id ?? limitedIns[0];
const powerfulRole = await svc.createRole(
{ name: 'powerful_invite_role', permissions: ['users.create', 'settings.banking'] },
superId,
);
powerfulRoleId = powerfulRole.id;
const modestRole = await svc.createRole(
{ name: 'modest_invite_role', permissions: ['events.view'] },
superId,
);
modestRoleId = modestRole.id;
clearPermissionCache();
}, 120000);
function nextEmail() {
inviteCounter += 1;
return `invitee-${inviteCounter}@example.com`;
}
it('refuses to let an admin invite someone into a role carrying permissions the admin lacks', async () => {
await expect(
svc.createInvitation({
email: nextEmail(),
roleId: powerfulRoleId,
invitedById: limitedId,
inviterRoleName: 'limited_inviter',
}),
).rejects.toThrow(/only grant permissions your own role/i);
});
it('allows an admin to invite someone into a role whose permissions it already holds', async () => {
const invitation = await svc.createInvitation({
email: nextEmail(),
roleId: limitedRoleId,
invitedById: limitedId,
inviterRoleName: 'limited_inviter',
});
expect(invitation.role).toBeTruthy();
});
it('allows an admin to invite someone into a role that is a subset of its own permissions', async () => {
const invitation = await svc.createInvitation({
email: nextEmail(),
roleId: modestRoleId,
invitedById: limitedId,
inviterRoleName: 'limited_inviter',
});
expect(invitation.role).toBeTruthy();
});
it('super_admin can still invite into any role, including one carrying more permissions than a limited actor holds', async () => {
const invitation = await svc.createInvitation({
email: nextEmail(),
roleId: powerfulRoleId,
invitedById: superId,
inviterRoleName: 'super_admin',
});
expect(invitation.role).toBeTruthy();
});
});
@@ -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();
});
}
});
@@ -0,0 +1,122 @@
/**
* Bounded, reclaimable storage reads for archiver downloads (#1399 follow-up).
*
* archiver drains the sources it is handed one at a time, so appending a
* storage read per photo opens N and drains one. Every other read parks its
* socket holding unread bytes, and nothing reclaims them: archiver's abort()
* does not touch source streams, and the S3 SDK clears its socket timeout as
* soon as response headers land. That is the mechanism behind the incident in
* PR #1402 43 of 50 pooled sockets held, uploads starved, restart required.
*
* #1402 fixes the cached-zip builder. These are the guarantees the same guard
* has to give the three remaining call sites, two of which need no admin
* credentials to reach.
*/
const { Readable } = require('stream');
const { createArchiveStreamGuard } = require('../../src/utils/archiveStreamGuard');
const makeStream = () => new Readable({ read() {} });
describe('archiveStreamGuard (#1399 follow-up)', () => {
it('lets the configured number of reads run at once', async () => {
const guard = createArchiveStreamGuard({ maxInFlight: 2 });
expect(await guard.acquire()).toBe(true);
guard.track(makeStream());
expect(await guard.acquire()).toBe(true);
guard.track(makeStream());
expect(guard.openCount).toBe(2);
});
it('parks the next acquire until a read finishes', async () => {
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
await guard.acquire();
const first = guard.track(makeStream());
let resumed = false;
const pending = guard.acquire().then((ok) => { resumed = ok; });
await new Promise((r) => setImmediate(r));
expect(resumed).toBe(false); // still parked — this is the cap doing its job
first.push(null);
first.resume();
await pending;
expect(resumed).toBe(true);
});
it('releases a slot when a read errors, not just when it ends', async () => {
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
await guard.acquire();
const stream = guard.track(makeStream());
stream.on('error', () => {});
stream.destroy(new Error('socket died'));
// Without the error listener the slot would never come back and the next
// photo would park forever.
expect(await guard.acquire()).toBe(true);
});
it('reports a failed read so the caller can abort the archive', async () => {
// A stream that errors while still QUEUED has no archiver listener on it
// yet. Releasing its slot and saying nothing leaves a dead stream in the
// queue, and the archive hangs when it reaches it.
const seen = [];
const guard = createArchiveStreamGuard({ maxInFlight: 2, onFatalError: (e) => seen.push(e) });
await guard.acquire();
const queued = guard.track(makeStream());
queued.on('error', () => {});
queued.destroy(new Error('socket died'));
await new Promise((r) => setImmediate(r)); // 'error' lands on the next tick
expect(seen).toHaveLength(1);
expect(seen[0].message).toBe('socket died');
});
it('stays quiet about reads it destroyed itself', async () => {
// destroyAll is the caller's own teardown; reporting those back as fatal
// would re-enter the abort path it is already running.
const seen = [];
const guard = createArchiveStreamGuard({ onFatalError: (e) => seen.push(e) });
await guard.acquire();
const s1 = guard.track(makeStream());
s1.on('error', () => {});
guard.destroyAll();
await new Promise((r) => setImmediate(r));
expect(seen).toHaveLength(0);
});
it('destroys every read still holding bytes', async () => {
const guard = createArchiveStreamGuard({ maxInFlight: 5 });
const streams = [makeStream(), makeStream(), makeStream()];
for (const s of streams) { await guard.acquire(); guard.track(s); }
expect(guard.openCount).toBe(3);
guard.destroyAll();
expect(streams.every((s) => s.destroyed)).toBe(true);
expect(guard.openCount).toBe(0);
});
it('wakes a parked acquire on destroyAll so the loop can exit', async () => {
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
await guard.acquire();
guard.track(makeStream());
const pending = guard.acquire();
guard.destroyAll();
// false, so the caller breaks out instead of appending to a dead archive.
expect(await pending).toBe(false);
});
it('destroys a stream tracked after shutdown rather than leaking it', () => {
const guard = createArchiveStreamGuard();
guard.destroyAll();
const late = guard.track(makeStream());
expect(late.destroyed).toBe(true);
expect(guard.openCount).toBe(0);
});
it('tolerates destroyAll twice — exit paths overlap', () => {
const guard = createArchiveStreamGuard();
guard.track(makeStream());
guard.destroyAll();
expect(() => guard.destroyAll()).not.toThrow();
});
});
@@ -0,0 +1,90 @@
/**
* Passwords must not survive in the email archive (see the module header of
* utils/emailSecretRedaction.js).
*/
const { secretValues, redactEmailData, redactRenderedHtml, replaceMaskedSecrets, parseEmailData, MASK } = require('../../src/utils/emailSecretRedaction');
describe('email secret redaction', () => {
const data = {
customer_name: 'Ada', gallery_link: 'https://p.example/gallery/x/tok',
gallery_password: 'Sunset-42!', client_password: '1234', welcome_message: 'hi',
attachments: [{ filename: 'a.pdf', password: 'zip-secret' }],
};
it('finds the secrets by key name, nested included, and skips sentinels', () => {
expect(secretValues(data).sort()).toEqual(['1234', 'Sunset-42!', 'zip-secret']);
expect(secretValues({ gallery_password: '{{password_security_message}}' })).toEqual([]);
expect(secretValues({ gallery_password: 'No password required' })).toEqual([]);
expect(secretValues({ gallery_password: '(set at creation)' })).toEqual([]);
expect(secretValues({ gallery_password: '' })).toEqual([]);
});
it('masks the secrets in the variables and leaves everything else alone', () => {
const redacted = redactEmailData(data);
expect(redacted.gallery_password).toBe(MASK);
expect(redacted.client_password).toBe(MASK);
expect(redacted.attachments[0].password).toBe(MASK);
expect(redacted.attachments[0].filename).toBe('a.pdf');
expect(redacted.customer_name).toBe('Ada');
expect(redacted.gallery_link).toBe(data.gallery_link);
// sentinels stay readable
expect(redactEmailData({ gallery_password: 'No password required' }).gallery_password).toBe('No password required');
// the input is not mutated
expect(data.gallery_password).toBe('Sunset-42!');
});
it('strips the secrets from the rendered HTML, plain and HTML-escaped', () => {
const html = '<li>Password: Sunset-42!</li><li>PIN: 1234</li><p>Tom &amp; Ada&#39;s day</p>';
const out = redactRenderedHtml(html, secretValues({ ...data, client_password: 'Tom & Ada\'s day' }));
expect(out).toContain(`Password: ${MASK}`);
expect(out).not.toContain('Sunset-42!');
expect(out).toContain(`<p>${MASK}</p>`);
expect(redactRenderedHtml(html, [])).toBe(html);
expect(redactRenderedHtml(null, ['x'])).toBeNull();
});
it('parses stored email_data leniently', () => {
expect(parseEmailData('{"a":1}')).toEqual({ a: 1 });
expect(parseEmailData({ a: 1 })).toEqual({ a: 1 });
expect(parseEmailData('not json')).toEqual({});
expect(parseEmailData(null)).toEqual({});
});
it('ignores the pipeline sentinels but not a brace-wrapped real password', () => {
expect(secretValues({ gallery_password: '(set at creation)', client_password: 'No password required' })).toEqual([]);
expect(secretValues({ gallery_password: '{{Sunset-42!}}' })).toEqual(['{{Sunset-42!}}']);
});
it('replaceMaskedSecrets swaps archive masks for the security sentinel, leaves the rest', () => {
const out = replaceMaskedSecrets({ customer_name: 'Ada', gallery_password: MASK, client_password: MASK, nested: { pin: MASK, note: MASK } });
expect(out).toEqual({
customer_name: 'Ada',
gallery_password: '{{password_security_message}}',
client_password: '{{password_security_message}}',
nested: { pin: '{{password_security_message}}', note: MASK },
});
});
it('masks a raw secret that the template turned into markup', () => {
const html = '<p>PIN: Se<cr3t>Pin42! and Se&lt;cr3t&gt;Pin42!</p>';
expect(redactRenderedHtml(html, ['Se<cr3t>Pin42!'])).toBe(`<p>PIN: ${MASK} and ${MASK}</p>`);
});
it('masks a secret in a quoted attribute value that contains ">"', () => {
const html = '<a title="Sunset42 > details" href="/x">Sunset42</a>';
expect(redactRenderedHtml(html, ['Sunset42'])).toBe(`<a title="${MASK} > details" href="/x">${MASK}</a>`);
// unbalanced quote: the broken tag is treated as text, still scrubbed
expect(redactRenderedHtml('<a title="Sunset42>Sunset42</a>', ['Sunset42'])).toBe(`<a title="${MASK}>${MASK}</a>`);
});
it('masks a secret inside an HTML comment, ">" included', () => {
const html = '<!-- PIN: 7788 --><p>x</p><!-- a > 7788 -->';
expect(redactRenderedHtml(html, ['7788'])).toBe(`<!-- PIN: ${MASK} --><p>x</p><!-- a > ${MASK} -->`);
});
it('masks overlapping secrets completely and leaves markup alone', () => {
const html = '<p>Password: Sunset-42! PIN: Sunset-42!7788</p><a href="https://x.example/?p=Sunset-42!" style="color:red" title=7788>href</a>';
const out = redactRenderedHtml(html, ['Sunset-42!', 'Sunset-42!7788', 'href', 'style', '7788']);
expect(out).toBe(`<p>Password: ${MASK} PIN: ${MASK}</p><a href="https://x.example/?p=${MASK}" style="color:red" title=${MASK}>${MASK}</a>`);
});
});
@@ -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 };
+1 -1
View File
@@ -87,7 +87,7 @@ When rate limits are exceeded, the following is logged:
## Configuration Settings
The general limiter reads these keys from `app_settings` (cached for 60 seconds). There is no admin screen for them yet; they are set through `PUT /api/admin/settings/security/rate-limit` (all six fields required) or directly in the table, JSON-encoded. The defaults below are what applies when a key has no row — a fresh install has none. Note that the route only updates rows that already exist: on a fresh install it answers 200 without writing anything, so insert the six rows directly first.
The general limiter reads these keys from `app_settings` (cached for 60 seconds). They are edited in the admin panel under Settings → Security (the API rate limiter card), which saves through `PUT /api/admin/settings/security/rate-limit` (all six fields required). The route upserts, so a fresh install needs no rows first, and the live limiters are rebuilt on save, so a new window applies without a restart. The defaults below are what applies when a key has no row; the settings read fills them in, so the form shows the budget in force.
| Setting | Default | Range | Description |
|---------|---------|-------|-------------|
+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,28 @@
const { addColumnIfNotExists } = require('../helpers');
/**
* Opt-in recoverable storage for gallery passwords (#1271).
*
* Both columns hold an AES-256-GCM ciphertext (see utils/galleryPasswordVault)
* and stay NULL unless the security setting
* `security_gallery_password_recoverable` is on. The bcrypt hashes remain the
* only thing the login path reads; these columns exist so an admin can show
* or resend a password without regenerating it.
*/
exports.up = async function (knex) {
await addColumnIfNotExists(knex, 'events', 'password_recoverable', (table) => {
table.text('password_recoverable').nullable();
});
await addColumnIfNotExists(knex, 'events', 'client_password_recoverable', (table) => {
table.text('client_password_recoverable').nullable();
});
};
exports.down = async function (knex) {
if (await knex.schema.hasColumn('events', 'password_recoverable')) {
await knex.schema.alterTable('events', (table) => table.dropColumn('password_recoverable'));
}
if (await knex.schema.hasColumn('events', 'client_password_recoverable')) {
await knex.schema.alterTable('events', (table) => table.dropColumn('client_password_recoverable'));
}
};
@@ -0,0 +1,36 @@
/**
* `events.external_watch` per-event opt-in for the external-media folder
* watcher (issue 1187).
*
* Managed uploads are picked up by fileWatcher.js as soon as they land in
* storage/events/active. A reference-mode event has no equivalent: new files
* copied into its NAS folder sit there until an admin opens the event and
* presses Import. services/externalMediaWatcher.js closes that gap for events
* that ask for it.
*
* Opt-in per event rather than a global switch: every watched folder is a set
* of inotify handles (or, on a mount that does not deliver events, a polling
* stat of the whole tree), and a large install with hundreds of reference
* events should not pay that for the ones nobody is still adding files to.
*
* Boolean with a false default so an existing install changes nothing on
* upgrade the column is read through formatBoolean() so SQLite's 0/1 and
* Postgres' true/false both work.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasColumn('events', 'external_watch'))) {
await knex.schema.alterTable('events', (table) => {
table.boolean('external_watch').notNullable().defaultTo(false);
});
console.log('208: added events.external_watch');
}
};
exports.down = async function (knex) {
if (await knex.schema.hasColumn('events', 'external_watch')) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('external_watch');
});
}
};
@@ -0,0 +1,35 @@
/**
* `external_import_exclusions` files an admin deleted from a reference
* event, so the folder watcher (issue 1187) does not bring them back.
*
* Deleting an external photo removes its row but leaves the NAS original
* alone (resolvePhotoStorageKey returns null for external rows, on purpose).
* The manual Import only ran when an admin pressed it, so the deleted file
* came back only if they asked. The watcher runs on its own, and a full pass
* that skips only rows the event still has would re-import every deleted
* photo on the next sweep republishing what an admin removed, without any
* new file arriving.
*
* One row per (event, root-relative path). Automatic passes skip these; the
* manual Import button ignores the list and clears the rows for whatever it
* imports, since pressing it is the explicit intent the exclusion exists to
* protect.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('external_import_exclusions'))) {
await knex.schema.createTable('external_import_exclusions', (t) => {
t.increments('id').primary();
t.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
// Same shape as photos.external_relpath: relative to EXTERNAL_MEDIA_ROOT.
t.text('external_relpath').notNullable();
t.timestamp('created_at').defaultTo(knex.fn.now());
t.unique(['event_id', 'external_relpath'], { indexName: 'external_import_exclusions_event_relpath_unique' });
});
console.log('209: created external_import_exclusions');
}
};
exports.down = async function (knex) {
await knex.schema.dropTableIfExists('external_import_exclusions');
};
@@ -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'));
}
};
@@ -0,0 +1,29 @@
/**
* Migration 213: TOTP replay protection for admin MFA (GHSA-qcwx-r25m-j869).
*
* verifyTotp()/verifyTotpEncrypted() were stateless: otplib's window:1
* tolerance means a captured 6-digit code stays valid across several real
* time-steps (~90s), so the same code could complete two independent admin
* logins. `two_factor_last_used_step` tracks, per admin, the absolute TOTP
* time-step (Math.floor(Date.now() / 30000)) that their last successfully
* consumed code matched; mfaService now rejects a code whose matched step
* doesn't advance past it.
*
* Additive and idempotent: only adds a column, guarded by hasColumn, so it
* is safe to re-run and touches no existing data.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasColumn('admin_users', 'two_factor_last_used_step'))) {
await knex.schema.alterTable('admin_users', (t) => {
t.integer('two_factor_last_used_step').nullable();
});
}
};
exports.down = async function (knex) {
if (await knex.schema.hasColumn('admin_users', 'two_factor_last_used_step')) {
await knex.schema.alterTable('admin_users', (t) => {
t.dropColumn('two_factor_last_used_step');
});
}
};
+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.127.2-beta.0",
"version": "3.131.7-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",
+1
View File
@@ -36,6 +36,7 @@ const MFA_CLEAR = {
two_factor_secret: null,
two_factor_recovery_codes: null,
two_factor_enrolled_at: null,
two_factor_last_used_step: null,
updated_at: new Date(),
};
+75 -55
View File
@@ -88,7 +88,7 @@ const backgroundProcessor = require('./src/services/backgroundProcessor');
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
const { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler');
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
const rateLimitService = require('./src/services/rateLimitService');
const { createApiRateLimitGate } = require('./src/middleware/apiRateLimitGate');
const { createAuthRateLimitGate } = require('./src/middleware/authRateLimitGate');
const { getPublicSitePayload } = require('./src/services/publicSiteService');
@@ -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) {
@@ -293,8 +293,6 @@ app.get(['/health', '/api/health'], async (req, res) => {
});
// Initialize rate limiters (they will be created dynamically)
let generalRateLimiter;
let authRateLimiter;
function composeInlineStyles(payload) {
const { branding } = payload;
@@ -487,8 +485,10 @@ async function handlePublicSiteRequest(req, res, next) {
// Function to initialize rate limiters
async function initializeRateLimiters() {
generalRateLimiter = await createRateLimiter();
authRateLimiter = await createAuthRateLimiter();
// The instances live in rateLimitService so the settings route can
// rebuild them when the window changes (#1337); the gates below read
// them per request through the service's getters.
await rateLimitService.initializeRateLimiters();
// Neither limiter is registered here — an app.use() at this point runs after
// the routers, the /api 404 handler and the error handler are already on the
@@ -511,13 +511,13 @@ async function initializeRateLimiters() {
// must stay unmounted (no path argument) so req.path keeps its /api prefix.
// /health and /api/health are mounted above this point and so are never
// counted, which matters because monitors poll them every couple of seconds.
app.use(createApiRateLimitGate(() => generalRateLimiter));
app.use(createApiRateLimitGate(rateLimitService.getGeneralLimiter));
// Per-IP limit for credential-verification endpoints only, on its own bucket.
// Registered after the general gate so that an IP already over the /api budget
// is rejected there first; see authRateLimitGate for the exact endpoint table
// and why it must stay unmounted.
app.use(createAuthRateLimitGate(() => authRateLimiter));
app.use(createAuthRateLimitGate(rateLimitService.getAuthLimiter));
// Body limits. 50mb is only needed by the authenticated admin and API-token
// surfaces (restore manifests, CMS and email templates, bulk operations);
@@ -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,16 +1113,19 @@ 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
// reference events that opted in. Not gated on STORAGE_BACKEND like the
// managed watcher — EXTERNAL_MEDIA_ROOT is always a local path.
try {
const { startExternalMediaWatcher } = require('./src/services/externalMediaWatcher');
startExternalMediaWatcher();
} catch (err) {
logger.warn('External-media watcher failed to start:', err.message);
}
// Start expiration checker
startExpirationChecker();
@@ -1140,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();
@@ -1297,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'}`);
@@ -1316,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();
};
+33 -100
View File
@@ -1,12 +1,21 @@
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');
// GHSA-h4w8-57xq-53fx: must_change_password was written on reset (and on
// invitation/OIDC-bypass paths) but nothing server-side ever checked it — a
// forced-reset admin could keep using the old/weak password indefinitely
// because the flag only ever reached the frontend as a response field. The
// frontend already renders a blocking modal for it (MandatoryPasswordChangeModal),
// this is the backstop for callers that skip the UI entirely. Every route
// gated by adminAuth() is blocked except the ones a flagged admin needs to
// clear the flag or leave: change their password, and log out.
const MUST_CHANGE_PASSWORD_EXEMPT_PATHS = new Set([
'/api/admin/auth/change-password',
'/api/admin/auth/logout',
]);
/**
* Enhanced admin authentication middleware with revocation checking
*/
@@ -16,7 +25,7 @@ async function adminAuth(req, res, next) {
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
@@ -31,101 +40,21 @@ 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' });
// includeProfile: true — need must_change_password for the enforcement
// check below on every request, not just the profile/session-check routes.
const admin = await sessionAccess.admin(decoded, { includeProfile: true });
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 });
}
// 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
if (admin.must_change_password
&& !MUST_CHANGE_PASSWORD_EXEMPT_PATHS.has(req.originalUrl.split('?')[0])) {
return res.status(403).json({
error: 'Password change required before continuing',
code: 'MUST_CHANGE_PASSWORD'
});
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'
});
}
}
// Add user info to request (enhanced with role)
@@ -135,6 +64,7 @@ async function adminAuth(req, res, next) {
email: admin.email,
roleId: admin.role_id,
roleName: admin.role_name,
mustChangePassword: !!admin.must_change_password,
// From the token, not the database: it is a property of this session
// rather than of the account (#1186). Carried so a route that reissues
// the token — change-password — can preserve the choice instead of
@@ -142,11 +72,14 @@ async function adminAuth(req, res, next) {
rememberMe: decoded.rememberMe === true
};
req.token = token; // Store token for potential revocation
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 }),
});
}
}
+22
View File
@@ -0,0 +1,22 @@
const { mutationOriginAllowed } = require('../utils/requestOrigin');
// The origin check is the CSRF defence. This list only has to keep out what
// a cross-site page can send without a preflight: a form cannot produce JSON
// or octet-stream, and fetch() with either is not CORS-safelisted.
// octet-stream is how the chunked upload route receives its raw body
// (#1377); express.json leaves it unread for everything else.
const ALLOWED_CONTENT_TYPES = ['application/json', 'multipart/form-data', 'application/octet-stream'];
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 allowed = ALLOWED_CONTENT_TYPES.includes(contentType) || contentType.endsWith('+json');
if (hasBody && !allowed) {
return res.status(415).json({ error: `Unsupported Content-Type. Use ${ALLOWED_CONTENT_TYPES.join(', ')}.` });
}
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)));
};
/**

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